Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions .github/scripts/backlog-cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/**
* GitHub Action script for managing issue backlog.
*
* Behavior:
* - Pull Requests are skipped (only opened issues are processed)
* - Skips issues with 'to-be-discussed' label.
* - Closes issues with label 'awaiting-response' or without assignees,
* with a standard closure comment.
* - Sends a Friendly Reminder comment to assigned issues without
* exempt labels that have been inactive for 90+ days.
* - Avoids sending duplicate Friendly Reminder comments if one was
* posted within the last 7 days.
* - Marks issues labeled 'questions' by adding the 'Move to Discussion' label.
* (Actual migration to Discussions must be handled manually.)
*/
Comment on lines +1 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/**
* GitHub Action script for managing issue backlog.
*
* Behavior:
* - Pull Requests are skipped (only opened issues are processed)
* - Skips issues with 'to-be-discussed' label.
* - Closes issues with label 'awaiting-response' or without assignees,
* with a standard closure comment.
* - Sends a Friendly Reminder comment to assigned issues without
* exempt labels that have been inactive for 90+ days.
* - Avoids sending duplicate Friendly Reminder comments if one was
* posted within the last 7 days.
* - Marks issues labeled 'questions' by adding the 'Move to Discussion' label.
* (Actual migration to Discussions must be handled manually.)
*/
/**
* GitHub Action script for managing issue backlog.
*
* Behavior:
* - Pull Requests are skipped (only opened issues are processed)
* - Skips issues with labels defined in 'exemptLabels'
* - Closes issues with labels defined in 'closeLabels' or without assignees,
* with a standard closure comment.
* - Sends a Friendly Reminder comment to assigned issues without
* exempt labels that have been inactive for 90+ days.
* - Avoids sending duplicate Friendly Reminder comments if one was
* posted within the last 7 days.
* - Marks issues labeled 'Type: Question' by adding the 'Move to Discussion' label.
* (Actual migration to Discussions must be handled manually due to API limitations.)
*/


const dedent = (strings, ...values) => {
const raw = typeof strings === 'string' ? [strings] : strings.raw;
let result = '';
raw.forEach((str, i) => {
result += str + (values[i] || '');
});
const lines = result.split('\n');
const minIndent = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^\s*/)[0].length));
Comment on lines +23 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid using empty lines in Math.min

Suggested change
const lines = result.split('\n');
const minIndent = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^\s*/)[0].length));
const lines = result.split('\n');
if (!lines.some(l => l.trim())) return '';
const minIndent = Math.min(...lines.filter(l => l.trim()).map(l => l.match(/^\s*/)[0].length));

return lines.map(l => l.slice(minIndent)).join('\n').trim();
};


async function addMoveToDiscussionLabel(github, owner, repo, issue, isDryRun) {
const targetLabel = "Move to Discussion";

const hasLabel = issue.labels.some(
l => l.name.toLowerCase() === targetLabel.toLowerCase()
);

if (hasLabel) return false;

if (isDryRun) {
console.log(`[DRY-RUN] Would add '${targetLabel}' to issue #${issue.number}`);
return true;
}

try {
await github.rest.issues.addLabels({
Comment on lines +43 to +44
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try {
await github.rest.issues.addLabels({
try {
console.log(`Adding label to #${issue.number} (Move to discussion)`);
await github.rest.issues.addLabels({

owner,
repo,
issue_number: issue.number,
labels: [targetLabel],
});
return true;

} catch (err) {
console.error(`Failed to add label to #${issue.number}`, err);
return false;
}
}


async function fetchAllOpenIssues(github, owner, repo) {
const issues = [];
let page = 1;

while (true) {
try {
const response = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
per_page: 100,
page,
});
const data = response.data || [];
if (data.length === 0) break;
const onlyIssues = data.filter(issue => !issue.pull_request);
issues.push(...onlyIssues);
if (data.length < 100) break;
page++;
} catch (err) {
console.error('Error fetching issues:', err);
break;
}
}
return issues;
}


async function hasRecentFriendlyReminder(github, owner, repo, issueNumber, maxAgeMs) {
let comments = [];
let page = 1;

while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
page,
});

if (!data || data.length === 0) break;
comments.push(...data);
if (data.length < 100) break;
page++;
}

const reminders = comments
.filter(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('⏰ Friendly Reminder')
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));

if (reminders.length === 0) return false;

const mostRecent = new Date(reminders[0].created_at);
return (Date.now() - mostRecent.getTime()) < maxAgeMs;
}
Comment on lines +87 to +117
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little more optimized version of this function for API requests. Please double check it works as expected.

Suggested change
async function hasRecentFriendlyReminder(github, owner, repo, issueNumber, maxAgeMs) {
let comments = [];
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
page,
});
if (!data || data.length === 0) break;
comments.push(...data);
if (data.length < 100) break;
page++;
}
const reminders = comments
.filter(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('⏰ Friendly Reminder')
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
if (reminders.length === 0) return false;
const mostRecent = new Date(reminders[0].created_at);
return (Date.now() - mostRecent.getTime()) < maxAgeMs;
}
async function hasRecentFriendlyReminder(github, owner, repo, issueNumber, maxAgeMs) {
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
page,
});
if (!data || data.length === 0) break;
for (const c of data) {
if (c.user?.login === 'github-actions[bot]' &&
c.body.includes('<!-- backlog-bot:friendly-reminder -->'))
{
const created = new Date(c.created_at).getTime();
if (Date.now() - created < maxAgeMs) {
return true;
}
}
}
if (data.length < 100) break;
page++;
}
return false;
}



module.exports = async ({ github, context, dryRun }) => {
const now = new Date();
const thresholdDays = 90;
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing some labels that should also be skipped.

Suggested change
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];
const exemptLabels = [
'Status: Community help needed',
'Status: Needs investigation',
'Move to Discussion',
'Status: Blocked upstream 🛑',
'Status: Blocked by ESP-IDF 🛑'
];

const closeLabels = ['Status: Awaiting Response'];
const questionLabel = 'Type: Question';
const { owner, repo } = context.repo;
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;

const isDryRun = dryRun === "1";
if (isDryRun) {
console.log("DRY-RUN mode enabled — no changes will be made.");
}

let totalClosed = 0;
let totalReminders = 0;
let totalSkipped = 0;
let totalMarkedToMigrate = 0;

let issues = [];

try {
issues = await fetchAllOpenIssues(github, owner, repo);
} catch (err) {
console.error('Failed to fetch issues:', err);
return;
}

for (const issue of issues) {
const isAssigned = issue.assignees && issue.assignees.length > 0;
const lastUpdate = new Date(issue.updated_at);
const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24));
const oneDayMs = 1000 * 60 * 60 * 24;
const daysSinceUpdate = Math.floor((now - lastUpdate) / oneDayMs);


if (issue.labels.some(label => exemptLabels.includes(label.name))) {
totalSkipped++;
continue;
Comment on lines +153 to +155
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (issue.labels.some(label => exemptLabels.includes(label.name))) {
totalSkipped++;
continue;
if (issue.labels.some(label => exemptLabels.includes(label.name))) {
console.log(`Skipping #${issue.number} (exempt label)`);
totalSkipped++;
continue;

}

if (issue.labels.some(label => label.name === questionLabel)) {
const marked = await addMoveToDiscussionLabel(github, owner, repo, issue, isDryRun);
if (marked) totalMarkedToMigrate++;
continue; // Do not apply reminder logic
}

if (daysSinceUpdate < thresholdDays) {
totalSkipped++;
continue;
Comment on lines +164 to +166
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (daysSinceUpdate < thresholdDays) {
totalSkipped++;
continue;
if (daysSinceUpdate < thresholdDays) {
console.log(`Skipping #${issue.number} (recent activity)`);
totalSkipped++;
continue;

}

if (issue.labels.some(label => closeLabels.includes(label.name)) || !isAssigned) {

if (isDryRun) {
console.log(`[DRY-RUN] Would close issue #${issue.number}`);
totalClosed++;
continue;
}

try {
await github.rest.issues.createComment({
Comment on lines +177 to +178
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try {
await github.rest.issues.createComment({
try {
console.log(`Closing #${issue.number} (inactivity)`);
await github.rest.issues.createComment({

owner,
repo,
issue_number: issue.number,
body: '⚠️ This issue was closed automatically due to inactivity. Please reopen or open a new one if still relevant.',
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
});
totalClosed++;
} catch (err) {
console.error(`Error closing issue #${issue.number}:`, err);
}
continue;
}

if (isAssigned) {

if (await hasRecentFriendlyReminder(github, owner, repo, issue.number, sevenDaysMs)) {
totalSkipped++;
continue;
}
Comment on lines +199 to +202
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (await hasRecentFriendlyReminder(github, owner, repo, issue.number, sevenDaysMs)) {
totalSkipped++;
continue;
}
if (await hasRecentFriendlyReminder(github, owner, repo, issue.number, sevenDaysMs)) {
console.log(`Skipping #${issue.number} (recent reminder)`);
totalSkipped++;
continue;
}


const assignees = issue.assignees.map(u => `@${u.login}`).join(', ');
const comment = dedent`
⏰ Friendly Reminder
Comment on lines +205 to +206
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we can edit the message without breaking the script.

Suggested change
const comment = dedent`
Friendly Reminder
const comment = dedent`
<!-- backlog-bot:friendly-reminder -->
Friendly Reminder


Hi ${assignees}!

This issue has had no activity for ${daysSinceUpdate} days. If it's still relevant:
- Please provide a status update
- Add any blocking details
- Or label it 'Status: Awaiting Response' if you're waiting on something
Comment on lines +211 to +213
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Please provide a status update
- Add any blocking details
- Or label it 'Status: Awaiting Response' if you're waiting on something
- Please provide a status update
- Add any blocking details and labels
- Or label it 'Status: Awaiting Response' if you're waiting on the user's response


This is just a reminder; the issue remains open for now.`;

if (isDryRun) {
console.log(`[DRY-RUN] Would post reminder on #${issue.number}`);
totalReminders++;
continue;
}

try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: comment,
});
totalReminders++;
} catch (err) {
console.error(`Error sending reminder for issue #${issue.number}:`, err);
}
}
}

console.log(dedent`
=== Backlog cleanup summary ===
Total issues processed: ${issues.length}
Total issues closed: ${totalClosed}
Total reminders sent: ${totalReminders}
Total marked to migrate to discussions: ${totalMarkedToMigrate}
Total skipped: ${totalSkipped}`);
};
33 changes: 33 additions & 0 deletions .github/workflows/backlog-bot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: "Backlog Management Bot"

on:
schedule:
- cron: '0 4 * * *' # Run daily at 4 AM UTC
workflow_dispatch:
inputs:
dry-run:
description: "Run without modifying issues"
required: false
default: "0"

permissions:
issues: write
discussions: write
contents: read

jobs:
backlog-bot:
name: "Check issues"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Run backlog cleanup script
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const script = require('./.github/scripts/backlog-cleanup.js');
const dryRun = "${{ github.event.inputs.dry-run }}";
await script({ github, context, dryRun });