Skip to content

Commit abec898

Browse files
committed
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>
1 parent fdd5ebb commit abec898

4 files changed

Lines changed: 523 additions & 1 deletion

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
name: "Linked Issue Check"
5+
6+
on:
7+
# Re-check when PR is opened or body is edited (author adds Fixes #N).
8+
pull_request_target:
9+
types: [opened, edited, synchronize, reopened]
10+
branches: [main]
11+
12+
# Re-check open PRs when a maintainer adds the "triaged" label to an issue.
13+
issues:
14+
types: [labeled]
15+
16+
permissions:
17+
contents: read
18+
pull-requests: write
19+
issues: read
20+
21+
jobs:
22+
# ── Job 1: validate linked issue on PR events ─────────────────────────
23+
check:
24+
if: >-
25+
github.repository_owner == 'NVIDIA-NeMo'
26+
&& github.event_name != 'issues'
27+
runs-on: ubuntu-latest
28+
steps:
29+
- name: Check author permissions
30+
id: author
31+
env:
32+
GH_TOKEN: ${{ github.token }}
33+
run: |
34+
USER="${{ github.event.pull_request.user.login }}"
35+
36+
# Bots that are always allowed (match DCO allowlist pattern).
37+
if [ "$USER" = "dependabot[bot]" ]; then
38+
echo "is_collaborator=true" >> "$GITHUB_OUTPUT"
39+
exit 0
40+
fi
41+
42+
PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${USER}/permission" \
43+
--jq '.permission' 2>/dev/null || echo "none")
44+
echo "permission=${PERMISSION}"
45+
46+
if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "write" ]; then
47+
echo "is_collaborator=true" >> "$GITHUB_OUTPUT"
48+
else
49+
echo "is_collaborator=false" >> "$GITHUB_OUTPUT"
50+
fi
51+
52+
- name: Parse issue reference from PR body
53+
id: parse
54+
if: steps.author.outputs.is_collaborator != 'true'
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
PR_BODY: ${{ github.event.pull_request.body }}
58+
run: |
59+
if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then
60+
echo "issue_num=" >> "$GITHUB_OUTPUT"
61+
echo "No PR body found"
62+
exit 0
63+
fi
64+
65+
# Case-insensitive match for Fixes #N, Closes #N, Resolves #N.
66+
ISSUE_NUM=$(echo "$PR_BODY" | grep -ioP '(?:fixes|closes|resolves)\s+#\K\d+' | head -1 || true)
67+
echo "issue_num=${ISSUE_NUM}" >> "$GITHUB_OUTPUT"
68+
echo "Parsed issue number: ${ISSUE_NUM:-<none>}"
69+
70+
- name: Validate issue exists and is triaged
71+
id: validate
72+
if: steps.author.outputs.is_collaborator != 'true' && steps.parse.outputs.issue_num != ''
73+
env:
74+
GH_TOKEN: ${{ github.token }}
75+
ISSUE_NUM: ${{ steps.parse.outputs.issue_num }}
76+
run: |
77+
RESPONSE=$(gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" 2>/dev/null) || {
78+
echo "issue_exists=false" >> "$GITHUB_OUTPUT"
79+
echo "is_triaged=false" >> "$GITHUB_OUTPUT"
80+
echo "Issue #${ISSUE_NUM} not found"
81+
exit 0
82+
}
83+
84+
# Verify it's an issue, not a PR (GitHub's issues API returns both).
85+
IS_PR=$(echo "$RESPONSE" | jq -r 'has("pull_request")')
86+
if [ "$IS_PR" = "true" ]; then
87+
echo "issue_exists=false" >> "$GITHUB_OUTPUT"
88+
echo "is_triaged=false" >> "$GITHUB_OUTPUT"
89+
echo "#${ISSUE_NUM} is a pull request, not an issue"
90+
exit 0
91+
fi
92+
93+
echo "issue_exists=true" >> "$GITHUB_OUTPUT"
94+
95+
TRIAGED=$(echo "$RESPONSE" | jq -r '[.labels[].name] | any(. == "triaged")')
96+
echo "is_triaged=${TRIAGED}" >> "$GITHUB_OUTPUT"
97+
echo "Issue #${ISSUE_NUM} exists, triaged=${TRIAGED}"
98+
99+
- name: Find existing comment
100+
uses: peter-evans/find-comment@v4
101+
id: find-comment
102+
with:
103+
issue-number: ${{ github.event.pull_request.number }}
104+
comment-author: "github-actions[bot]"
105+
body-includes: "<!-- linked-issue-check -->"
106+
107+
- name: Build comment body
108+
id: comment
109+
env:
110+
IS_COLLABORATOR: ${{ steps.author.outputs.is_collaborator }}
111+
ISSUE_NUM: ${{ steps.parse.outputs.issue_num }}
112+
ISSUE_EXISTS: ${{ steps.validate.outputs.issue_exists }}
113+
IS_TRIAGED: ${{ steps.validate.outputs.is_triaged }}
114+
run: |
115+
if [ "$IS_COLLABORATOR" = "true" ]; then
116+
# Collaborators pass; no comment needed.
117+
echo "status=pass" >> "$GITHUB_OUTPUT"
118+
echo "body=" >> "$GITHUB_OUTPUT"
119+
exit 0
120+
fi
121+
122+
if [ -z "$ISSUE_NUM" ]; then
123+
STATUS="fail"
124+
BODY=$(cat <<'MSG'
125+
<!-- linked-issue-check -->
126+
### Linked Issue Check
127+
128+
This PR does not reference an issue. External contributions must link to
129+
a triaged issue before the PR can be merged.
130+
131+
Add one of the following to your PR description:
132+
- `Fixes #<issue-number>`
133+
- `Closes #<issue-number>`
134+
- `Resolves #<issue-number>`
135+
136+
If no issue exists yet, [open one](https://github.com/NVIDIA-NeMo/DataDesigner/issues/new/choose)
137+
and a maintainer will triage it.
138+
139+
See [CONTRIBUTING.md](https://github.com/NVIDIA-NeMo/DataDesigner/blob/main/CONTRIBUTING.md)
140+
for details.
141+
MSG
142+
)
143+
elif [ "$ISSUE_EXISTS" != "true" ]; then
144+
STATUS="fail"
145+
BODY=$(cat <<MSG
146+
<!-- linked-issue-check -->
147+
### Linked Issue Check
148+
149+
The referenced issue #${ISSUE_NUM} was not found. Please check the issue
150+
number in your PR description.
151+
MSG
152+
)
153+
elif [ "$IS_TRIAGED" != "true" ]; then
154+
STATUS="fail"
155+
BODY=$(cat <<MSG
156+
<!-- linked-issue-check -->
157+
### Linked Issue Check
158+
159+
Issue #${ISSUE_NUM} has not been triaged yet. A maintainer needs to review
160+
the issue and add the \`triaged\` label before this PR can be merged.
161+
162+
You can continue working on the PR in the meantime. The check will
163+
re-run automatically once the issue is triaged.
164+
MSG
165+
)
166+
else
167+
STATUS="pass"
168+
BODY=$(cat <<MSG
169+
<!-- linked-issue-check -->
170+
### Linked Issue Check
171+
172+
Linked to triaged issue #${ISSUE_NUM}.
173+
MSG
174+
)
175+
fi
176+
177+
echo "status=${STATUS}" >> "$GITHUB_OUTPUT"
178+
# Use a temp file to avoid shell quoting issues.
179+
echo "$BODY" > /tmp/comment-body.md
180+
181+
- name: Post or update comment
182+
if: steps.comment.outputs.body != ''
183+
uses: peter-evans/create-or-update-comment@v5
184+
with:
185+
comment-id: ${{ steps.find-comment.outputs.comment-id }}
186+
issue-number: ${{ github.event.pull_request.number }}
187+
edit-mode: replace
188+
body-path: /tmp/comment-body.md
189+
190+
- name: Delete stale comment on success
191+
if: steps.comment.outputs.status == 'pass' && steps.find-comment.outputs.comment-id != ''
192+
env:
193+
GH_TOKEN: ${{ github.token }}
194+
run: |
195+
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${{ steps.find-comment.outputs.comment-id }}" || true
196+
197+
- name: Set check result
198+
if: steps.comment.outputs.status == 'fail'
199+
run: |
200+
echo "::error::Linked issue check failed. See the PR comment for details."
201+
exit 1
202+
203+
# ── Job 2: re-trigger check when an issue gets triaged ────────────────
204+
retrigger:
205+
if: >-
206+
github.repository_owner == 'NVIDIA-NeMo'
207+
&& github.event_name == 'issues'
208+
&& github.event.label.name == 'triaged'
209+
runs-on: ubuntu-latest
210+
steps:
211+
- name: Find PRs referencing this issue
212+
id: find-prs
213+
env:
214+
GH_TOKEN: ${{ github.token }}
215+
ISSUE_NUMBER: ${{ github.event.issue.number }}
216+
run: |
217+
# List open PRs and find those whose body references this issue.
218+
PRS=$(gh pr list --repo "${{ github.repository }}" --state open \
219+
--json number,body --limit 200 \
220+
| jq -r "[.[] | select(.body != null) | select(.body | test(\"(?i)(fixes|closes|resolves)\\\\s+#${ISSUE_NUMBER}\\\\b\")) | .number] | .[]")
221+
222+
if [ -z "$PRS" ]; then
223+
echo "No open PRs reference issue #${ISSUE_NUMBER}"
224+
echo "prs=" >> "$GITHUB_OUTPUT"
225+
else
226+
echo "Found PRs: ${PRS}"
227+
echo "prs=${PRS}" >> "$GITHUB_OUTPUT"
228+
fi
229+
230+
- name: Re-trigger linked issue check
231+
if: steps.find-prs.outputs.prs != ''
232+
env:
233+
GH_TOKEN: ${{ github.token }}
234+
ISSUE_NUMBER: ${{ github.event.issue.number }}
235+
run: |
236+
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
237+
238+
for PR_NUM in ${{ steps.find-prs.outputs.prs }}; do
239+
echo "Re-triggering check for PR #${PR_NUM}..."
240+
241+
# Read current PR body.
242+
CURRENT_BODY=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q '.body')
243+
244+
# Append or update hidden timestamp to trigger the 'edited' event,
245+
# which re-runs the check job.
246+
MARKER="<!-- triaged-recheck"
247+
if echo "$CURRENT_BODY" | grep -q "$MARKER"; then
248+
NEW_BODY=$(echo "$CURRENT_BODY" | sed "s|<!-- triaged-recheck[^>]*-->|<!-- triaged-recheck: ${TIMESTAMP} -->|")
249+
else
250+
NEW_BODY="${CURRENT_BODY}
251+
<!-- triaged-recheck: ${TIMESTAMP} -->"
252+
fi
253+
254+
gh pr edit "$PR_NUM" --repo "${{ github.repository }}" --body "$NEW_BODY"
255+
256+
# Post a visible comment so the author knows what happened.
257+
gh pr comment "$PR_NUM" --repo "${{ github.repository }}" --body \
258+
"Issue #${ISSUE_NUMBER} has been triaged. The linked issue check is being re-evaluated."
259+
done

0 commit comments

Comments
 (0)