ci: require every PR description to link its issue - #1835
Conversation
Closes #1800 A merged PR closes an issue only when a closing keyword sits directly before the issue number in its description. Without one, a resolved issue stays open after the merge. Add a "PR Issue Link" workflow that fails unless the description links an issue: a closing link (Closes/Fixes/Resolves #N) when the PR resolves it, or a non-closing link (Refs/Part of/Related to #N) when it does not. When the branch name encodes an issue (github-issue-N, feature-request-N, issue-N), that issue must be among the links. Bot-authored PRs are exempt. HTML comments, code spans and fenced code are ignored, as GitHub ignores them, so the template's examples cannot satisfy the check. The rule lives in scripts/check_pr_issue_link.py, unit-tested under Script Tests. The workflow is separate from ci.yaml because it must re-run on the `edited` event, which in ci.yaml would restart the whole pipeline on every description edit. Document the convention in CLAUDE.md, CONTRIBUTING.md, the PR template and docs/contributing/pull-requests.md. That guide also gets its broken closing fences repaired and its stale template sample replaced.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved workflow security and HTML-comment parsing findings must be addressed.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds automated enforcement that every PR description links an issue, with validation, workflow integration, tests, and documentation updates.
Changes:
- Adds issue-link parsing, branch validation, and bot exemptions.
- Adds the
PR Issue Linkworkflow and CI coverage. - Updates PR templates and contributor documentation.
File summaries
| File | Final review notes |
|---|---|
scripts/check_pr_issue_link.py |
Moderate findings (1 and 2 votes): HTML comments can discard valid text following their closing markers. |
scripts/check_pr_issue_link_test.py |
No final review comments. |
docs/contributing/pull-requests.md |
No final review comments. |
CONTRIBUTING.md |
No final review comments. |
CLAUDE.md |
No final review comments. |
.github/workflows/pr-issue-link.yml |
Critical finding (3 votes): the PR-controlled workflow and script can bypass the check; use trusted base-branch execution. |
.github/workflows/ci.yaml |
No final review comments. |
.github/PULL_REQUEST_TEMPLATE.md |
No final review comments. |
Review details
Suppressed comments (2)
scripts/check_pr_issue_link.py:109
- When a multiline comment closes on this line, the
continuediscards everything after-->as well. A description such as<!--\nexample\n--> Closes #1800therefore fails even thoughCloses #1800is outside the comment; preserve and reprocess the suffix after the closing marker.
if in_comment:
in_comment = "-->" not in line
continue
scripts/check_pr_issue_link.py:159
- Splitting into paragraphs before stripping inline markup lets an HTML comment span a blank line and then exposes its contents to the matcher. For example,
intro <!--\n\nCloses #1800\n-->leavesCloses #1800in the second paragraph even though GitHub treats it as comment content; strip inline constructs over the full block or carry comment state across paragraph boundaries.
paragraphs = _PARAGRAPH_BREAK.split(_strip_blocks(body or ""))
return "\n\n".join(_strip_inline(p) for p in paragraphs)
- Files reviewed: 8/8 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
📦 Build artifacts for this PR · commit
Artifacts expire in 7 days. Downloading requires being signed in to GitHub. macOS needs two extractions: unzip the downloaded artifact, then unzip the Updated automatically on each push. |
Refs #1800 Run the workflow on pull_request_target. Under pull_request the workflow and script come from the PR's merge ref, so a PR could edit either to report green and bypass its own gate. pull_request_target runs both as they exist on main, while its check run still lands on the PR head commit that branch protection reads. The job never checks out or runs PR code (main checkout, credentials not persisted, description read through the API), which is what makes that trigger safe here. Keep visible text after an HTML comment closes. A comment opening and closing on one line was dropped with the rest of that line, and the closing line of a multi-line comment lost its suffix, so `<!-- note --> Closes #1800` did not count as a link.
Related Issue
Closes #1800
Summary
GitHub closes an issue on merge only when a closing keyword sits directly before the issue number in the PR description. A number in the title, or a passing mention in prose, links nothing, so a resolved issue can stay open after its PR merges. This PR makes the link a required, checked part of every PR: its description must say which issue it relates to.
Changes
PR Issue Linkworkflow (.github/workflows/pr-issue-link.yml) runsscripts/check_pr_issue_link.pyonpull_request_target(opened,edited,reopened,synchronize). It fails unless the description contains:Closes/Fixes/Resolves#N (all nine GitHub keywords, case-insensitive, optional colon), orRefs/Part of/Related to#N, for phased work and follow-ups that must not close the issue early.github-issue-N,feature-request-N,issue-N), that issue must be among the links.promote.ymland Dependabot PRs still auto-merge.edited. In ci.yaml that trigger would restart the whole pipeline on every description edit.pull_request_target: underpull_requestthe workflow and script come from the PR's merge ref, so a PR could edit either to report green and bypass its own gate.pull_request_targetruns both as they exist on main. Its check run still lands on the PR head commit (verified on this repo: apull_request_targetrun'shead_shais the PR head), which is where branch protection looks. The trigger is safe here because the job never checks out or runs PR code: it checks out main with credentials not persisted, reads the description through the API, and passes untrusted fields to the script only through env vars.gh api, so re-running a failed job sees the corrected description..github/PULL_REQUEST_TEMPLATE.md(new "Related Issue" section) document the rule.docs/contributing/pull-requests.mdgains a "Linking Issues" section.```text, which is not a valid closing fence, so most of the page rendered as one code block.The examples in #1800
None of the three examples is actually a missed auto-close, so this PR leaves them as they are:
After merge
The check blocks merges only once it is a required status check. This adds it without touching
CI Successor any other protection field:gh api -X POST repos/submersion-app/submersion/branches/main/protection/required_status_checks/contexts -f 'contexts[]=PR Issue Link'Once it is required, PRs that are already open show "Expected" until someone pushes to them or edits their description. Over the last 40 merged PRs, 26 linked no issue and would have been blocked, so expect most PRs to need an issue opened or referenced first.
Test Plan
python3 scripts/check_pr_issue_link_test.py: 48 tests pass on Python 3.14 and 3.9, 100% line coverage of the scriptclosingIssuesReferencesfor every PR (0 mismatches); both bot bump PRs passpython3 scripts/check_ci_success_gate.pypasses; actionlint reports nothing for the new workflowpull_request) ran the check on this PR and passed:ok closes #1800. Since the switch topull_request_target, the workflow runs as it exists on main, so this PR gets no further run; the first run on another PR happens after merge.