blob: 5fab357387bc6e0d2de5a19555dd0c3f51298768 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
export const STALE_WARNING_MARKER = '<!-- maka-lifecycle:stale-warning -->';
export const STALE_CLOSE_MARKER = '<!-- maka-lifecycle:stale-close -->';
export const STALE_REOPEN_MARKER = '<!-- maka-lifecycle:reopened -->';
export const LIFECYCLE_LABELS = [
{
name: 'stale',
color: 'ededed',
description: 'No qualifying activity within the lifecycle policy window',
},
{
name: 'pinned',
color: '0e8a16',
description: 'Exempt from automated lifecycle management',
},
];
const POLICY = {
issue: { inactiveDays: 30, graceDays: 30 },
pull_request: { inactiveDays: 30, graceDays: 7 },
};
const ISSUE_WARNING = `${STALE_WARNING_MARKER}
This issue has had no human activity for 30 days and has been marked stale. It will be closed in 30 days unless someone comments.
If the issue is still current, please confirm it against the latest \`main\` and add any information that would help move it forward. Assigned issues and issues labelled \`pinned\` are exempt from this policy.`;
const ISSUE_CLOSE = `${STALE_CLOSE_MARKER}
This issue has been closed because it received no human response during the 30-day grace period. This is not a judgement on the value of the report.
If the issue still applies to the latest \`main\`, please comment with updated evidence so a maintainer can reopen it, or open a follow-up issue with the missing information.`;
const PR_WARNING = `${STALE_WARNING_MARKER}
This pull request has had no new commits for 30 days and has been marked stale. It will be closed in 7 days unless a new commit is pushed.
Comments do not reset this timer: only a new commit does. If the pull request is intentionally long-lived, a maintainer can apply the \`pinned\` label.`;
const PR_CLOSE = `${STALE_CLOSE_MARKER}
This pull request has been closed because it received no new commits during the 7-day grace period. This is not a judgement on the merit of the change; it only keeps the review queue aligned with work that is actively in flight.
The branch and review history are preserved. Push a new commit and reopen the pull request whenever the work is ready to continue.`;
function time(value, field) {
const result = Date.parse(value);
if (!Number.isFinite(result)) throw new TypeError(`${field} must be an ISO timestamp`);
return result;
}
function isBot(comment) {
return (
comment.author?.__typename === 'Bot' ||
String(comment.author?.login ?? '') === 'github-actions[bot]'
);
}
function latestTimestamp(values, fallback, field) {
return values.reduce((latest, value) => Math.max(latest, time(value, field)), fallback);
}
function issueActivityAt(item) {
const createdAt = time(item.createdAt, 'createdAt');
const qualifyingComments = (item.comments ?? [])
.filter(
(comment) => !isBot(comment) || String(comment.body ?? '').includes(STALE_REOPEN_MARKER),
)
.map((comment) => comment.createdAt);
return latestTimestamp(qualifyingComments, createdAt, 'comment.createdAt');
}
function pullRequestActivityAt(item) {
return time(item.lastCommitAt ?? item.createdAt, 'lastCommitAt');
}
function latestWarningAt(item) {
const warnings = (item.comments ?? [])
.filter(
(comment) => isBot(comment) && String(comment.body ?? '').includes(STALE_WARNING_MARKER),
)
.map((comment) => comment.createdAt);
return warnings.length === 0
? undefined
: latestTimestamp(warnings, Number.NEGATIVE_INFINITY, 'comment.createdAt');
}
function atLeastDaysBetween(later, earlier, days) {
return later - earlier >= days * DAY_MS;
}
/**
* Decide one lifecycle transition without performing GitHub API writes.
*
* Issues use their creation or latest non-bot comment as activity and are
* exempt while assigned. Pull requests use only their latest commit. Both are
* exempt when pinned, and grace starts at this workflow's latest warning.
*
* @param {{
* kind: 'issue' | 'pull_request',
* createdAt: string,
* lastCommitAt?: string,
* labels?: string[],
* assigneeCount?: number,
* comments?: Array<{createdAt: string, body?: string, author?: {__typename?: string, login?: string}}>,
* }} item
* @param {string | Date} [now]
*/
export function planLifecycle(item, now = new Date()) {
const policy = POLICY[item.kind];
if (!policy) throw new TypeError(`unsupported lifecycle item kind: ${item.kind}`);
const nowAt = now instanceof Date ? now.getTime() : time(now, 'now');
if (!Number.isFinite(nowAt)) throw new TypeError('now must be a valid date');
const labels = new Set(item.labels ?? []);
const isStale = labels.has('stale');
const exempt = labels.has('pinned') || (item.kind === 'issue' && (item.assigneeCount ?? 0) > 0);
if (exempt) {
return {
action: isStale ? 'unstale' : 'none',
reason: labels.has('pinned') ? 'pinned' : 'assigned',
};
}
const activityAt = item.kind === 'issue' ? issueActivityAt(item) : pullRequestActivityAt(item);
const warningAt = latestWarningAt(item);
if (isStale) {
if (warningAt === undefined) {
return { action: 'warn', message: item.kind === 'issue' ? ISSUE_WARNING : PR_WARNING };
}
if (activityAt > warningAt) {
return { action: 'unstale', reason: 'qualifying activity after warning' };
}
if (atLeastDaysBetween(nowAt, warningAt, policy.graceDays)) {
return { action: 'close', message: item.kind === 'issue' ? ISSUE_CLOSE : PR_CLOSE };
}
return { action: 'none', reason: 'within grace period' };
}
if (atLeastDaysBetween(nowAt, activityAt, policy.inactiveDays)) {
return { action: 'warn', message: item.kind === 'issue' ? ISSUE_WARNING : PR_WARNING };
}
return { action: 'none', reason: 'recent qualifying activity' };
}