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
239 changes: 239 additions & 0 deletions .github/scripts/backlog-cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* 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' to 'Move to Discussion'.
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

The documentation states that the script "Marks issues labeled 'questions' to 'Move to Discussion'" but doesn't explain what happens after marking. It would be helpful to clarify that this is only adding a label and that actual migration to discussions would need to be handled separately (either manually or by another automation).

Suggested change
* - Marks issues labeled 'questions' to 'Move to Discussion'.
* - Marks issues labeled 'questions' by adding the 'Move to Discussion' label.
* (Actual migration to Discussions must be handled separately, either manually or by another automation.)

Copilot uses AI. Check for mistakes.
*/

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 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({
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;
}


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, dryRun }) => {
const now = new Date();
const thresholdDays = 90;
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation', 'Move to Discussion'];
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

The 'Move to Discussion' label is included in exemptLabels, which means issues that have already been marked for migration will be skipped entirely in future runs. This prevents the bot from ever closing these issues even if they meet closure criteria. Consider whether issues marked for discussion migration should still be subject to closure after extended inactivity, or remove 'Move to Discussion' from the exempt list.

Suggested change
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation', 'Move to Discussion'];
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];

Copilot uses AI. Check for mistakes.
const closeLabels = ['Status: Awaiting Response'];
Comment on lines +95 to +97
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

The threshold of 90 days and the list of exempt/close labels are hardcoded. Consider making these configurable through environment variables or workflow inputs to allow easier adjustment without modifying the script. This would improve maintainability and flexibility for different repository needs.

Suggested change
const thresholdDays = 90;
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation', 'Move to Discussion'];
const closeLabels = ['Status: Awaiting Response'];
// Allow configuration via environment variables, fallback to defaults
const thresholdDays = process.env.BACKLOG_CLEANUP_THRESHOLD_DAYS
? parseInt(process.env.BACKLOG_CLEANUP_THRESHOLD_DAYS, 10)
: 90;
const exemptLabels = process.env.BACKLOG_CLEANUP_EXEMPT_LABELS
? process.env.BACKLOG_CLEANUP_EXEMPT_LABELS.split(',').map(l => l.trim())
: ['Status: Community help needed', 'Status: Needs investigation', 'Move to Discussion'];
const closeLabels = process.env.BACKLOG_CLEANUP_CLOSE_LABELS
? process.env.BACKLOG_CLEANUP_CLOSE_LABELS.split(',').map(l => l.trim())
: ['Status: Awaiting Response'];

Copilot uses AI. Check for mistakes.
const questionLabel = 'Type: Question';
const { owner, repo } = context.repo;

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

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

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

Issues with exempt labels are skipped before checking the threshold days, but the documentation and intended behavior suggest that exempt labels should prevent reminders and closures, not prevent staleness checks entirely. An issue with an exempt label that also has 'Status: Awaiting Response' or is unassigned might still need to be processed. Consider checking exempt labels only where they're actually relevant (in the reminder logic) rather than at the top of the loop.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

After marking a question issue for migration to discussions, the script continues to the next issue. However, if the issue has been inactive for 90+ days and is assigned, it should potentially still receive a reminder. The current logic skips all further processing for question issues. Consider whether question issues that are assigned and inactive should also receive reminders, or clarify the intended behavior in the documentation.

Suggested change
continue; // Do not apply reminder logic
// Do not skip further processing; allow reminder logic for assigned, inactive question issues

Copilot uses AI. Check for mistakes.
}

if (daysSinceUpdate < thresholdDays) {
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({
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;
}

let comments = [];
try {
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
page,
});
if (!data || data.length === 0) break;
comments.push(...data);
if (data.length < 100) break;
page++;
}
} catch (err) {
console.error(`Error fetching comments for issue #${issue.number}:`, err);
}

const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder')
);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
Comment on lines +189 to +195
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

The logic for checking recent friendly reminders is incomplete. The code finds a reminder comment but doesn't verify if it was posted within the last 7 days as documented in the file header. This means duplicate reminders will still be sent as long as they're more than 7 days apart, but the current implementation skips the issue entirely if ANY friendly reminder comment exists, regardless of age. Add a date check to verify the reminder is within the last 7 days, similar to the documented behavior.

Suggested change
const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder')
);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
// Find the most recent friendly reminder comment from github-actions[bot]
const friendlyReminders = comments
.filter(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder')
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const mostRecentReminder = friendlyReminders.length > 0 ? friendlyReminders[0] : null;
const now = new Date();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
if (mostRecentReminder) {
const reminderDate = new Date(mostRecentReminder.created_at);
if (now - reminderDate < sevenDaysMs) {
totalSkipped++;
continue;
}

Copilot uses AI. Check for mistakes.
}

if (shouldSendReminder(issue, exemptLabels, closeLabels)) {
Comment on lines +169 to +198
Copy link

Copilot AI Dec 12, 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 passes the initial checks, even when they're not needed. The comments are only used for checking friendly reminders on assigned issues without exempt/close labels. Consider moving the comment fetching logic inside the shouldSendReminder check to avoid unnecessary API calls for issues that will be closed or don't need reminders. This could significantly reduce API rate limit consumption, especially on repositories with many issues.

Suggested change
let comments = [];
try {
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
page,
});
if (!data || data.length === 0) break;
comments.push(...data);
if (data.length < 100) break;
page++;
}
} catch (err) {
console.error(`Error fetching comments for issue #${issue.number}:`, err);
}
const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder')
);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
}
if (shouldSendReminder(issue, exemptLabels, closeLabels)) {
if (shouldSendReminder(issue, exemptLabels, closeLabels)) {
// Fetch comments only if we might send a reminder
let comments = [];
try {
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
page,
});
if (!data || data.length === 0) break;
comments.push(...data);
if (data.length < 100) break;
page++;
}
} catch (err) {
console.error(`Error fetching comments for issue #${issue.number}:`, err);
}
const recentFriendlyReminder = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('⏰ Friendly Reminder')
);
if (recentFriendlyReminder) {
totalSkipped++;
continue;
}

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

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

The label name in the comment text ('awaiting-response') doesn't match the actual label name defined in closeLabels ('Status: Awaiting Response'). This inconsistency could confuse users who are looking for the exact label name to apply. Consider using the actual label name or referencing it from the closeLabels array.

Suggested change
- Or label it 'awaiting-response' if you're waiting on something
- Or label it 'Status: Awaiting Response' if you're waiting on something

Copilot uses AI. Check for mistakes.

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 });