Skip to content

feat(ci): auto-apply triage labels to issues - #3574

Open
Aleksei Sviridkin (lexfrei) wants to merge 1 commit into
mainfrom
feat/issue-triage-labeler
Open

feat(ci): auto-apply triage labels to issues#3574
Aleksei Sviridkin (lexfrei) wants to merge 1 commit into
mainfrom
feat/issue-triage-labeler

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Issues that arrive through the API or gh issue create bypass the issue templates, so they carry no triage/* label and nothing in the repository adds one afterwards. In a snapshot of the open issues taken today, 138 of 277 have no triage label at all, which makes the triage queue impossible to filter on and hides how much of the backlog nobody has looked at yet.

This adds a workflow that labels an issue when it is opened or reopened, plus a daily sweep that backstops labels removed by hand and works through the existing backlog. An issue somebody has assigned, or marked with one of priority/critical-urgent, priority/important-soon, priority/important-longterm, epic or lifecycle/frozen, gets triage/accepted, since a maintainer has demonstrably looked at it; everything else gets triage/needs-triage. An issue already carrying any triage/* label is left alone, so the sweep never overwrites a decision someone made by hand, and pull requests returned by the same listing endpoint are skipped. Against the snapshot above, the sweep applies triage/accepted to 43 issues and triage/needs-triage to 95, and touches none of the 139 that are already triaged.

The signal list is not simply every priority/* tier, and the reason is worth stating. triage/accepted is itself on stale.yaml's exempt-issue-labels, so applying it does more than reset a clock: the issue stops being auto-closable. Every label in the signal list is already on that exempt list, so for those the exemption changes nothing. priority/backlog is the one tier stale.yaml deliberately leaves reapable, so it is not a signal here either and those issues go through triage like any other. An assignee is the single signal that is neither a label nor already exempt, so an issue whose only signal is an assignee does gain a permanent reprieve. That is the intended reading rather than a side effect: somebody took ownership, which makes it a known long-tail task rather than an abandoned one, and it is the same argument stale.yaml's own comment makes for exempting triage/accepted in the first place.

Two operational details are worth calling out. First, the sweep paces its writes a second apart and caps a run at 400, because GitHub allows 80 content-generating requests a minute and 500 an hour, this client carries no throttling plugin and no retries, and a throttled write answers 403, which is exempt from retry anyway. Unpaced, the first pass over the backlog would not slow down when throttled; it would fail the job partway and leave the backlog half labeled. Whatever a run leaves behind stays unlabeled, so the next one picks it up. Second, adding a label bumps an issue's updated_at, which stale.yaml reads as activity, so the first sweep un-marks the issues currently labeled lifecycle/stale. That is deliberate and one-time: those issues get triaged now, and their staleness clock restarts from there.

A write that fails does not take the rest of the run with it, but the two failure shapes are told apart rather than counted together. Any status other than a 403 or 429 lets the run carry on, most often a 404 or 410 from an issue transferred or deleted mid-sweep, since a failure about one issue says nothing about the next. A 403 or 429 means the token lost issues: write or the pacing stopped being enough, which applies to every write left in the run, so the sweep stops at the first one and the job fails loudly. Repeating a rejected request up to the cap is what gets an integration throttled harder.

workflow_dispatch takes a dry_run input that logs what would be labeled without writing anything. It is on by default, so a manual run writes nothing until somebody unchecks the box, and schedule is the one event that writes without being asked. The cap applies in dry-run too, so a dry run predicts what a real one would do instead of printing a longer list nobody gets. The job asks for issues: write and everything else is read-only, the action it uses is pinned by digest, and a 20-minute timeout keeps a hung call from holding the queue for the default six hours.

No pull request lane can exercise this workflow, so its first real execution is a sweep over every open issue with nothing having run it before. hack/issue-triage-contract.bats pins the executable lines that bound that blast radius: the pull-request skip, the already-triaged skip, the pacing rate and the write cap, the failure handling, the digest-pinned action, the token scopes, the serialised queue, the cron ordering against stale.yaml, that both labels it writes exist in .github/labels.yml, and that no accepted-signal label sits outside stale.yaml's exempt list. Each assertion was checked against a mutated copy of the workflow to confirm it goes red rather than merely existing.

Suggested first step after merge: run the workflow by hand and read the summary before the 05:53 cron arms itself. The box arrives checked, so that run costs nothing.

Screenshots

Not a UI change, so there is nothing to show.

Downstream repositories

I walked the trigger map in docs/agents/contributing.md against the diff. The diff adds two files, .github/workflows/issue-triage.yaml and hack/issue-triage-contract.bats, and touches nothing else. No row matches: it adds and renames no package, changes no chart values, no schema, no version enum, no ApplicationDefinition, no CRD, no namespace, no variant or bundle, no label or annotation any downstream repository matches on, no metric and no release asset. The map's one hack/ row covers hack/package.mk and hack/update-crd.sh, which cozystack/ccp and cozystack/external-apps-example copy or anchor on; a new bats file next to them is neither.

Release note

feat(ci): new and reopened issues now get a `triage/*` label automatically, and a daily sweep labels any open issue that has none: `triage/accepted` when it is assigned or carries a priority, epic or frozen marker, `triage/needs-triage` otherwise.

Summary by CodeRabbit

  • New Features

    • Added automated issue triage that classifies eligible open issues and applies appropriate labels.
    • Supports issue-created, reopened, scheduled, and manually triggered triage runs.
    • Includes dry-run support, status reporting, and safeguards for pull requests, previously triaged issues, rate limits, permissions, and duplicate runs.
  • Tests

    • Added comprehensive validation to ensure the triage workflow follows its operational and safety requirements.

@lexfrei
Aleksei Sviridkin (lexfrei) marked this pull request as ready for review August 6, 2026 07:50
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/feature Categorizes issue or PR as related to a new feature labels Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c3620a9c-b219-4183-8331-91e0ef598e85

📥 Commits

Reviewing files that changed from the base of the PR and between b4e2031 and e78bef9.

📒 Files selected for processing (2)
  • .github/workflows/issue-triage.yaml
  • hack/issue-triage-contract.bats
🚧 Files skipped from review as they are similar to previous changes (2)
  • hack/issue-triage-contract.bats
  • .github/workflows/issue-triage.yaml

📝 Walkthrough

Walkthrough

The PR adds an issue-triage workflow and a Bats contract suite. The workflow classifies issues, applies triage labels, supports scheduled and manual sweeps, limits writes, handles refusals, and reports results.

Changes

Issue triage automation

Layer / File(s) Summary
Workflow entry and classification
.github/workflows/issue-triage.yaml, hack/issue-triage-contract.bats
The workflow defines triggers, permissions, concurrency, labels, accepted signals, and issue classification. Contract tests verify skip rules, action digests, permissions, concurrency, stale compatibility, schedule ordering, and label declarations.
Issue event label handling
.github/workflows/issue-triage.yaml, hack/issue-triage-contract.bats
Issue events apply triage labels or log dry-run results. Contract tests verify manual dry-run defaults and runtime input handling.
Sweep execution and write handling
.github/workflows/issue-triage.yaml, hack/issue-triage-contract.bats
Scheduled and manual sweeps paginate open issues, enforce pacing and write caps, isolate per-issue failures, stop on HTTP 403 or 429 responses, report totals, and fail on refused writes. Contract tests verify these controls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IssueEvent
  participant IssueTriageWorkflow
  participant GitHubIssuesAPI
  IssueEvent->>IssueTriageWorkflow: trigger issue, schedule, or dispatch
  IssueTriageWorkflow->>GitHubIssuesAPI: retrieve open issues
  GitHubIssuesAPI-->>IssueTriageWorkflow: return paginated issues
  IssueTriageWorkflow->>IssueTriageWorkflow: classify issue and enforce write limits
  IssueTriageWorkflow->>GitHubIssuesAPI: apply triage label
  GitHubIssuesAPI-->>IssueTriageWorkflow: return write result or refusal
Loading

Possibly related issues

  • cozystack/cozystack#3589: Adds contract tests for label-writing workflows, which matches this PR's issue-triage contract suite.

Suggested labels: area/testing

Suggested reviewers: myasnikovdaniil

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: automatically applying triage labels to issues through CI.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-triage-labeler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/issue-triage.yaml:
- Around line 142-146: Update .github/workflows/issue-triage.yaml lines 142-146
to detect multiple triage labels and reconcile them to the documented
authoritative state instead of skipping every triage-labeled issue. Before the
event-driven addLabels call at lines 163-167, re-read and reclassify the issue;
likewise, before each sweep-item write at lines 203-220, re-read and reclassify
the issue so concurrent label changes cannot produce contradictory labels.
- Around line 189-194: Update the issue-processing flow around github.paginate
to use github.paginate.iterator with for-await page iteration, processing issues
incrementally and breaking once writes reaches MAX_WRITES_PER_RUN. Revise
deferred-count handling so it does not depend on fetching or retaining remaining
pages after the write cap is reached.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9c22e6e7-187f-47bc-ae6d-63c68acd84e8

📥 Commits

Reviewing files that changed from the base of the PR and between b4e2031 and 11d63da.

📒 Files selected for processing (2)
  • .github/workflows/issue-triage.yaml
  • hack/issue-triage-contract.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • hack/issue-triage-contract.bats

Comment thread .github/workflows/issue-triage.yaml
Comment thread .github/workflows/issue-triage.yaml
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Aug 7, 2026
## What this PR does

The comment above the top-level `permissions:` block said jobs request
the minimum extra scopes on top of it. That's backwards. A job-level
block replaces the workflow-level one, and [every scope it does not name
is set to
`none`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions).
Write `packages: write` alone in a job and it loses `contents: read` and
can't check the repo out.

Same wording in every file that carried the old line. It matches what
#3574 uses for the workflow it adds.

`backport.yaml` and `pull-requests.yaml` also appear in open #3569. Its
changed lines there are the `cancel-in-progress` expressions two lines
below the comment, and a three-way merge is clean in either order.

Comment-only: every changed line starts with `#`, the files still parse,
and actionlint reports the same 45 pre-existing findings before and
after.

### Screenshots

Not a UI change.

### Downstream repositories

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:

Walked the trigger map against the diff. The only row that names a file
here is the ccp one on `.github/workflows/tags.yaml`, and it triggers on
release-prep behaviour, which this doesn't touch.

### Release note

```release-note
docs(ci): the comment above the top-level `permissions` block in the workflow files now says a job-level block replaces it instead of adding to it
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
  * Clarified workflow permission guidance across automation processes.
  * Documented that the default token permissions are read-only.
* Clarified that job-level permissions replace top-level defaults and
must explicitly declare all required scopes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Issues created via the API or gh bypass the issue templates and arrive
without any triage/* label; roughly half of the open issues carry none.
Label issues on arrival (opened/reopened) and run a daily sweep as a
backstop: issues already prioritised, assigned, epics, or frozen get
triage/accepted, the rest triage/needs-triage.

The sweep paces its writes and caps how many it makes per run, so a
pass over a large backlog stays inside GitHub's secondary rate limits
instead of failing partway through.

The manual dispatch defaults to dry-run, so the one entry point a human
can reach writes nothing until somebody unchecks the box. The schedule
is the one event that writes without being asked.

No PR lane can exercise this workflow, so the first real run is a sweep
over every open issue. A contract test pins the executable lines that
bound that: the pull-request skip, the already-triaged skip, the pacing
and the cap, the dry-run default, the pinned action, and the token
scopes.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci Issues or PRs related to CI workflows, GitHub Actions, automation kind/feature Categorizes issue or PR as related to a new feature size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant