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
225 changes: 225 additions & 0 deletions .github/scripts/backlog-cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* 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) {
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 migrateToDiscussion(github, owner, repo, issue, categories) {
const discussionCategory = 'Q&A';
try {
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({
Copy link

Copilot AI Oct 22, 2025

Choose a reason for hiding this comment

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

The GitHub REST API does not have a github.rest.discussions.create endpoint. Discussion creation requires the GraphQL API. This will cause the migration feature to fail at runtime. Use the GraphQL API with the createDiscussion mutation instead.

Copilot uses AI. Check for mistakes.
owner,
repo,
title: issue.title,
body: `Originally created by @${issue.user.login} in #${issue.number}\n\n---\n\n${issue.body || ''}`,
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;
} catch (err) {
console.error(`Error migrating issue #${issue.number} to discussion:`, err);
return null;
}
}


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 now = new Date();
const thresholdDays = 90;
const exemptLabels = ['Status: Community help needed', 'Status: Needs investigation'];
const closeLabels = ['Status: Awaiting Response'];
const discussionLabel = 'Type: Question';
const { owner, repo } = context.repo;

let totalClosed = 0;
let totalReminders = 0;
let totalSkipped = 0;
let totalMigrated = 0;
let issues = [];
let categories = [];

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

try {
const { data } = await github.rest.discussions.listCategories({ owner, repo });
categories = data;
Comment on lines +121 to +122
Copy link

Copilot AI Oct 22, 2025

Choose a reason for hiding this comment

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

The GitHub REST API does not have a github.rest.discussions.listCategories endpoint. Fetching discussion categories requires the GraphQL API. This will cause the category lookup to fail. Use the GraphQL API to query the repository's discussion categories.

Suggested change
const { data } = await github.rest.discussions.listCategories({ owner, repo });
categories = data;
const result = await github.graphql(`
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
discussionCategories(first: 100) {
nodes {
id
name
}
}
}
}
`, { owner, repo });
categories = result.repository.discussionCategories.nodes;

Copilot uses AI. Check for mistakes.
} catch (err) {
console.error('Error fetching discussion categories:', err);
}

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

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 ?

Copy link
Author

Choose a reason for hiding this comment

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

Triggers are changing labels, editing the title, body, assignees, comments, closing or reopening the issue.

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

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

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

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

if (issue.labels.some(label => closeLabels.includes(label.name)) || !isAssigned) {
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 {
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 50,
});
comments = data;
Comment on lines +171 to +177
Copy link

Copilot AI Oct 22, 2025

Choose a reason for hiding this comment

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

The comment fetching uses per_page: 50 without pagination. For issues with more than 50 comments, recent 'Friendly Reminder' comments might not be fetched, causing duplicate reminders to be sent. Implement pagination or fetch comments in reverse chronological order to ensure recent comments are captured.

Suggested change
const { data } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 50,
});
comments = data;
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++;
}

Copilot uses AI. Check for mistakes.
} 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)) {
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.`;
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 migrated to discussions: ${totalMigrated}
Total skipped: ${totalSkipped}`);
};
26 changes: 26 additions & 0 deletions .github/workflows/backlog-bot.yml
Copy link
Member

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 ?

Copy link
Author

Choose a reason for hiding this comment

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

I performed a dry run on the forked repository linked in the issue description. Do you want to do dryrun on regular basis?

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

on:
schedule:
- cron: '0 4 * * *' # Run daily at 4 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@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');
await script({ github, context });