Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /