From 6816268ee15c281f2b49db919071cddc8ad89a31 Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Thu, 9 Apr 2026 04:12:32 +0000 Subject: [PATCH 1/9] 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 --- .github/workflows/pr-linked-issue.yml | 259 ++++++++++++++++++++++++++ .github/workflows/pr-stale.yml | 171 +++++++++++++++++ CONTRIBUTING.md | 2 +- plans/518/pr-hygiene-plan.md | 92 +++++++++ 4 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-linked-issue.yml create mode 100644 .github/workflows/pr-stale.yml create mode 100644 plans/518/pr-hygiene-plan.md diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml new file mode 100644 index 000000000..75ceba530 --- /dev/null +++ b/.github/workflows/pr-linked-issue.yml @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: "Linked Issue Check" + +on: + # Re-check when PR is opened or body is edited (author adds Fixes #N). + pull_request_target: + types: [opened, edited, synchronize, reopened] + branches: [main] + + # Re-check open PRs when a maintainer adds the "triaged" label to an issue. + issues: + types: [labeled] + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + # ── Job 1: validate linked issue on PR events ───────────────────────── + check: + if: >- + github.repository_owner == 'NVIDIA-NeMo' + && github.event_name != 'issues' + runs-on: ubuntu-latest + steps: + - name: Check author permissions + id: author + env: + GH_TOKEN: ${{ github.token }} + run: | + USER="${{ github.event.pull_request.user.login }}" + + # Bots that are always allowed (match DCO allowlist pattern). + if [ "$USER" = "dependabot[bot]" ]; then + echo "is_collaborator=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${USER}/permission" \ + --jq '.permission' 2>/dev/null || echo "none") + echo "permission=${PERMISSION}" + + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then + echo "is_collaborator=true" >> "$GITHUB_OUTPUT" + else + echo "is_collaborator=false" >> "$GITHUB_OUTPUT" + fi + + - name: Parse issue reference from PR body + id: parse + if: steps.author.outputs.is_collaborator != 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then + echo "issue_num=" >> "$GITHUB_OUTPUT" + echo "No PR body found" + exit 0 + fi + + # Case-insensitive match for Fixes #N, Closes #N, Resolves #N. + ISSUE_NUM=$(echo "$PR_BODY" | grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' | head -1 || true) + echo "issue_num=${ISSUE_NUM}" >> "$GITHUB_OUTPUT" + echo "Parsed issue number: ${ISSUE_NUM:-}" + + - name: Validate issue exists and is triaged + id: validate + if: steps.author.outputs.is_collaborator != 'true' && steps.parse.outputs.issue_num != '' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUM: ${{ steps.parse.outputs.issue_num }} + run: | + RESPONSE=$(gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" 2>/dev/null) || { + echo "issue_exists=false" >> "$GITHUB_OUTPUT" + echo "is_triaged=false" >> "$GITHUB_OUTPUT" + echo "Issue #${ISSUE_NUM} not found" + exit 0 + } + + # Verify it's an issue, not a PR (GitHub's issues API returns both). + IS_PR=$(echo "$RESPONSE" | jq -r 'has("pull_request")') + if [ "$IS_PR" = "true" ]; then + echo "issue_exists=false" >> "$GITHUB_OUTPUT" + echo "is_triaged=false" >> "$GITHUB_OUTPUT" + echo "#${ISSUE_NUM} is a pull request, not an issue" + exit 0 + fi + + echo "issue_exists=true" >> "$GITHUB_OUTPUT" + + TRIAGED=$(echo "$RESPONSE" | jq -r '[.labels[].name] | any(. == "triaged")') + echo "is_triaged=${TRIAGED}" >> "$GITHUB_OUTPUT" + echo "Issue #${ISSUE_NUM} exists, triaged=${TRIAGED}" + + - name: Find existing comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Build comment body + id: comment + env: + IS_COLLABORATOR: ${{ steps.author.outputs.is_collaborator }} + ISSUE_NUM: ${{ steps.parse.outputs.issue_num }} + ISSUE_EXISTS: ${{ steps.validate.outputs.issue_exists }} + IS_TRIAGED: ${{ steps.validate.outputs.is_triaged }} + run: | + if [ "$IS_COLLABORATOR" = "true" ]; then + # Collaborators pass; no comment needed. + echo "status=pass" >> "$GITHUB_OUTPUT" + echo "body=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -z "$ISSUE_NUM" ]; then + STATUS="fail" + BODY=$(cat <<'MSG' + + ### Linked Issue Check + + This PR does not reference an issue. External contributions must link to + a triaged issue before the PR can be merged. + + Add one of the following to your PR description: + - `Fixes #` + - `Closes #` + - `Resolves #` + + If no issue exists yet, [open one](https://github.com/NVIDIA-NeMo/DataDesigner/issues/new/choose) + and a maintainer will triage it. + + See [CONTRIBUTING.md](https://github.com/NVIDIA-NeMo/DataDesigner/blob/main/CONTRIBUTING.md) + for details. + MSG + ) + elif [ "$ISSUE_EXISTS" != "true" ]; then + STATUS="fail" + BODY=$(cat < + ### Linked Issue Check + + The referenced issue #${ISSUE_NUM} was not found. Please check the issue + number in your PR description. + MSG + ) + elif [ "$IS_TRIAGED" != "true" ]; then + STATUS="fail" + BODY=$(cat < + ### Linked Issue Check + + Issue #${ISSUE_NUM} has not been triaged yet. A maintainer needs to review + the issue and add the \`triaged\` label before this PR can be merged. + + You can continue working on the PR in the meantime. The check will + re-run automatically once the issue is triaged. + MSG + ) + else + STATUS="pass" + BODY=$(cat < + ### Linked Issue Check + + Linked to triaged issue #${ISSUE_NUM}. + MSG + ) + fi + + echo "status=${STATUS}" >> "$GITHUB_OUTPUT" + # Use a temp file to avoid shell quoting issues. + echo "$BODY" > /tmp/comment-body.md + + - name: Post or update comment + if: steps.comment.outputs.body != '' + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + edit-mode: replace + body-path: /tmp/comment-body.md + + - name: Delete stale comment on success + if: steps.comment.outputs.status == 'pass' && steps.find-comment.outputs.comment-id != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${{ steps.find-comment.outputs.comment-id }}" || true + + - name: Set check result + if: steps.comment.outputs.status == 'fail' + run: | + echo "::error::Linked issue check failed. See the PR comment for details." + exit 1 + + # ── Job 2: re-trigger check when an issue gets triaged ──────────────── + retrigger: + if: >- + github.repository_owner == 'NVIDIA-NeMo' + && github.event_name == 'issues' + && github.event.label.name == 'triaged' + runs-on: ubuntu-latest + steps: + - name: Find PRs referencing this issue + id: find-prs + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + # List open PRs and find those whose body references this issue. + PRS=$(gh pr list --repo "${{ github.repository }}" --state open \ + --json number,body --limit 200 \ + | jq -r "[.[] | select(.body != null) | select(.body | test(\"(?i)(fixes|closes|resolves)\\\\s+#${ISSUE_NUMBER}\\\\b\")) | .number] | .[]") + + if [ -z "$PRS" ]; then + echo "No open PRs reference issue #${ISSUE_NUMBER}" + echo "prs=" >> "$GITHUB_OUTPUT" + else + echo "Found PRs: ${PRS}" + echo "prs=${PRS}" >> "$GITHUB_OUTPUT" + fi + + - name: Re-trigger linked issue check + if: steps.find-prs.outputs.prs != '' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + for PR_NUM in ${{ steps.find-prs.outputs.prs }}; do + echo "Re-triggering check for PR #${PR_NUM}..." + + # Read current PR body. + CURRENT_BODY=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q '.body') + + # Append or update hidden timestamp to trigger the 'edited' event, + # which re-runs the check job. + MARKER="||") + else + NEW_BODY="${CURRENT_BODY} + " + fi + + gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body "$NEW_BODY" + + # Post a visible comment so the author knows what happened. + gh pr comment "$PR_NUM" --repo "${{ github.repository }}" --body \ + "Issue #${ISSUE_NUMBER} has been triaged. The linked issue check is being re-evaluated." + done diff --git a/.github/workflows/pr-stale.yml b/.github/workflows/pr-stale.yml new file mode 100644 index 000000000..3e6da6115 --- /dev/null +++ b/.github/workflows/pr-stale.yml @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: "Stale PR Cleanup" + +on: + schedule: + - cron: "0 9 * * *" # daily at 09:00 UTC + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + issues: read + +jobs: + stale-check: + if: github.repository_owner == 'NVIDIA-NeMo' + runs-on: ubuntu-latest + steps: + - name: Check stale PRs + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + NOW=$(date -u +%s) + + # Thresholds in days. + REMIND_DAYS_EXTERNAL=7 + CLOSE_DAYS_EXTERNAL=14 + REMIND_DAYS_COLLAB=14 + CLOSE_DAYS_COLLAB=28 + + REMINDER_MARKER="" + CLOSE_MARKER="" + + # Fetch all open PRs. + PRS=$(gh pr list --repo "$REPO" --state open \ + --json number,author,labels,createdAt --limit 200) + + PR_COUNT=$(echo "$PRS" | jq 'length') + echo "Found ${PR_COUNT} open PRs" + + echo "$PRS" | jq -c '.[]' | while IFS= read -r PR; do + PR_NUM=$(echo "$PR" | jq -r '.number') + AUTHOR=$(echo "$PR" | jq -r '.author.login') + LABELS=$(echo "$PR" | jq -r '[.labels[].name] | join(",")') + + echo "--- PR #${PR_NUM} by ${AUTHOR} ---" + + # Skip if keep-open label is present. + if echo "$LABELS" | grep -q "keep-open"; then + echo " Skipping: has keep-open label" + continue + fi + + # Check if author is a collaborator. + PERMISSION=$(gh api "repos/${REPO}/collaborators/${AUTHOR}/permission" \ + --jq '.permission' 2>/dev/null || echo "none") + + IS_COLLAB=false + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then + IS_COLLAB=true + fi + + if [ "$IS_COLLAB" = "true" ]; then + REMIND_DAYS=$REMIND_DAYS_COLLAB + CLOSE_DAYS=$CLOSE_DAYS_COLLAB + else + REMIND_DAYS=$REMIND_DAYS_EXTERNAL + CLOSE_DAYS=$CLOSE_DAYS_EXTERNAL + fi + + # Check for failing checks on the PR's head commit. + CHECKS_JSON=$(gh pr checks "$PR_NUM" --repo "$REPO" --json name,state 2>/dev/null || echo "[]") + FAILING=$(echo "$CHECKS_JSON" | jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length') + + if [ "$FAILING" -eq 0 ]; then + echo " Skipping: no failing checks" + continue + fi + + FAILING_NAMES=$(echo "$CHECKS_JSON" | jq -r '[.[] | select(.state == "FAILURE" or .state == "ERROR") | .name] | join(", ")') + echo " Failing checks (${FAILING}): ${FAILING_NAMES}" + + # Find last author activity: latest push or author comment. + LAST_PUSH=$(gh api "repos/${REPO}/pulls/${PR_NUM}/commits" \ + --jq 'last | .commit.committer.date' 2>/dev/null || echo "") + LAST_COMMENT=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \ + --jq "[.[] | select(.user.login == \"${AUTHOR}\") | .created_at] | last" 2>/dev/null || echo "") + + # Convert to epoch, take the most recent. + LAST_PUSH_EPOCH=0 + LAST_COMMENT_EPOCH=0 + if [ -n "$LAST_PUSH" ] && [ "$LAST_PUSH" != "null" ]; then + LAST_PUSH_EPOCH=$(date -u -d "$LAST_PUSH" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_PUSH" +%s 2>/dev/null || echo 0) + fi + if [ -n "$LAST_COMMENT" ] && [ "$LAST_COMMENT" != "null" ]; then + LAST_COMMENT_EPOCH=$(date -u -d "$LAST_COMMENT" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_COMMENT" +%s 2>/dev/null || echo 0) + fi + + if [ "$LAST_PUSH_EPOCH" -gt "$LAST_COMMENT_EPOCH" ]; then + LAST_ACTIVITY_EPOCH=$LAST_PUSH_EPOCH + else + LAST_ACTIVITY_EPOCH=$LAST_COMMENT_EPOCH + fi + + if [ "$LAST_ACTIVITY_EPOCH" -eq 0 ]; then + # Fall back to PR creation date. + CREATED=$(echo "$PR" | jq -r '.createdAt') + LAST_ACTIVITY_EPOCH=$(date -u -d "$CREATED" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$CREATED" +%s 2>/dev/null || echo "$NOW") + fi + + DAYS_INACTIVE=$(( (NOW - LAST_ACTIVITY_EPOCH) / 86400 )) + echo " Days inactive: ${DAYS_INACTIVE} (remind=${REMIND_DAYS}, close=${CLOSE_DAYS}, collab=${IS_COLLAB})" + + # Check for existing reminder comment. + HAS_REMINDER=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \ + --jq "[.[] | select(.body | contains(\"${REMINDER_MARKER}\"))] | length" 2>/dev/null || echo 0) + + if [ "$DAYS_INACTIVE" -ge "$CLOSE_DAYS" ] && [ "$HAS_REMINDER" -gt 0 ]; then + echo " Closing PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days, reminder was posted)" + + CLOSE_BODY=$(cat < /tmp/close-body.md + gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/close-body.md + gh pr close "$PR_NUM" --repo "$REPO" + + elif [ "$DAYS_INACTIVE" -ge "$REMIND_DAYS" ] && [ "$HAS_REMINDER" -eq 0 ]; then + echo " Posting reminder on PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days)" + + GRACE_LEFT=$(( CLOSE_DAYS - DAYS_INACTIVE )) + if [ "$GRACE_LEFT" -lt 1 ]; then + GRACE_LEFT=1 + fi + + REMIND_BODY=$(cat < /tmp/remind-body.md + gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/remind-body.md + + else + echo " No action needed" + fi + done + + echo "Stale PR check complete" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de1bcc561..a40b09a32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ The repository includes skills for common development tasks. These are located i ## Pull Requests -- PRs should link to the issue they address (`Fixes #NNN` or `Closes #NNN`) +- PRs must link to the issue they address (`Fixes #NNN` or `Closes #NNN`). For external contributors, this is enforced by a required status check: the linked issue must exist and carry the `triaged` label (added by a maintainer after review). Collaborators are exempt from this check. - Use the `create-pr` skill for well-formatted PR descriptions, or follow the PR template - Ensure all checks pass before requesting review: ```bash diff --git a/plans/518/pr-hygiene-plan.md b/plans/518/pr-hygiene-plan.md new file mode 100644 index 000000000..b647112d2 --- /dev/null +++ b/plans/518/pr-hygiene-plan.md @@ -0,0 +1,92 @@ +--- +date: 2026-04-09 +status: in-progress +authors: + - andreatgretel +--- + +# Plan: PR Hygiene Automation + +Closes #518. + +## Problem + +External contributors open PRs and never come back - DCO, title, or other required +checks fail and the PR sits indefinitely. The PR template and CONTRIBUTING.md already +ask for linked issues and proper formatting, but nothing enforces it. + +## Goals + +1. **Linked issue check** - external PRs must reference a triaged issue to merge. +2. **Stale PR cleanup** - remind authors of failing checks, auto-close if unaddressed. +3. **Minimal friction** for collaborators - team members bypass the linked-issue check. + +## Non-goals + +- No agent or self-hosted runner involvement (plain GitHub Actions on `ubuntu-latest`). +- Not part of the agentic CI plan (plans/472). + +--- + +## Design + +### Linked issue check (`pr-linked-issue.yml`) + +**Trigger:** `pull_request_target: [opened, edited, synchronize, reopened]` + +`issues: [labeled]` (for re-check when an issue is triaged). + +Uses `pull_request_target` so the workflow token can post comments on fork PRs. +Safe because the workflow never checks out or executes PR code. + +**Two jobs:** + +1. **`check`** (PR events) - validates the PR: + - Collaborators (`admin`/`write` permission) pass unconditionally. + - Non-collaborators must have `Fixes #N`/`Closes #N`/`Resolves #N` in the PR + body, pointing to an existing issue with the `triaged` label. + - Posts/updates a comment explaining what's missing or confirming success. + +2. **`retrigger`** (issue labeled) - when `triaged` is added to an issue, finds + open PRs referencing it and edits a hidden HTML comment in their body to + trigger the `edited` event, which re-runs the `check` job. + +### Stale PR cleanup (`pr-stale.yml`) + +**Trigger:** daily cron (09:00 UTC) + `workflow_dispatch`. + +For each open PR with failing checks and no author activity: + +| Author type | Reminder | Auto-close | +|-------------|----------|------------| +| Non-collaborator | 7 days | 14 days | +| Collaborator | 14 days | 28 days | + +The `keep-open` label prevents auto-close. + +Uses `gh api` directly for comment management (looping over multiple PRs). + +### Labels + +| Label | Purpose | +|-------|---------| +| `triaged` | Maintainer approval gate for external PRs | +| `task` | Auto-label for development-task issue template (was missing) | +| `keep-open` | Prevent stale-PR auto-close | + +--- + +## Deliverables + +- [x] Labels created (`triaged`, `task`, `keep-open`) +- [ ] `.github/workflows/pr-linked-issue.yml` +- [ ] `.github/workflows/pr-stale.yml` +- [ ] CONTRIBUTING.md updated with linked-issue requirement +- [ ] `Linked Issue Check` added as required status check on `main` (post-merge) + +## Validation + +- Open test PR from non-collaborator, verify check blocks without triaged issue. +- Add `triaged` to linked issue, verify re-check passes. +- Verify collaborator PRs pass without a linked issue. +- Run stale workflow via `workflow_dispatch`, verify correct PR targeting. +- Confirm `keep-open` label prevents auto-close. From 2d638ea00ada63030a59ae6a948e074f26d3586a Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Fri, 10 Apr 2026 15:15:38 +0000 Subject: [PATCH 2/9] 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 --- .agents/recipes/issue-triage/recipe.md | 186 ++++++++++++++++++ .github/workflows/agentic-ci-issue-triage.yml | 152 ++++++++++++++ .github/workflows/pr-stale.yml | 11 ++ 3 files changed, 349 insertions(+) create mode 100644 .agents/recipes/issue-triage/recipe.md create mode 100644 .github/workflows/agentic-ci-issue-triage.yml diff --git a/.agents/recipes/issue-triage/recipe.md b/.agents/recipes/issue-triage/recipe.md new file mode 100644 index 000000000..396b0667a --- /dev/null +++ b/.agents/recipes/issue-triage/recipe.md @@ -0,0 +1,186 @@ +--- +name: issue-triage +description: Weekly triage of open issues and PRs - classify, verify, detect staleness, duplicates, and cross-reference +trigger: schedule +tool: claude-code +timeout_minutes: 15 +max_turns: 30 +permissions: + contents: read + issues: write + pull-requests: read +--- + +# Repository Triage + +Triage all open issues and pull requests in this repository, then post a +combined report to the tracking issue. + +## Instructions + +### 1. Gather data + +Collect all open issues, open PRs, and recent merge activity: + +```bash +# All open issues with metadata +gh issue list --state open --limit 200 \ + --json number,title,state,createdAt,updatedAt,labels,assignees,author,body + +# All open PRs with metadata +gh pr list --state open --limit 200 \ + --json number,title,state,createdAt,updatedAt,labels,author,headRefName,body + +# Recently merged PRs (last 60 days) to cross-reference +gh pr list --state merged --limit 100 \ + --json number,title,headRefName,body,mergedAt + +# PR check status for open PRs +for pr in $(gh pr list --state open --json number --jq '.[].number'); do + echo "=== PR #${pr} ===" + gh pr checks "$pr" --json name,state --jq '[.[] | select(.state == "FAILURE" or .state == "ERROR")] | length' +done +``` + +### 2. Triage issues + +For each open issue, determine: + +**Classification** (pick one): +- `bug` - something is broken +- `feature` - new capability or enhancement +- `chore` - maintenance, CI, docs, refactoring +- `discussion` - needs design input or decision before work starts + +**Staleness** (based on last update, today's date, and activity): +- `active` - updated within the last 14 days +- `aging` - updated 14-30 days ago +- `stale` - no update for 30+ days + +**Verification** - check if the issue has been addressed: +- Search merged PRs for closing keywords (`Fixes #N`, `Closes #N`, `Resolves #N`) + referencing this issue +- Search merged PR titles and branches for keywords matching the issue +- If a merged PR appears to fix the issue, flag it as `potentially resolved` +- If there is an open PR linked to the issue, note the PR number + +**Labels as signals** - issues with `needs-attention` were flagged by the stale +PR workflow because their linked PR was auto-closed. Always include these in the +"Action needed" section. + +**Duplicates / related** - flag issues that overlap in scope or description. + +### 3. Triage PRs + +For each open PR, determine: + +**Health flags** (check all that apply): +- `no-issue` - PR body has no `Fixes/Closes/Resolves #N` reference (external + contributors only - collaborators are exempt) +- `issue-closed` - PR links to an issue that is already closed (by another PR + or manually) +- `checks-failing` - PR has failing CI checks +- `stale` - no author activity (push or comment) for 14+ days with failing + checks +- `duplicate-fix` - another open PR references the same issue + +**Cross-reference** - for each PR that references an issue: +- Verify the linked issue exists and is open +- Check if another open or merged PR also references the same issue +- If two open PRs fix the same issue, flag both as `duplicate-fix` + +### 4. Build the report + +Write the combined report to `/tmp/issue-triage-report.md` using this format: + +```markdown + +## Repository Triage Report + +**Run date:** YYYY-MM-DD +**Open issues:** N | **Open PRs:** N + +--- + +### Issues: action needed + +Issues that need maintainer attention (potentially resolved, stale with no +assignee, possible duplicates, needs-attention label). + +| # | Title | Category | Staleness | Flag | Notes | +|---|-------|----------|-----------|------|-------| + +### Issues: active work + +Issues with assignees or linked open PRs. + +| # | Title | Category | Assignee | PR | Last updated | +|---|-------|----------|----------|-----|-------------| + +### Issues: backlog + +Remaining open issues, ordered by staleness (most stale first). + +| # | Title | Category | Staleness | Last updated | +|---|-------|----------|-----------|-------------| + +--- + +### PRs: action needed + +PRs with health flags that need maintainer attention. + +| # | Title | Author | Flags | Notes | +|---|-------|--------|-------|-------| + +### PRs: healthy + +Open PRs with no flags. + +| # | Title | Author | Linked issue | Last updated | +|---|-------|--------|-------------|-------------| + +--- + +### Summary + +**Issues:** +- N triaged, N flagged for action, N active, N backlog +- Flags: X potentially resolved, Y stale, Z duplicates + +**PRs:** +- N triaged, N flagged +- Flags: X no linked issue, Y checks failing, Z stale, W duplicate fixes +``` + +### 5. Post the report + +Find the tracking issue number from the `ISSUE_TRIAGE_TRACKING_ISSUE` +environment variable. Find the last comment by `github-actions[bot]` that +contains `` and note its ID. + +- If a previous comment exists, **edit it in place** using + `gh api -X PATCH repos/{owner}/{repo}/issues/comments/{id}`. +- If no previous comment exists, post a new comment using `gh issue comment`. + +```bash +# Edit existing comment +gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${COMMENT_ID}" \ + -f body="$(cat /tmp/issue-triage-report.md)" + +# Or post new comment +gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md +``` + +## Constraints + +- **Read-only triage.** Do not close, label, or modify any issues or PRs. The + report is for maintainers to act on. +- **Do not post the report yourself if you cannot find the tracking issue.** + Write the report to `/tmp/issue-triage-report.md` and stop. The workflow + will handle fallback posting. +- **Stay concise.** Notes columns should be one sentence max. Link to the + relevant PR, issue, or duplicate - don't explain the fix. +- **Cost awareness.** Do not read full issue/PR bodies unless needed to + determine duplicates or verify cross-references. The metadata from + `gh issue list` and `gh pr list` is enough for most checks. diff --git a/.github/workflows/agentic-ci-issue-triage.yml b/.github/workflows/agentic-ci-issue-triage.yml new file mode 100644 index 000000000..f1c36b6fe --- /dev/null +++ b/.github/workflows/agentic-ci-issue-triage.yml @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: "Agentic CI: Repository Triage" + +on: + schedule: + - cron: "0 10 * * 1" # every Monday at 10:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: agentic-ci-issue-triage + cancel-in-progress: true + +jobs: + triage: + if: github.repository_owner == 'NVIDIA-NeMo' + runs-on: [self-hosted, agentic-ci] + timeout-minutes: 15 + steps: + - name: Check required config + env: + AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }} + TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }} + run: | + if [ -z "$AGENTIC_CI_MODEL" ]; then + echo "::error::AGENTIC_CI_MODEL variable is not set. Configure it in repo settings." + exit 1 + fi + if [ -z "$TRACKING_ISSUE" ]; then + echo "::error::ISSUE_TRIAGE_TRACKING_ISSUE variable is not set. Create a pinned issue and set the variable." + exit 1 + fi + + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + + - name: Pre-flight checks + env: + ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }} + AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }} + run: | + if ! command -v claude &> /dev/null; then + echo "::error::claude CLI not found in PATH" + exit 1 + fi + echo "Claude CLI version: $(claude --version 2>&1 || true)" + + if [ -n "$ANTHROPIC_BASE_URL" ] && [ -n "$ANTHROPIC_API_KEY" ]; then + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + --max-time 10 \ + -X POST "${ANTHROPIC_BASE_URL}/v1/messages" \ + -H "Content-Type: application/json" \ + -H "x-api-key: ${ANTHROPIC_API_KEY}" \ + -H "anthropic-version: 2023-06-01" \ + -d "{\"model\":\"${AGENTIC_CI_MODEL}\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}") + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo "::error::API pre-flight failed with HTTP ${HTTP_CODE}" + exit 1 + fi + echo "API pre-flight passed (HTTP ${HTTP_CODE})" + fi + + - name: Run issue triage recipe + env: + ANTHROPIC_BASE_URL: ${{ secrets.AGENTIC_CI_API_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.AGENTIC_CI_API_KEY }} + AGENTIC_CI_MODEL: ${{ vars.AGENTIC_CI_MODEL }} + DISABLE_PROMPT_CACHING: "1" + GH_TOKEN: ${{ github.token }} + ISSUE_TRIAGE_TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -o pipefail + + RUNNER_CTX=$(cat .agents/recipes/_runner.md) + RECIPE_BODY=$(cat .agents/recipes/issue-triage/recipe.md \ + | sed '1,/^---$/{ /^---$/,/^---$/d }') + + PROMPT=$(printf '%s\n\n%s\n' "${RUNNER_CTX}" "${RECIPE_BODY}") + + claude \ + --model "$AGENTIC_CI_MODEL" \ + -p "$PROMPT" \ + --max-turns 30 \ + --output-format text \ + --verbose \ + 2>&1 | tee /tmp/claude-triage-log.txt || true + continue-on-error: true + + - name: Fallback post if agent did not post + env: + GH_TOKEN: ${{ github.token }} + TRACKING_ISSUE: ${{ vars.ISSUE_TRIAGE_TRACKING_ISSUE }} + run: | + if [ ! -s "/tmp/issue-triage-report.md" ]; then + echo "::warning::Triage report not created by agent." + exit 0 + fi + + # Check if the agent already posted/updated the comment. + MARKER="" + EXISTING=$(gh api "repos/${{ github.repository }}/issues/${TRACKING_ISSUE}/comments" \ + --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${MARKER}\"))] | last | .id" \ + 2>/dev/null || echo "") + + REPORT=$(cat /tmp/issue-triage-report.md) + + # Only post if the report marker is not already in a recent comment + # with today's date (agent already posted). + TODAY=$(date -u +%Y-%m-%d) + if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then + EXISTING_BODY=$(gh api "repos/${{ github.repository }}/issues/comments/${EXISTING}" --jq '.body') + if echo "$EXISTING_BODY" | grep -q "$TODAY"; then + echo "Agent already posted today's report. Skipping fallback." + exit 0 + fi + # Update existing comment. + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/${EXISTING}" \ + -f body="$REPORT" + echo "Updated existing triage comment." + else + gh issue comment "$TRACKING_ISSUE" --body-file /tmp/issue-triage-report.md + echo "Posted new triage comment." + fi + + - name: Write job summary + if: always() + run: | + if [ -s "/tmp/issue-triage-report.md" ]; then + cat /tmp/issue-triage-report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "No triage report was generated." >> "$GITHUB_STEP_SUMMARY" + fi + + if [ -s "/tmp/claude-triage-log.txt" ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "
Agent log" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + tail -100 /tmp/claude-triage-log.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "
" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/pr-stale.yml b/.github/workflows/pr-stale.yml index 3e6da6115..c765a5428 100644 --- a/.github/workflows/pr-stale.yml +++ b/.github/workflows/pr-stale.yml @@ -138,6 +138,17 @@ jobs: gh pr comment "$PR_NUM" --repo "$REPO" --body-file /tmp/close-body.md gh pr close "$PR_NUM" --repo "$REPO" + # Signal linked issues so the triage workflow picks them up. + PR_BODY=$(gh pr view "$PR_NUM" --repo "$REPO" --json body -q '.body' 2>/dev/null || echo "") + LINKED_ISSUE=$(echo "$PR_BODY" | grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' | head -1 || true) + if [ -n "$LINKED_ISSUE" ]; then + ISSUE_STATE=$(gh api "repos/${REPO}/issues/${LINKED_ISSUE}" --jq '.state' 2>/dev/null || echo "") + if [ "$ISSUE_STATE" = "open" ]; then + gh issue edit "$LINKED_ISSUE" --repo "$REPO" --add-label "needs-attention" 2>/dev/null || true + echo " Added needs-attention label to issue #${LINKED_ISSUE}" + fi + fi + elif [ "$DAYS_INACTIVE" -ge "$REMIND_DAYS" ] && [ "$HAS_REMINDER" -eq 0 ]; then echo " Posting reminder on PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days)" From 3fea4ffb5cee7065b6a487ee5728f39a4f47300c Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 17:30:51 +0000 Subject: [PATCH 3/9] docs: document stale PR policy and auto-retrigger in CONTRIBUTING.md --- CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a40b09a32..d1aa08f12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,8 @@ The repository includes skills for common development tasks. These are located i ## Pull Requests -- PRs must link to the issue they address (`Fixes #NNN` or `Closes #NNN`). For external contributors, this is enforced by a required status check: the linked issue must exist and carry the `triaged` label (added by a maintainer after review). Collaborators are exempt from this check. +- PRs must link to the issue they address (`Fixes #NNN` or `Closes #NNN`). For external contributors, this is enforced by a required status check: the linked issue must exist and carry the `triaged` label (added by a maintainer after review). Collaborators are exempt from this check. You can open the PR before the issue is triaged - the check re-runs automatically once a maintainer adds the label. +- PRs with failing checks that remain inactive are automatically reminded after 7 days and closed after 14 days (collaborators: 14/28 days). Push an update or leave a comment to reset the timer. If you need more time, ask a maintainer to add the `keep-open` label. - Use the `create-pr` skill for well-formatted PR descriptions, or follow the PR template - Ensure all checks pass before requesting review: ```bash From 0f3b25e8ab036c108f48d07d3ece92bb82747646 Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 17:52:40 +0000 Subject: [PATCH 4/9] 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 --- .github/workflows/pr-linked-issue.yml | 2 +- .github/workflows/pr-stale.yml | 24 ++++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index 75ceba530..64b48ed61 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -179,7 +179,7 @@ jobs: echo "$BODY" > /tmp/comment-body.md - name: Post or update comment - if: steps.comment.outputs.body != '' + if: steps.comment.outputs.status == 'fail' uses: peter-evans/create-or-update-comment@v5 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} diff --git a/.github/workflows/pr-stale.yml b/.github/workflows/pr-stale.yml index c765a5428..c2a563bec 100644 --- a/.github/workflows/pr-stale.yml +++ b/.github/workflows/pr-stale.yml @@ -11,7 +11,7 @@ on: permissions: contents: read pull-requests: write - issues: read + issues: write jobs: stale-check: @@ -116,9 +116,25 @@ jobs: DAYS_INACTIVE=$(( (NOW - LAST_ACTIVITY_EPOCH) / 86400 )) echo " Days inactive: ${DAYS_INACTIVE} (remind=${REMIND_DAYS}, close=${CLOSE_DAYS}, collab=${IS_COLLAB})" - # Check for existing reminder comment. - HAS_REMINDER=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \ - --jq "[.[] | select(.body | contains(\"${REMINDER_MARKER}\"))] | length" 2>/dev/null || echo 0) + # Check for existing reminder comment and when it was posted. + REMINDER_JSON=$(gh api "repos/${REPO}/issues/${PR_NUM}/comments" \ + --jq "[.[] | select(.body | contains(\"${REMINDER_MARKER}\"))] | last // empty" 2>/dev/null || echo "") + + HAS_REMINDER=0 + if [ -n "$REMINDER_JSON" ]; then + HAS_REMINDER=1 + REMINDER_ID=$(echo "$REMINDER_JSON" | jq -r '.id') + REMINDER_DATE=$(echo "$REMINDER_JSON" | jq -r '.created_at') + REMINDER_EPOCH=$(date -u -d "$REMINDER_DATE" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$REMINDER_DATE" +%s 2>/dev/null || echo 0) + + # If activity happened after the reminder, the author responded. + # Delete the stale reminder so the cycle restarts cleanly. + if [ "$LAST_ACTIVITY_EPOCH" -gt "$REMINDER_EPOCH" ]; then + echo " Activity after reminder - deleting stale reminder, resetting timer" + gh api -X DELETE "repos/${REPO}/issues/comments/${REMINDER_ID}" 2>/dev/null || true + HAS_REMINDER=0 + fi + fi if [ "$DAYS_INACTIVE" -ge "$CLOSE_DAYS" ] && [ "$HAS_REMINDER" -gt 0 ]; then echo " Closing PR #${PR_NUM} (inactive ${DAYS_INACTIVE} days, reminder was posted)" From 05c6704abf1445cf36c9f51598fb26c92a1e7d0e Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 17:59:59 +0000 Subject: [PATCH 5/9] 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. --- .github/workflows/pr-linked-issue.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index 64b48ed61..ce6fb6a8e 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -245,13 +245,14 @@ jobs: # which re-runs the check job. MARKER="||") + echo "$CURRENT_BODY" \ + | sed "s|||" \ + > /tmp/pr-body.md else - NEW_BODY="${CURRENT_BODY} - " + printf '%s\n' "$CURRENT_BODY" "$TIMESTAMP" > /tmp/pr-body.md fi - gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body "$NEW_BODY" + gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body-file /tmp/pr-body.md # Post a visible comment so the author knows what happened. gh pr comment "$PR_NUM" --repo "${{ github.repository }}" --body \ From 512f706a6f88279942f9dc8c3e2782c3d756593c Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 18:12:09 +0000 Subject: [PATCH 6/9] 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. --- .github/workflows/pr-linked-issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index ce6fb6a8e..c4ebfcfd2 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -224,7 +224,7 @@ jobs: echo "prs=" >> "$GITHUB_OUTPUT" else echo "Found PRs: ${PRS}" - echo "prs=${PRS}" >> "$GITHUB_OUTPUT" + echo "prs=$(echo "$PRS" | tr '\n' ' ')" >> "$GITHUB_OUTPUT" fi - name: Re-trigger linked issue check From 6f73b1dc45e7091992203d7d2752d6466d6b4da6 Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 18:19:04 +0000 Subject: [PATCH 7/9] 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 --- .github/workflows/pr-linked-issue.yml | 23 +++++++++++++---------- .github/workflows/pr-stale.yml | 4 ++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index c4ebfcfd2..683db42f0 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -30,8 +30,9 @@ jobs: id: author env: GH_TOKEN: ${{ github.token }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} run: | - USER="${{ github.event.pull_request.user.login }}" + USER="$PR_AUTHOR" # Bots that are always allowed (match DCO allowlist pattern). if [ "$USER" = "dependabot[bot]" ]; then @@ -63,7 +64,8 @@ jobs: fi # Case-insensitive match for Fixes #N, Closes #N, Resolves #N. - ISSUE_NUM=$(echo "$PR_BODY" | grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' | head -1 || true) + printf '%s' "$PR_BODY" > /tmp/pr-body-raw.txt + ISSUE_NUM=$(grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' /tmp/pr-body-raw.txt | head -1 || true) echo "issue_num=${ISSUE_NUM}" >> "$GITHUB_OUTPUT" echo "Parsed issue number: ${ISSUE_NUM:-}" @@ -232,24 +234,25 @@ jobs: env: GH_TOKEN: ${{ github.token }} ISSUE_NUMBER: ${{ github.event.issue.number }} + PR_NUMBERS: ${{ steps.find-prs.outputs.prs }} run: | TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) - for PR_NUM in ${{ steps.find-prs.outputs.prs }}; do + for PR_NUM in $PR_NUMBERS; do echo "Re-triggering check for PR #${PR_NUM}..." - # Read current PR body. - CURRENT_BODY=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q '.body') + # Read current PR body to a file to avoid shell expansion issues. + gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q '.body' > /tmp/current-body.txt # Append or update hidden timestamp to trigger the 'edited' event, # which re-runs the check job. MARKER="||" \ - > /tmp/pr-body.md + if grep -q "$MARKER" /tmp/current-body.txt; then + sed "s|||" \ + /tmp/current-body.txt > /tmp/pr-body.md else - printf '%s\n' "$CURRENT_BODY" "$TIMESTAMP" > /tmp/pr-body.md + cp /tmp/current-body.txt /tmp/pr-body.md + printf '\n' "$TIMESTAMP" >> /tmp/pr-body.md fi gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body-file /tmp/pr-body.md diff --git a/.github/workflows/pr-stale.yml b/.github/workflows/pr-stale.yml index c2a563bec..8efac6cd8 100644 --- a/.github/workflows/pr-stale.yml +++ b/.github/workflows/pr-stale.yml @@ -155,8 +155,8 @@ jobs: gh pr close "$PR_NUM" --repo "$REPO" # Signal linked issues so the triage workflow picks them up. - PR_BODY=$(gh pr view "$PR_NUM" --repo "$REPO" --json body -q '.body' 2>/dev/null || echo "") - LINKED_ISSUE=$(echo "$PR_BODY" | grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' | head -1 || true) + gh pr view "$PR_NUM" --repo "$REPO" --json body -q '.body' > /tmp/pr-body-raw.txt 2>/dev/null || true + LINKED_ISSUE=$(grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' /tmp/pr-body-raw.txt | head -1 || true) if [ -n "$LINKED_ISSUE" ]; then ISSUE_STATE=$(gh api "repos/${REPO}/issues/${LINKED_ISSUE}" --jq '.state' 2>/dev/null || echo "") if [ "$ISSUE_STATE" = "open" ]; then From 22be29f8fff08c9c26d33b712471a9fd585acd5f Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 18:23:52 +0000 Subject: [PATCH 8/9] 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. --- .github/workflows/pr-linked-issue.yml | 72 ++++++++++++--------------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index 683db42f0..ad9527f9a 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -98,32 +98,37 @@ jobs: echo "is_triaged=${TRIAGED}" >> "$GITHUB_OUTPUT" echo "Issue #${ISSUE_NUM} exists, triaged=${TRIAGED}" - - name: Find existing comment - uses: peter-evans/find-comment@v4 - id: find-comment - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: "github-actions[bot]" - body-includes: "" - - - name: Build comment body + - name: Build comment body and post result id: comment env: + GH_TOKEN: ${{ github.token }} IS_COLLABORATOR: ${{ steps.author.outputs.is_collaborator }} ISSUE_NUM: ${{ steps.parse.outputs.issue_num }} ISSUE_EXISTS: ${{ steps.validate.outputs.issue_exists }} IS_TRIAGED: ${{ steps.validate.outputs.is_triaged }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} run: | + MARKER="" + + # Find existing bot comment with our marker. + COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq "[.[] | select(.user.login == \"github-actions[bot]\") | select(.body | contains(\"${MARKER}\"))] | last | .id // empty" \ + 2>/dev/null || echo "") + if [ "$IS_COLLABORATOR" = "true" ]; then - # Collaborators pass; no comment needed. echo "status=pass" >> "$GITHUB_OUTPUT" - echo "body=" >> "$GITHUB_OUTPUT" + # Clean up any leftover comment from before the author became a collaborator. + if [ -n "$COMMENT_ID" ]; then + gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" || true + fi exit 0 fi + # Build comment body. if [ -z "$ISSUE_NUM" ]; then STATUS="fail" - BODY=$(cat <<'MSG' + cat > /tmp/comment-body.md <<'MSG' ### Linked Issue Check @@ -141,20 +146,18 @@ jobs: See [CONTRIBUTING.md](https://github.com/NVIDIA-NeMo/DataDesigner/blob/main/CONTRIBUTING.md) for details. MSG - ) elif [ "$ISSUE_EXISTS" != "true" ]; then STATUS="fail" - BODY=$(cat < /tmp/comment-body.md < ### Linked Issue Check The referenced issue #${ISSUE_NUM} was not found. Please check the issue number in your PR description. MSG - ) elif [ "$IS_TRIAGED" != "true" ]; then STATUS="fail" - BODY=$(cat < /tmp/comment-body.md < ### Linked Issue Check @@ -164,37 +167,24 @@ jobs: You can continue working on the PR in the meantime. The check will re-run automatically once the issue is triaged. MSG - ) else STATUS="pass" - BODY=$(cat < - ### Linked Issue Check - - Linked to triaged issue #${ISSUE_NUM}. - MSG - ) fi echo "status=${STATUS}" >> "$GITHUB_OUTPUT" - # Use a temp file to avoid shell quoting issues. - echo "$BODY" > /tmp/comment-body.md - - name: Post or update comment - if: steps.comment.outputs.status == 'fail' - uses: peter-evans/create-or-update-comment@v5 - with: - comment-id: ${{ steps.find-comment.outputs.comment-id }} - issue-number: ${{ github.event.pull_request.number }} - edit-mode: replace - body-path: /tmp/comment-body.md - - - name: Delete stale comment on success - if: steps.comment.outputs.status == 'pass' && steps.find-comment.outputs.comment-id != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${{ steps.find-comment.outputs.comment-id }}" || true + # Post, update, or delete the comment. + if [ "$STATUS" = "fail" ]; then + if [ -n "$COMMENT_ID" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${COMMENT_ID}" \ + -f body="$(cat /tmp/comment-body.md)" + else + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + -f body="$(cat /tmp/comment-body.md)" + fi + elif [ -n "$COMMENT_ID" ]; then + gh api -X DELETE "repos/${REPO}/issues/comments/${COMMENT_ID}" || true + fi - name: Set check result if: steps.comment.outputs.status == 'fail' From 7836998d9847dd5808b6a8847628853a0986e6de Mon Sep 17 00:00:00 2001 From: Andre Manoel Date: Mon, 13 Apr 2026 18:26:25 +0000 Subject: [PATCH 9/9] docs: add pull_request_target security comment --- .github/workflows/pr-linked-issue.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pr-linked-issue.yml b/.github/workflows/pr-linked-issue.yml index ad9527f9a..4842a0464 100644 --- a/.github/workflows/pr-linked-issue.yml +++ b/.github/workflows/pr-linked-issue.yml @@ -20,6 +20,11 @@ permissions: jobs: # ── Job 1: validate linked issue on PR events ───────────────────────── + # SECURITY: This workflow uses pull_request_target to get write access for + # posting comments on fork PRs. It MUST NOT check out or execute code from + # the PR branch. All inputs from the PR (body, author) are read via API + # only. Adding actions/checkout here would run untrusted fork code with + # base repo write permissions. check: if: >- github.repository_owner == 'NVIDIA-NeMo'