Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
170 lines
6.4 KiB
JavaScript
170 lines
6.4 KiB
JavaScript
/*
|
|
* 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' };
|
|
}
|