| /* |
| * 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' }; |
| } |