Skip to content

feat(workflow): Backlog management bot #11518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
202 changes: 202 additions & 0 deletions .github/scripts/backlog-cleanup.js
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add some try/catch blocks to avoid crashing the script if something fails.

Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* 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.
* - Moves issues labeled 'questions' to GitHub Discussions
*/

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));
return lines.map(l => l.slice(minIndent)).join('\n').trim();
};

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

while (true) {
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++;
}
return issues;
}


async function migrateToDiscussion(github, owner, repo, issue) {
const discussionCategory = 'Q&A';

const { data: categories } = await github.rest.discussions.listCategories({
Copy link
Preview

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

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

The discussion categories are fetched for every issue that needs migration. Consider fetching categories once at the beginning of the script and reusing the result to avoid redundant API calls.

Copilot uses AI. Check for mistakes.

owner,
repo,
});

const category = categories.find(cat =>
cat.name.toLowerCase() === discussionCategory.toLowerCase()
);

if (!category) {
throw new Error(`Discussion category '${discussionCategory}' not found.`);
}

const { data: discussion } = await github.rest.discussions.create({
owner,
repo,
title: issue.title,
body: `Originally created by @${issue.user.login} in #${issue.number}\n\n---\n\n${issue.body}`,
Copy link
Preview

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

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

The issue body could be null or undefined, which would result in 'undefined' being appended to the discussion body. Add a null check: ${issue.body || ''} or similar handling.

Suggested change
body: `Originally created by @${issue.user.login} in #${issue.number}\n\n---\n\n${issue.body}`,
body: `Originally created by @${issue.user.login} in #${issue.number}\n\n---\n\n${issue.body || ''}`,

Copilot uses AI. Check for mistakes.

category_id: category.id,
});

await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `💬 This issue was moved to [Discussions](${discussion.html_url}) for better visibility.`,
});

await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
});

return discussion.html_url;
}
Comment on lines +52 to +91
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this will cause the discussion to lose its comments (unlike when clicking Convert to Discussion). I don't see any API available to do this though. IDK what would be the best approach here.



const shouldSendReminder = (issue, exemptLabels, closeLabels) => {
const hasExempt = issue.labels.some(l => exemptLabels.includes(l.name));
const hasClose = issue.labels.some(l => closeLabels.includes(l.name));
return issue.assignees.length > 0 && !hasExempt && !hasClose;
};


module.exports = async ({ github, context }) => {
const { owner, repo } = context.repo;
const issues = await fetchAllOpenIssues(github, owner, repo);
const now = new Date();
const thresholdDays = 90;
const exemptLabels = ['to-be-discussed'];
const closeLabels = ['awaiting-response'];
const discussionLabel = 'questions';
Comment on lines +106 to +108
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const exemptLabels = ['to-be-discussed'];
const closeLabels = ['awaiting-response'];
const discussionLabel = 'questions';
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];
const closeLabels = ['Status: Awaiting Response'];
const discussionLabel = 'Type: Question';

const sevenDays = 7 * 24 * 60 * 60 * 1000;

let totalClosed = 0;
let totalReminders = 0;
let totalSkipped = 0;
let totalMigrated = 0;

for (const issue of issues) {
const isAssigned = issue.assignees && issue.assignees.length > 0;
const lastUpdate = new Date(issue.updated_at);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you know what triggers this updated at ? If I change a label, will this moment be considered the last update ?

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

if (issue.labels.some(label => label.name === discussionLabel)) {
await migrateToDiscussion(github, owner, repo, issue);
totalMigrated++;
continue;
}

if (daysSinceUpdate < thresholdDays) {
totalSkipped++;
continue;
}

const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 10,
Copy link
Preview

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

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

Using per_page: 10 when checking for recent comments is inefficient. Since you're only looking for recent 'Friendly Reminder' comments from github-actions[bot], consider using per_page: 100 or at least 50 to reduce API calls, especially for issues with many comments.

Suggested change
per_page: 10,
per_page: 50,

Copilot uses AI. Check for mistakes.

});
Comment on lines +132 to +137
Copy link
Preview

Copilot AI Jun 25, 2025

Choose a reason for hiding this comment

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

Limiting comment retrieval to 10 may miss recent reminders if an issue has many comments. Consider increasing per_page to 100 or implementing pagination so all comments within the last 7 days are checked.

Suggested change
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 10,
});
let comments = [];
let page = 1;
let fetchedComments;
do {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
page,
});
fetchedComments = data;
comments = comments.concat(fetchedComments);
page++;
} while (fetchedComments.length === 100);

Copilot uses AI. Check for mistakes.

Copy link
Author

Choose a reason for hiding this comment

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

If there would be more than 10 comments, it is a sign that the issue is being worked on. In that case, the issue will be skipped because the 90 days of inactivity condition will not be met.

Copy link
Author

Choose a reason for hiding this comment

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

@lucasssvaz wdyt?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Will review this PR in a few days. For now testing the new BLE related stuff before 3.3.0 RC1. Will review ASAP.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think there might be cases where something was discussed for very long and there was no further discussions later. So I think it might be good to grab all the comments (or at least the 10 most recent ones, if possible).


const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder') &&
(now - new Date(comment.created_at)) < sevenDays
Copy link
Collaborator

@lucasssvaz lucasssvaz Jul 31, 2025

Choose a reason for hiding this comment

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

Why is this checking if "recent" is seven days ? Once the comment is posted, won't the daysSinceUpdate become 0 and next reminder only be posted after 90 more days ? (this is what we want it to do)

);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
}

Comment on lines +132 to +148
Copy link
Preview

Copilot AI Jul 30, 2025

Choose a reason for hiding this comment

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

Comments are fetched for every issue that meets the age threshold, even if the issue will be closed or skipped due to exempt labels. Consider checking exempt labels and close conditions before fetching comments to reduce unnecessary API calls.

Suggested change
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 10,
});
const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder') &&
(now - new Date(comment.created_at)) < sevenDays
);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
}

Copilot uses AI. Check for mistakes.

if (issue.labels.some(label => exemptLabels.includes(label.name))) {
totalSkipped++;
continue;
}

if (issue.labels.some(label => closeLabels.includes(label.name)) || !isAssigned) {
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++;
continue;
}

if (shouldSendReminder(issue, exemptLabels, closeLabels)) {
const assignees = issue.assignees.map(u => `@${u.login}`).join(', ');
const comment = dedent`
⏰ 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 'awaiting-response' if you're waiting on something

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

await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: comment,
});
totalReminders++;
}
}

console.log(dedent`
=== Backlog cleanup summary ===
Total issues processed: ${issues.length}
Total issues closed: ${totalClosed}
Total reminders sent: ${totalReminders}
Total migrated to discussions: ${totalMigrated}
Total skipped: ${totalSkipped}`);
};
26 changes: 26 additions & 0 deletions .github/workflows/backlog-bot.yml
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we also implement a dry-run mode toggled by an input from a workflow dispatch ?

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: "Backlog Management Bot"

on:
schedule:
- cron: '0 2 * * *' # Run daily at 2 AM UTC
Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to avoid clashing with other jobs that run at the same time.

Suggested change
- cron: '0 2 * * *' # Run daily at 2 AM UTC
- cron: '0 4 * * *' # Run daily at 2 AM UTC


permissions:
issues: write
discussions: write
contents: read

jobs:
backlog-bot:
name: "Check for stale issues"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
Copy link
Collaborator

Choose a reason for hiding this comment

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

To avoid supply chain attacks

Suggested change
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2


- name: Run backlog cleanup script
uses: actions/github-script@v7
Copy link
Collaborator

Choose a reason for hiding this comment

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

To avoid supply chain attacks

Suggested change
uses: actions/github-script@v7
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1

with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const script = require('./.github/scripts/backlog-cleanup.js');
await script({ github, context });
Loading