-
Couldn't load subscription status.
- Fork 7.7k
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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({ | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||
| 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; |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.
Copilot
AI
Oct 22, 2025
There was a problem hiding this comment.
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.
| 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++; | |
| } |
SamuelFialka marked this conversation as resolved.
Show resolved
Hide resolved
SamuelFialka marked this conversation as resolved.
Show resolved
Hide resolved
SamuelFialka marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }); |
Uh oh!
There was an error while loading. Please reload this page.