-
Notifications
You must be signed in to change notification settings - 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,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({ | ||||||||||||||||||||||||||||||||||||||||||||
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. 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. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||
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}`, | ||||||||||||||||||||||||||||||||||||||||||||
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. The issue body could be null or undefined, which would result in 'undefined' being appended to the discussion body. Add a null check:
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||
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
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 think this will cause the discussion to lose its comments (unlike when clicking |
||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
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
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.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
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); | ||||||||||||||||||||||||||||||||||||||||||||
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. 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, | ||||||||||||||||||||||||||||||||||||||||||||
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. 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+132
to
+137
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. Limiting comment retrieval to 10 may miss recent reminders if an issue has many comments. Consider increasing
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback 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. 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. 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. @lucasssvaz wdyt? 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. Will review this PR in a few days. For now testing the new BLE related stuff before 3.3.0 RC1. Will review ASAP. 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 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 | ||||||||||||||||||||||||||||||||||||||||||||
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. Why is this checking if "recent" is seven days ? Once the comment is posted, won't the |
||||||||||||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||||||||||||
if (recentFriendlyReminder) { | ||||||||||||||||||||||||||||||||||||||||||||
totalSkipped++; | ||||||||||||||||||||||||||||||||||||||||||||
continue; | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+132
to
+148
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. 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
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||||||||||||||||||||||||||||||||||||||
if (issue.labels.some(label => exemptLabels.includes(label.name))) { | ||||||||||||||||||||||||||||||||||||||||||||
totalSkipped++; | ||||||||||||||||||||||||||||||||||||||||||||
continue; | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
lucasssvaz marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||
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}`); | ||||||||||||||||||||||||||||||||||||||||||||
}; |
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 ? |
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 | ||||||
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. Just to avoid clashing with other jobs that run at the same time.
Suggested change
|
||||||
|
||||||
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 | ||||||
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. To avoid supply chain attacks
Suggested change
|
||||||
|
||||||
- name: Run backlog cleanup script | ||||||
uses: actions/github-script@v7 | ||||||
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. To avoid supply chain attacks
Suggested change
|
||||||
with: | ||||||
github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
script: | | ||||||
const script = require('./.github/scripts/backlog-cleanup.js'); | ||||||
await script({ github, context }); |
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.
Add some
try/catch
blocks to avoid crashing the script if something fails.