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-linked-issue.yml b/.github/workflows/pr-linked-issue.yml new file mode 100644 index 000000000..4842a0464 --- /dev/null +++ b/.github/workflows/pr-linked-issue.yml @@ -0,0 +1,258 @@ +# 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 ───────────────────────── + # 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' + && github.event_name != 'issues' + runs-on: ubuntu-latest + steps: + - name: Check author permissions + id: author + env: + GH_TOKEN: ${{ github.token }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + USER="$PR_AUTHOR" + + # 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. + 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:-}" + + - 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: 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 + echo "status=pass" >> "$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" + cat > /tmp/comment-body.md <<'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" + 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" + cat > /tmp/comment-body.md < + ### 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" + fi + + echo "status=${STATUS}" >> "$GITHUB_OUTPUT" + + # 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' + 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=$(echo "$PRS" | tr '\n' ' ')" >> "$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 }} + PR_NUMBERS: ${{ steps.find-prs.outputs.prs }} + run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + for PR_NUM in $PR_NUMBERS; do + echo "Re-triggering check for PR #${PR_NUM}..." + + # 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/current-body.txt > /tmp/pr-body.md + else + 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 + + # 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..8efac6cd8 --- /dev/null +++ b/.github/workflows/pr-stale.yml @@ -0,0 +1,198 @@ +# 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: write + +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 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)" + + 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" + + # Signal linked issues so the triage workflow picks them up. + 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 + 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)" + + 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..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 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. 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 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.