Skip to content

Commit 254c109

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

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

.github/scripts/backlog-cleanup.js

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

.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)