Add automated workflow for tagging aging bugs (#39284)

Automates bug triage by tagging issues based on age: `~old bug` for bugs
≥180 days, `~aging bug` for bugs ≥90 days.

Relates to #39155.

## Changes

**New workflow: `.github/workflows/tag-aging-bugs.yml`**
- Runs daily at 8:06 UTC via cron, supports manual dispatch
- Dry run mode (default: false) logs actions without modifying labels
- Two-pass processing:
  1. Bugs ≥180 days: adds `~old bug`, removes `~aging bug` if present
  2. Bugs ≥90 days without either label: adds `~aging bug`
- Uses github-script with pagination for scalability
- Follows repo patterns (harden-runner, proper permissions)

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] QA'd all new/changed functionality manually

<!-- START COPILOT CODING AGENT SUFFIX -->



<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> Add a GitHub Actions workflow that runs daily at 8:06am UTC, and can
be manually dispatched. In that workflow, retrieve all issues labelled
`bug` created >= 180 days ago that don't include the `~old bug` tag,
then for each bug add the `~old bug` tag and remove `~aging bug` if it
is applied. Then retrieve all issues labelled `bug` created >= 90 days
ago that has neither `~aging bug` nor `~old bug` tags, and add the
`~aging bug` tag. Include a dry run workflow parameter, default off,
that logs rather than setting the label.


</details>



<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iansltx <472804+iansltx@users.noreply.github.com>
This commit is contained in:
Copilot 2026-02-03 18:00:58 -06:00 committed by GitHub
parent 9ce3182726
commit b7440e8d7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

189
.github/workflows/tag-aging-bugs.yml vendored Normal file
View file

@ -0,0 +1,189 @@
name: Tag aging bugs
# This action will tag bugs based on their age:
# - Bugs >= 180 days old get tagged with ~old bug (and ~aging bug is removed)
# - Bugs >= 90 days old (but < 180 days) get tagged with ~aging bug
on:
schedule:
# Daily at 8:06am UTC
- cron: "6 8 * * *"
workflow_dispatch: # Manual
inputs:
dry_run:
description: 'Dry run mode (log only, do not modify labels)'
required: false
type: boolean
default: false
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id}}
cancel-in-progress: true
defaults:
run:
# fail-fast using bash -eo pipefail. See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference
shell: bash
permissions:
contents: read
jobs:
tag-bugs:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Tag aging bugs
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const dryRun = ${{ github.event.inputs.dry_run || false }};
console.log(`Dry run mode: ${dryRun}`);
// Calculate date thresholds
const now = new Date();
const oldBugDate = new Date(now);
oldBugDate.setDate(oldBugDate.getDate() - 180);
const agingBugDate = new Date(now);
agingBugDate.setDate(agingBugDate.getDate() - 90);
console.log(`Old bug threshold: ${oldBugDate.toISOString()}`);
console.log(`Aging bug threshold: ${agingBugDate.toISOString()}`);
// Process old bugs (>= 180 days)
console.log('\n=== Processing old bugs (>= 180 days) ===');
let page = 1;
let oldBugsProcessed = 0;
while (true) {
const { data: oldBugs } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'bug',
state: 'open',
per_page: 100,
page: page,
sort: 'created',
direction: 'asc'
});
if (oldBugs.length === 0) break;
for (const issue of oldBugs) {
const createdDate = new Date(issue.created_at);
// Stop if we've passed the old bug threshold
if (createdDate > oldBugDate) {
page = Infinity; // Signal to stop pagination
break;
}
const labels = issue.labels.map(label => label.name);
const hasOldBugLabel = labels.includes('~old bug');
if (!hasOldBugLabel) {
oldBugsProcessed++;
console.log(`Issue #${issue.number}: Created ${createdDate.toISOString()}`);
if (dryRun) {
console.log(` [DRY RUN] Would add ~old bug label`);
if (labels.includes('~aging bug')) {
console.log(` [DRY RUN] Would remove ~aging bug label`);
}
} else {
// Add ~old bug label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['~old bug']
});
console.log(` Added ~old bug label`);
// Remove ~aging bug if present
if (labels.includes('~aging bug')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: '~aging bug'
});
console.log(` Removed ~aging bug label`);
}
}
}
}
if (page === Infinity) break;
page++;
}
console.log(`\nProcessed ${oldBugsProcessed} old bugs`);
// Process aging bugs (>= 90 days but < 180 days)
console.log('\n=== Processing aging bugs (>= 90 days) ===');
page = 1;
let agingBugsProcessed = 0;
while (true) {
const { data: agingBugs } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'bug',
state: 'open',
per_page: 100,
page: page,
sort: 'created',
direction: 'asc'
});
if (agingBugs.length === 0) break;
for (const issue of agingBugs) {
const createdDate = new Date(issue.created_at);
// Skip if newer than aging threshold
if (createdDate > agingBugDate) {
page = Infinity; // Signal to stop pagination
break;
}
const labels = issue.labels.map(label => label.name);
const hasAgingBugLabel = labels.includes('~aging bug');
const hasOldBugLabel = labels.includes('~old bug');
// Only tag if it doesn't have either label
// (hasOldBugLabel check handles issues that became old since last run)
if (!hasAgingBugLabel && !hasOldBugLabel) {
agingBugsProcessed++;
console.log(`Issue #${issue.number}: Created ${createdDate.toISOString()}`);
if (dryRun) {
console.log(` [DRY RUN] Would add ~aging bug label`);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['~aging bug']
});
console.log(` Added ~aging bug label`);
}
}
}
if (page === Infinity) break;
page++;
}
console.log(`\nProcessed ${agingBugsProcessed} aging bugs`);
console.log(`\n=== Summary ===`);
console.log(`Total old bugs tagged: ${oldBugsProcessed}`);
console.log(`Total aging bugs tagged: ${agingBugsProcessed}`);