Skip to content

Commit b8e3c9b

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

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+
constdedent=(strings, ...values)=>{
18+
constraw=typeofstrings==='string' ? [strings] : strings.raw;
19+
letresult='';
20+
raw.forEach((str,i)=>{
21+
result+=str+(values[i]||'');
22+
});
23+
constlines=result.split('\n');
24+
constminIndent=Math.min(...lines.filter(l=>l.trim()).map(l=>l.match(/^\s*/)[0].length));
25+
returnlines.map(l=>l.slice(minIndent)).join('\n').trim();
26+
};
27+
28+
29+
asyncfunctionaddMoveToDiscussionLabel(github,owner,repo,issue,isDryRun){
30+
consttargetLabel="Move to Discussion";
31+
32+
consthasLabel=issue.labels.some(
33+
l=>l.name.toLowerCase()===targetLabel.toLowerCase()
34+
);
35+
36+
if(hasLabel)returnfalse;
37+
38+
if(isDryRun){
39+
console.log(`[DRY-RUN] Would add '${targetLabel}' to issue #${issue.number}`);
40+
returntrue;
41+
}
42+
43+
try{
44+
awaitgithub.rest.issues.addLabels({
45+
owner,
46+
repo,
47+
issue_number: issue.number,
48+
labels: [targetLabel],
49+
});
50+
returntrue;
51+
52+
}catch(err){
53+
console.error(`Failed to add label to #${issue.number}`,err);
54+
returnfalse;
55+
}
56+
}
57+
58+
59+
asyncfunctionfetchAllOpenIssues(github,owner,repo){
60+
constissues=[];
61+
letpage=1;
62+
63+
while(true){
64+
try{
65+
constresponse=awaitgithub.rest.issues.listForRepo({
66+
owner,
67+
repo,
68+
state: 'open',
69+
per_page: 100,
70+
page,
71+
});
72+
constdata=response.data||[];
73+
if(data.length===0)break;
74+
constonlyIssues=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+
returnissues;
84+
}
85+
86+
87+
asyncfunctionhasRecentFriendlyReminder(github,owner,repo,issueNumber,maxAgeMs){
88+
letcomments=[];
89+
letpage=1;
90+
91+
while(true){
92+
const{ data }=awaitgithub.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+
constreminders=comments
107+
.filter(c=>
108+
c.user.login==='github-actions[bot]'&&
109+
c.body.includes('⏰ Friendly Reminder')
110+
)
111+
.sort((a,b)=>newDate(b.created_at)-newDate(a.created_at));
112+
113+
if(reminders.length===0)returnfalse;
114+
115+
constmostRecent=newDate(reminders[0].created_at);
116+
return(Date.now()-mostRecent.getTime())<maxAgeMs;
117+
}
118+
119+
120+
module.exports=async({ github, context, dryRun })=>{
121+
constnow=newDate();
122+
constthresholdDays=90;
123+
constexemptLabels=['Status: Community help needed','Status: Needs investigation'];
124+
constcloseLabels=['Status: Awaiting Response'];
125+
constquestionLabel='Type: Question';
126+
const{ owner, repo }=context.repo;
127+
constsevenDaysMs=7*24*60*60*1000;
128+
129+
constisDryRun=dryRun==="1";
130+
if(isDryRun){
131+
console.log("DRY-RUN mode enabled — no changes will be made.");
132+
}
133+
134+
lettotalClosed=0;
135+
lettotalReminders=0;
136+
lettotalSkipped=0;
137+
lettotalMarkedToMigrate=0;
138+
139+
letissues=[];
140+
141+
try{
142+
issues=awaitfetchAllOpenIssues(github,owner,repo);
143+
}catch(err){
144+
console.error('Failed to fetch issues:',err);
145+
return;
146+
}
147+
148+
for(constissueofissues){
149+
constisAssigned=issue.assignees&&issue.assignees.length>0;
150+
constlastUpdate=newDate(issue.updated_at);
151+
constdaysSinceUpdate=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+
constmarked=awaitaddMoveToDiscussionLabel(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+
awaitgithub.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+
awaitgithub.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(awaithasRecentFriendlyReminder(github,owner,repo,issue.number,sevenDaysMs)){
200+
totalSkipped++;
201+
continue;
202+
}
203+
204+
constassignees=issue.assignees.map(u=>`@${u.login}`).join(', ');
205+
constcomment=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+
awaitgithub.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)