Skip to content

Commit 82c1a69

Browse files
ci: add PR hygiene automation (linked issue check + stale PR cleanup) (#521)
* ci: add PR hygiene automation (linked issue check + stale PR cleanup) Add two workflows to enforce contribution quality and clean up abandoned PRs: - pr-linked-issue.yml: required status check that validates external PRs reference a triaged issue. Collaborators bypass. Re-triggers automatically when a maintainer adds the `triaged` label to the linked issue. - pr-stale.yml: daily cron that reminds authors of failing checks after 7/14 days of inactivity and auto-closes after 14/28 days (external/collaborator). Respects `keep-open` label. New labels created: `triaged`, `task`, `keep-open`. Closes #518 Signed-off-by: Andrea Manoel <amanoel@nvidia.com> * ci: add agentic repository triage workflow Add a weekly scheduled workflow that uses Claude to triage all open issues and PRs, producing a combined dashboard report on a pinned tracking issue. - New recipe (.agents/recipes/issue-triage/) classifies issues, checks staleness, cross-references merged PRs, detects duplicates, and flags PR health problems (missing linked issues, failing checks, orphaned PRs) - New workflow (.github/workflows/agentic-ci-issue-triage.yml) runs every Monday 10:00 UTC on the agentic-ci runner, with manual dispatch support - pr-stale.yml now adds needs-attention label to linked issues when a PR is auto-closed, bridging the two workflows via labels * docs: document stale PR policy and auto-retrigger in CONTRIBUTING.md * fix: address review findings in PR hygiene workflows - pr-linked-issue: fix comment gate so failure comments are posted - pr-stale: upgrade issues permission to write for labeling - pr-stale: compare reminder timestamp against last activity so push/comment actually resets the stale timer * fix: use --body-file in retrigger job to avoid shell quoting issues PR bodies with backticks or unmatched quotes would break the gh pr edit --body "$NEW_BODY" call. Write to a temp file and use --body-file instead. * fix: retrigger job drops PRs after the first jq outputs newline-separated numbers but GITHUB_OUTPUT only preserves the first line. Convert to space-separated so the for loop processes all matching PRs. * fix: harden workflows against shell injection - Move attacker-influenced values (${{ user.login }}, step outputs) from expression interpolation in run: blocks to env vars - Replace echo "$PR_BODY" | grep with write-to-file + grep-file to avoid shell expansion of untrusted PR body content - Same treatment for PR body handling in retrigger and stale jobs * refactor: replace peter-evans actions with gh api calls Remove peter-evans/find-comment and peter-evans/create-or-update-comment third-party action dependencies. Replace with gh api calls for finding, creating, updating, and deleting bot comments. Eliminates supply chain risk from unpinned third-party actions. * docs: add pull_request_target security comment --------- Signed-off-by: Andrea Manoel <amanoel@nvidia.com>
1 parent 533a94b commit 82c1a69

File tree

6 files changed

+888
-1
lines changed

6 files changed

+888
-1
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
---
2+
name: issue-triage
3+
description: Weekly triage of open issues and PRs - classify, verify, detect staleness, duplicates, and cross-reference
4+
trigger: schedule
5+
tool: claude-code
6+
timeout_minutes: 15
7+
max_turns: 30
8+
permissions:
9+
contents: read
10+
issues: write
11+
pull-requests: read
12+
---
13+
14+
# Repository Triage
15+
16+
Triage all open issues and pull requests in this repository, then post a
17+
combined report to the tracking issue.
18+
19+
## Instructions
20+
21+
### 1. Gather data
22+
23+
Collect all open issues, open PRs, and recent merge activity:
24+
25+
```bash
26+
# All open issues with metadata
27+
gh issue list --state open --limit 200 \
28+
--json number,title,state,createdAt,updatedAt,labels,assignees,author,body
29+
30+
# All open PRs with metadata
31+
gh pr list --state open --limit 200 \
32+
--json number,title,state,createdAt,updatedAt,labels,author,headRefName,body
33+
34+
# Recently merged PRs (last 60 days) to cross-reference
35+
gh pr list --state merged --limit 100 \
36+
--json number,title,headRefName,body,mergedAt
37+
38+
# PR check status for open PRs
39+
for pr in $(gh pr list --state open --json number --jq '.[].number'); do
40+
echo "=== PR #${pr} ==="
41+
gh pr checks "$pr" --json name,state --jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length'
42+
done
43+
```
44+
45+
### 2. Triage issues
46+
47+
For each open issue, determine:
48+
49+
**Classification** (pick one):
50+
- `bug` - something is broken
51+
- `feature` - new capability or enhancement
52+
- `chore` - maintenance, CI, docs, refactoring
53+
- `discussion` - needs design input or decision before work starts
54+
55+
**Staleness** (based on last update, today's date, and activity):
56+
- `active` - updated within the last 14 days
57+
- `aging` - updated 14-30 days ago
58+
- `stale` - no update for 30+ days
59+
60+
**Verification** - check if the issue has been addressed:
61+
- Search merged PRs for closing keywords (`Fixes #N`, `Closes #N`, `Resolves #N`)
62+
referencing this issue
63+
- Search merged PR titles and branches for keywords matching the issue
64+
- If a merged PR appears to fix the issue, flag it as `potentially resolved`
65+
- If there is an open PR linked to the issue, note the PR number
66+
67+
**Labels as signals** - issues with `needs-attention` were flagged by the stale
68+
PR workflow because their linked PR was auto-closed. Always include these in the
69+
"Action needed" section.
70+
71+
**Duplicates / related** - flag issues that overlap in scope or description.
72+
73+
### 3. Triage PRs
74+
75+
For each open PR, determine:
76+
77+
**Health flags** (check all that apply):
78+
- `no-issue` - PR body has no `Fixes/Closes/Resolves #N` reference (external
79+
contributors only - collaborators are exempt)
80+
- `issue-closed` - PR links to an issue that is already closed (by another PR
81+
or manually)
82+
- `checks-failing` - PR has failing CI checks
83+
- `stale` - no author activity (push or comment) for 14+ days with failing
84+
checks
85+
- `duplicate-fix` - another open PR references the same issue
86+
87+
**Cross-reference** - for each PR that references an issue:
88+
- Verify the linked issue exists and is open
89+
- Check if another open or merged PR also references the same issue
90+
- If two open PRs fix the same issue, flag both as `duplicate-fix`
91+
92+
### 4. Build the report
93+
94+
Write the combined report to `/tmp/issue-triage-report.md` using this format:
95+
96+
```markdown
97+
<!-- agentic-ci-issue-triage -->
98+
## Repository Triage Report
99+
100+
**Run date:** YYYY-MM-DD
101+
**Open issues:** N | **Open PRs:** N
102+
103+
---
104+
105+
### Issues: action needed
106+
107+
Issues that need maintainer attention (potentially resolved, stale with no
108+
assignee, possible duplicates, needs-attention label).
109+
110+
| # | Title | Category | Staleness | Flag | Notes |
111+
|---|-------|----------|-----------|------|-------|
112+
113+
### Issues: active work
114+
115+
Issues with assignees or linked open PRs.
116+
117+
| # | Title | Category | Assignee | PR | Last updated |
118+
|---|-------|----------|----------|-----|-------------|
119+
120+
### Issues: backlog
121+
122+
Remaining open issues, ordered by staleness (most stale first).
123+
124+
| # | Title | Category | Staleness | Last updated |
125+
|---|-------|----------|-----------|-------------|
126+
127+
---
128+
129+
### PRs: action needed
130+
131+
PRs with health flags that need maintainer attention.
132+
133+
| # | Title | Author | Flags | Notes |
134+
|---|-------|--------|-------|-------|
135+
136+
### PRs: healthy
137+
138+
Open PRs with no flags.
139+
140+
| # | Title | Author | Linked issue | Last updated |
141+
|---|-------|--------|-------------|-------------|
142+
143+
---
144+
145+
### Summary
146+
147+
**Issues:**
148+
- N triaged, N flagged for action, N active, N backlog
149+
- Flags: X potentially resolved, Y stale, Z duplicates
150+
151+
**PRs:**
152+
- N triaged, N flagged
153+
- Flags: X no linked issue, Y checks failing, Z stale, W duplicate fixes
154+
```
155+
156+
### 5. Post the report
157+
158+
Find the tracking issue number from the `ISSUE_TRIAGE_TRACKING_ISSUE`
159+
environment variable. Find the last comment by `github-actions[bot]` that
160+
contains `<!-- agentic-ci-issue-triage -->` and note its ID.
161+
162+
- If a previous comment exists, **edit it in place** using
163+
`gh api -X PATCH repos/{owner}/{repo}/issues/comments/{id}`.
164+
- If no previous comment exists, post a new comment using `gh issue comment`.
165+
166+
```bash
167+
# Edit existing comment
168+
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \
169+
-f body="$(cat /tmp/issue-triage-report.md)"
170+
171+
# Or post new comment
172+
gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md
173+
```
174+
175+
## Constraints
176+
177+
- **Read-only triage.** Do not close, label, or modify any issues or PRs. The
178+
report is for maintainers to act on.
179+
- **Do not post the report yourself if you cannot find the tracking issue.**
180+
Write the report to `/tmp/issue-triage-report.md` and stop. The workflow
181+
will handle fallback posting.
182+
- **Stay concise.** Notes columns should be one sentence max. Link to the
183+
relevant PR, issue, or duplicate - don't explain the fix.
184+
- **Cost awareness.** Do not read full issue/PR bodies unless needed to
185+
determine duplicates or verify cross-references. The metadata from
186+
`gh issue list` and `gh pr list` is enough for most checks.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
name: "Agentic CI: Repository Triage"
5+
6+
on:
7+
schedule:
8+
- cron: "0 10 * * 1" # every Monday at 10:00 UTC
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: read
13+
issues: write
14+
pull-requests: read
15+
16+
concurrency:
17+
group: agentic-ci-issue-triage
18+
cancel-in-progress: true
19+
20+
jobs:
21+
triage:
22+
if: github.repository_owner == 'NVIDIA-NeMo'
23+
runs-on: [self-hosted, agentic-ci]
24+
timeout-minutes: 15
25+
steps:
26+
- name: Check required config
27+
env:
28+
AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
29+
TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
30+
run: |
31+
if [ -z "$AGENTIC_CI_MODEL" ]; then
32+
echo "::error::AGENTIC_CI_MODEL variable is not set. Configure it in repo settings."
33+
exit 1
34+
fi
35+
if [ -z "$TRACKING_ISSUE" ]; then
36+
echo "::error::ISSUE_TRIAGE_TRACKING_ISSUE variable is not set. Create a pinned issue and set the variable."
37+
exit 1
38+
fi
39+
40+
- name: Checkout main
41+
uses: actions/checkout@v4
42+
with:
43+
ref: main
44+
45+
- name: Pre-flight checks
46+
env:
47+
ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }}
48+
ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }}
49+
AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
50+
run: |
51+
if ! command -v claude &> /dev/null; then
52+
echo "::error::claude CLI not found in PATH"
53+
exit 1
54+
fi
55+
echo "Claude CLI version: $(claude --version 2>&1 || true)"
56+
57+
if [ -n "$ANTHROPIC_BASE_URL" ] && [ -n "$ANTHROPIC_API_KEY" ]; then
58+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
59+
--max-time 10 \
60+
-X POST "${ANTHROPIC_BASE_URL}/v1/messages" \
61+
-H "Content-Type: application/json" \
62+
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
63+
-H "anthropic-version: 2023-06-01" \
64+
-d "{\"model\":\"${AGENTIC_CI_MODEL}\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}")
65+
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
66+
echo "::error::API pre-flight failed with HTTP ${HTTP_CODE}"
67+
exit 1
68+
fi
69+
echo "API pre-flight passed (HTTP ${HTTP_CODE})"
70+
fi
71+
72+
- name: Run issue triage recipe
73+
env:
74+
ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }}
75+
ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }}
76+
AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }}
77+
DISABLE_PROMPT_CACHING: "1"
78+
GH_TOKEN: ${{ github.token }}
79+
ISSUE_TRIAGE_TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
80+
GITHUB_REPOSITORY: ${{ github.repository }}
81+
run: |
82+
set -o pipefail
83+
84+
RUNNER_CTX=$(cat .agents/recipes/_runner.md)
85+
RECIPE_BODY=$(cat .agents/recipes/issue-triage/recipe.md \
86+
| sed '1,/^---$/{ /^---$/,/^---$/d }')
87+
88+
PROMPT=$(printf '%s\n\n%s\n' "${RUNNER_CTX}" "${RECIPE_BODY}")
89+
90+
claude \
91+
--model "$AGENTIC_CI_MODEL" \
92+
-p "$PROMPT" \
93+
--max-turns 30 \
94+
--output-format text \
95+
--verbose \
96+
2>&1 | tee /tmp/claude-triage-log.txt || true
97+
continue-on-error: true
98+
99+
- name: Fallback post if agent did not post
100+
env:
101+
GH_TOKEN: ${{ github.token }}
102+
TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }}
103+
run: |
104+
if [ ! -s "/tmp/issue-triage-report.md" ]; then
105+
echo "::warning::Triage report not created by agent."
106+
exit 0
107+
fi
108+
109+
# Check if the agent already posted/updated the comment.
110+
MARKER="<!-- agentic-ci-issue-triage -->"
111+
EXISTING=$(gh api "repos/${{ github.repository }}/issues/${TRACKING_ISSUE}/comments" \
112+
--jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${MARKER}\"))] | last | .id" \
113+
2>/dev/null || echo "")
114+
115+
REPORT=$(cat /tmp/issue-triage-report.md)
116+
117+
# Only post if the report marker is not already in a recent comment
118+
# with today's date (agent already posted).
119+
TODAY=$(date -u +%Y-%m-%d)
120+
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
121+
EXISTING_BODY=$(gh api "repos/${{ github.repository }}/issues/comments/${EXISTING}" --jq '.body')
122+
if echo "$EXISTING_BODY" | grep -q "$TODAY"; then
123+
echo "Agent already posted today's report. Skipping fallback."
124+
exit 0
125+
fi
126+
# Update existing comment.
127+
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \
128+
-f body="$REPORT"
129+
echo "Updated existing triage comment."
130+
else
131+
gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md
132+
echo "Posted new triage comment."
133+
fi
134+
135+
- name: Write job summary
136+
if: always()
137+
run: |
138+
if [ -s "/tmp/issue-triage-report.md" ]; then
139+
cat /tmp/issue-triage-report.md >> "$GITHUB_STEP_SUMMARY"
140+
else
141+
echo "No triage report was generated." >> "$GITHUB_STEP_SUMMARY"
142+
fi
143+
144+
if [ -s "/tmp/claude-triage-log.txt" ]; then
145+
echo "" >> "$GITHUB_STEP_SUMMARY"
146+
echo "<details><summary>Agent log</summary>" >> "$GITHUB_STEP_SUMMARY"
147+
echo "" >> "$GITHUB_STEP_SUMMARY"
148+
echo '```' >> "$GITHUB_STEP_SUMMARY"
149+
tail -100 /tmp/claude-triage-log.txt >> "$GITHUB_STEP_SUMMARY"
150+
echo '```' >> "$GITHUB_STEP_SUMMARY"
151+
echo "</details>" >> "$GITHUB_STEP_SUMMARY"
152+
fi

0 commit comments

Comments
 (0)