Skip to content

Commit bc77c00

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

File tree

2 files changed

+290
-0
lines changed

2 files changed

+290
-0
lines changed

.github/scripts/backlog-cleanup.js

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

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