Skip to content

Commit 9dd9b95

Browse files
feat(ci): add a backlog clean up bot
1 parent b859bdf commit 9dd9b95

File tree

2 files changed

+277
-0
lines changed

2 files changed

+277
-0
lines changed

.github/scripts/backlog-cleanup.js

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* GitHub Action script for managing issue backlog.
3+
*
4+
* Behavior:
5+
* - Pull Requests are skipped (only opened issues are processed)
6+
* - Skips issues with 'to-be-discussed' label.
7+
* - Closes issues with label 'awaiting-response' or without assignees,
8+
* with a standard closure comment.
9+
* - Sends a Friendly Reminder comment to assigned issues without
10+
* exempt labels that have been inactive for 90+ days.
11+
* - Avoids sending duplicate Friendly Reminder comments if one was
12+
* posted within the last 7 days.
13+
* - Marks issues labeled 'questions' by adding the 'Move to Discussion' label.
14+
* (Actual migration to Discussions must be handled manually.)
15+
*/
16+
17+
const dedent = (strings, ...values) => {
18+
const raw = typeof strings === 'string' ? [strings] : strings.raw;
19+
let result = '';
20+
raw.forEach((str, i) => {
21+
result += str + (values[i] || '');
22+
});
23+
const lines = result.split('\n');
24+
const minIndent = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^\s*/)[0].length));
25+
return lines.map(l => l.slice(minIndent)).join('\n').trim();
26+
};
27+
28+
29+
async function addMoveToDiscussionLabel(github, owner, repo, issue, isDryRun) {
30+
const targetLabel = "Move to Discussion";
31+
32+
const hasLabel = issue.labels.some(
33+
l => l.name.toLowerCase() === targetLabel.toLowerCase()
34+
);
35+
36+
if (hasLabel) return false;
37+
38+
if (isDryRun) {
39+
console.log(`[DRY-RUN] Would add '${targetLabel}' to issue #${issue.number}`);
40+
return true;
41+
}
42+
43+
try {
44+
await github.rest.issues.addLabels({
45+
owner,
46+
repo,
47+
issue_number: issue.number,
48+
labels: [targetLabel],
49+
});
50+
return true;
51+
52+
} catch (err) {
53+
console.error(`Failed to add label to #${issue.number}`, err);
54+
return false;
55+
}
56+
}
57+
58+
59+
async function fetchAllOpenIssues(github, owner, repo) {
60+
const issues = [];
61+
let page = 1;
62+
63+
while (true) {
64+
try {
65+
const response = await github.rest.issues.listForRepo({
66+
owner,
67+
repo,
68+
state: 'open',
69+
per_page: 100,
70+
page,
71+
});
72+
const data = response.data || [];
73+
if (data.length === 0) break;
74+
const onlyIssues = data.filter(issue => !issue.pull_request);
75+
issues.push(...onlyIssues);
76+
if (data.length < 100) break;
77+
page++;
78+
} catch (err) {
79+
console.error('Error fetching issues:', err);
80+
break;
81+
}
82+
}
83+
return issues;
84+
}
85+
86+
87+
async function hasRecentFriendlyReminder(github, owner, repo, issueNumber, maxAgeMs) {
88+
let comments = [];
89+
let page = 1;
90+
91+
while (true) {
92+
const { data } = await github.rest.issues.listComments({
93+
owner,
94+
repo,
95+
issue_number: issueNumber,
96+
per_page: 100,
97+
page,
98+
});
99+
100+
if (!data || data.length === 0) break;
101+
comments.push(...data);
102+
if (data.length < 100) break;
103+
page++;
104+
}
105+
106+
const reminders = comments
107+
.filter(c =>
108+
c.user.login === 'github-actions[bot]' &&
109+
c.body.includes('⏰ Friendly Reminder')
110+
)
111+
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
112+
113+
if (reminders.length === 0) return false;
114+
115+
const mostRecent = new Date(reminders[0].created_at);
116+
return (Date.now() - mostRecent.getTime()) < maxAgeMs;
117+
}
118+
119+
120+
module.exports = async ({ github, context, dryRun }) => {
121+
const now = new Date();
122+
const thresholdDays = 0;
123+
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];
124+
const closeLabels = ['Status: Awaiting Response'];
125+
const questionLabel = 'Type: Question';
126+
const { owner, repo } = context.repo;
127+
const sevenDaysMs = 0;
128+
129+
const isDryRun = dryRun === "1";
130+
if (isDryRun) {
131+
console.log("DRY-RUN mode enabled — no changes will be made.");
132+
}
133+
134+
let totalClosed = 0;
135+
let totalReminders = 0;
136+
let totalSkipped = 0;
137+
let totalMarkedToMigrate = 0;
138+
139+
let issues = [];
140+
141+
try {
142+
issues = await fetchAllOpenIssues(github, owner, repo);
143+
} catch (err) {
144+
console.error('Failed to fetch issues:', err);
145+
return;
146+
}
147+
148+
for (const issue of issues) {
149+
const isAssigned = issue.assignees && issue.assignees.length > 0;
150+
const lastUpdate = new Date(issue.updated_at);
151+
const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24));
152+
153+
if (issue.labels.some(label => exemptLabels.includes(label.name))) {
154+
totalSkipped++;
155+
continue;
156+
}
157+
158+
if (issue.labels.some(label => label.name === questionLabel)) {
159+
const marked = await addMoveToDiscussionLabel(github, owner, repo, issue, isDryRun);
160+
if (marked) totalMarkedToMigrate++;
161+
continue; // Do not apply reminder logic
162+
}
163+
164+
if (daysSinceUpdate < thresholdDays) {
165+
totalSkipped++;
166+
continue;
167+
}
168+
169+
if (issue.labels.some(label => closeLabels.includes(label.name)) || !isAssigned) {
170+
171+
if (isDryRun) {
172+
console.log(`[DRY-RUN] Would close issue #${issue.number}`);
173+
totalClosed++;
174+
continue;
175+
}
176+
177+
try {
178+
await github.rest.issues.createComment({
179+
owner,
180+
repo,
181+
issue_number: issue.number,
182+
body: '⚠️ This issue was closed automatically due to inactivity. Please reopen or open a new one if still relevant.',
183+
});
184+
await github.rest.issues.update({
185+
owner,
186+
repo,
187+
issue_number: issue.number,
188+
state: 'closed',
189+
});
190+
totalClosed++;
191+
} catch (err) {
192+
console.error(`Error closing issue #${issue.number}:`, err);
193+
}
194+
continue;
195+
}
196+
197+
if (isAssigned) {
198+
199+
if (await hasRecentFriendlyReminder(github, owner, repo, issue.number, sevenDaysMs)) {
200+
totalSkipped++;
201+
continue;
202+
}
203+
204+
const assignees = issue.assignees.map(u => `@${u.login}`).join(', ');
205+
const comment = dedent`
206+
⏰ Friendly Reminder
207+
208+
Hi ${assignees}!
209+
210+
This issue has had no activity for ${daysSinceUpdate} days. If it's still relevant:
211+
- Please provide a status update
212+
- Add any blocking details
213+
- Or label it 'Status: Awaiting Response' if you're waiting on something
214+
215+
This is just a reminder; the issue remains open for now.`;
216+
217+
if (isDryRun) {
218+
console.log(`[DRY-RUN] Would post reminder on #${issue.number}`);
219+
totalReminders++;
220+
continue;
221+
}
222+
223+
try {
224+
await github.rest.issues.createComment({
225+
owner,
226+
repo,
227+
issue_number: issue.number,
228+
body: comment,
229+
});
230+
totalReminders++;
231+
} catch (err) {
232+
console.error(`Error sending reminder for issue #${issue.number}:`, err);
233+
}
234+
}
235+
}
236+
237+
console.log(dedent`
238+
=== Backlog cleanup summary ===
239+
Total issues processed: ${issues.length}
240+
Total issues closed: ${totalClosed}
241+
Total reminders sent: ${totalReminders}
242+
Total marked to migrate to discussions: ${totalMarkedToMigrate}
243+
Total skipped: ${totalSkipped}`);
244+
};

.github/workflows/backlog-bot.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: "Backlog Management Bot"
2+
3+
on:
4+
schedule:
5+
- cron: '0 4 * * *' # Run daily at 4 AM UTC
6+
workflow_dispatch:
7+
inputs:
8+
dry-run:
9+
description: "Run without modifying issues"
10+
required: false
11+
default: "0"
12+
13+
permissions:
14+
issues: write
15+
discussions: write
16+
contents: read
17+
18+
jobs:
19+
backlog-bot:
20+
name: "Check issues"
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
25+
26+
- name: Run backlog cleanup script
27+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
28+
with:
29+
github-token: ${{ secrets.GITHUB_TOKEN }}
30+
script: |
31+
const script = require('./.github/scripts/backlog-cleanup.js');
32+
const dryRun = "${{ github.event.inputs.dry-run }}";
33+
await script({ github, context, dryRun });

0 commit comments

Comments
 (0)