Skip to content

Commit a661322

Browse files
committed
feat: add stale/abandoned PR cleanup workflows
1 parent c176b8e commit a661322

2 files changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
name: PR Stale and Abandon Tracker
3+
4+
on:
5+
schedule:
6+
- cron: '0 2 * * *' # Daily at 2:00 AM UTC
7+
workflow_dispatch: # Allows manual trigger for testing
8+
9+
permissions:
10+
pull-requests: write
11+
issues: write
12+
contents: read
13+
14+
jobs:
15+
stale_tracker:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
21+
- name: Trace and Mark Stale PRs
22+
uses: actions/github-script@v7
23+
with:
24+
script: |
25+
const path =
26+
'./.github/workflows/scripts/pr-cron-stale-abandon.js';
27+
const script = require(path);
28+
await script({ github, context });
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @param {Object} params
3+
* @param {import('@actions/github').GitHub} params.github
4+
* @param {import('@actions/github').Context} params.context
5+
*/
6+
module.exports = async function staleTracker({ github, context }) {
7+
const { owner, repo } = context.repo;
8+
9+
// Threshold Config (abandon is an additional 30 days on top of stale)
10+
const STALE_THRESHOLD_DAYS = 30;
11+
const ADDITIONAL_ABANDON_DAYS = 30;
12+
const ABANDON_THRESHOLD_DAYS =
13+
STALE_THRESHOLD_DAYS + ADDITIONAL_ABANDON_DAYS;
14+
15+
// Label Constants
16+
const LABEL_UNDER_REVIEW = 'status:under-review';
17+
const LABEL_BLOCKED = 'blocked';
18+
const LABEL_STALE_REVIEW = 'status:stale-review';
19+
const LABEL_NEEDS_TRIAGE = 'status:needs-triage';
20+
const LABEL_ABANDON_CANDIDATE = 'status:abandon-candidate';
21+
22+
const staleLimit =
23+
new Date(Date.now() - STALE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000);
24+
const abandonLimit =
25+
new Date(Date.now() - ABANDON_THRESHOLD_DAYS * 24 * 60 * 60 * 1000);
26+
27+
console.log(`[CONFIG] Stale limit: >${STALE_THRESHOLD_DAYS} days (${staleLimit.toISOString()})`);
28+
console.log(`[CONFIG] Abandon limit: >${ABANDON_THRESHOLD_DAYS} days (${abandonLimit.toISOString()})`);
29+
30+
// Use native Octokit paginate to list ALL open pull requests
31+
const pulls = await github.paginate(github.rest.pulls.list, {
32+
owner,
33+
repo,
34+
state: 'open',
35+
per_page: 100
36+
});
37+
const totalScanned = pulls.length;
38+
39+
let staleFound = 0, staleLabeled = 0, abandonFound = 0, abandonLabeled = 0;
40+
let alreadyLabeledCount = 0;
41+
42+
console.log(`[FETCH] Retrieved ${totalScanned} open PRs to evaluate.`);
43+
44+
for (const pr of pulls) {
45+
const updatedDate = new Date(pr.updated_at);
46+
const labels = pr.labels.map(l => l.name);
47+
48+
const isStale = updatedDate < staleLimit;
49+
const isAbandonCandidate = updatedDate < abandonLimit;
50+
51+
// Determine matching category
52+
const isStaleReview = isStale && labels.includes(LABEL_UNDER_REVIEW);
53+
const isBlockedAbandon =
54+
isAbandonCandidate && labels.includes(LABEL_BLOCKED);
55+
56+
if (!isStaleReview && !isBlockedAbandon) continue;
57+
58+
console.log(
59+
`[INACTIVE] PR #${pr.number} "${pr.title}" ` +
60+
`is inactive since ${pr.updated_at}`
61+
);
62+
63+
// Select target labels based on matching category
64+
const targetLabels = isBlockedAbandon
65+
? [LABEL_STALE_REVIEW, LABEL_ABANDON_CANDIDATE, LABEL_NEEDS_TRIAGE]
66+
: [LABEL_STALE_REVIEW, LABEL_NEEDS_TRIAGE];
67+
68+
if (isStaleReview) staleFound++;
69+
if (isBlockedAbandon) abandonFound++;
70+
71+
// Keep only labels that are missing from the PR
72+
const missingLabels = targetLabels.filter(l => !labels.includes(l));
73+
74+
if (missingLabels.length > 0) {
75+
console.log(
76+
`[ACTION] PR #${pr.number} "${pr.title}" is inactive. ` +
77+
`Adding missing labels: ${missingLabels}`
78+
);
79+
await github.rest.issues.addLabels({
80+
owner,
81+
repo,
82+
issue_number: pr.number,
83+
labels: missingLabels
84+
});
85+
86+
let commentBody = '';
87+
88+
// Abandon candidate notification comment
89+
if (
90+
isBlockedAbandon &&
91+
missingLabels.includes(LABEL_ABANDON_CANDIDATE)
92+
) {
93+
commentBody =
94+
`This pull request has been blocked and inactive for ` +
95+
`${ABANDON_THRESHOLD_DAYS} days. ` +
96+
`It has been marked as an abandon candidate. ` +
97+
`Please resolve the blockers to resume review.`;
98+
}
99+
// Stale review notification comment
100+
else if (isStaleReview && missingLabels.includes(LABEL_STALE_REVIEW)) {
101+
commentBody =
102+
`This pull request has been inactive for ` +
103+
`${STALE_THRESHOLD_DAYS} days. ` +
104+
`Could you please provide an update or follow up on reviews?`;
105+
}
106+
107+
if (commentBody) {
108+
console.log(
109+
`[ACTION] Posting stale/abandon comment on PR #${pr.number}`
110+
);
111+
await github.rest.issues.createComment({
112+
owner,
113+
repo,
114+
issue_number: pr.number,
115+
body: commentBody
116+
});
117+
}
118+
119+
if (isStaleReview) staleLabeled++;
120+
if (isBlockedAbandon) abandonLabeled++;
121+
} else {
122+
alreadyLabeledCount++;
123+
}
124+
}
125+
126+
console.log('\n========================================');
127+
console.log(' STALE TRACKER RUN SUMMARY');
128+
console.log('========================================');
129+
console.log(`Total Open PRs Scanned: ${totalScanned}`);
130+
console.log(`PRs Already Correctly Labeled: ${alreadyLabeledCount}`);
131+
console.log('----------------------------------------');
132+
console.log(
133+
`Stale Reviews (>${STALE_THRESHOLD_DAYS}d) Found: ` +
134+
`${staleFound}`
135+
);
136+
console.log(`Stale Reviews Newly Labeled: ${staleLabeled}`);
137+
console.log('----------------------------------------');
138+
console.log(
139+
`Abandon Candidates (>${ABANDON_THRESHOLD_DAYS}d) Found: ` +
140+
`${abandonFound}`
141+
);
142+
console.log(`Abandon Candidates Newly Labeled: ${abandonLabeled}`);
143+
console.log('========================================\n');
144+
}

0 commit comments

Comments
 (0)