Skip to content

fix(source-control): PR-body linkage gates reject Refs: #N that CI accepts, and pass negated closers CI rejects (post ci-workflows#544 drift) #4183

Description

@kyle-sexton

Summary

The local PR-body linkage gates accept two linkage states (Closes/Fixes/Resolves #N, or the literal No linked issue / No related issue:). The pr-contract CI step they exist to predict accepts three — the same two plus an explicit non-closing Refs: #N / Relates to: #N marker on its own line — and additionally fails a negated closing reference that the local gates pass.

The gates drifted from CI when melodic-software/ci-workflows#544 (fix(pr-issue-linkage): accept Refs/Relates markers and fail negated closers, merged 2026-09-02) landed. Nothing local followed.

This is a two-directional contract break in one file:

  1. False positive (blocking). A body carrying Refs: #N and no closing keyword passes CI and is blocked locally. The author is told to add a closing keyword that would auto-close an issue the PR must not close — the exact hazard ci-workflows#544 added the marker to avoid.
  2. False negative. A body carrying a negated closer (does not close #N) passes locally and fails CI, which fails it outright because GitHub's linkage parser is negation-blind and will auto-close the issue on merge anyway.

The gate's own header states mirroring is the point: plugins/source-control/hooks/pr-body-linkage-gate.sh:12 — "WHAT IT ENFORCES — the five requirements of the linkage contract, mirrored" — and :27-38 keys enforcement to the consuming repo's own pr-contract step. So the hook is the side that should change; extending CI further would un-ship #544 for every consumer.

Fix

In plugins/source-control/hooks/pr-linkage-validator.sh (citations at main cf6d15df732ed4a12bbfef10a8be934b5398e8ea):

  • :151 KEYWORD_ERE and :152 NO_ISSUE_ERE are the only two patterns; :154-158 has_linkage() tests exactly those.
  • Add a third, line-anchored NON_CLOSING_ERE mirroring CI's shape (indent ≤ 3, marker on its own line, optional owner/repo prefix) and OR it into has_linkage at :157. There is no refs/relates pattern anywhere in plugins/source-control/hooks/ today.
  • Update the message at :343 ('Missing a native closing keyword (Closes/Fixes/Resolves #N) and no "No linked issue" marker.') to name the Refs: / Relates to: option, matching CI's own wording at run.sh:515.
  • Update the mirrored header text at pr-body-linkage-gate.sh:21-22 and pr-linkage-mcp-gate.sh:22, and the block remedy at pr-body-linkage-gate.sh:293 (echo " Closes #<issue> (or the literal line: No linked issue)", and its twin at pr-linkage-mcp-gate.sh:296).
  • Second half: add the negated-closer check CI runs, or state explicitly in the header that the local gate does not model it. CI's implementation is run.sh:245-277 (negation_trigger(), the disclaimer-word scan) surfacing at :508-510.
  • Cover both shapes in pr-body-linkage-gate.test.sh so the pair cannot drift again silently.

Verification

Upstream, melodic-software/ci-workflows .github/actions/pr-contract/run.sh, read at tag v0.22.2 (5776760254f8b63cba44e896f51604cb755350d9) and re-read at v0.24.0 (2c1de45aa0e1b1489afb8edfebc12cb3a4fa6ac3) — the linkage logic is byte-identical at both:

  • :200-203 (header) — "the body must carry a native closing keyword, an explicit non-closing Refs:/Relates to: marker, or a no-issue opt-out".
  • :284-285if (rest ~ /^(refs|relates[ \t]+to):[ \t]*([a-z0-9_.-]+\/[a-z0-9_.-]+)?#[0-9]+[ \t]*$/) { has_non_closing = 1 }
  • :444if (has_non_closing) print "non-closing"
  • :512-515 — the "Missing a native closing keyword" error fires only when none of closing, non-closing, no-issue is present.
  • :509 — the negated-closer error, which has no local counterpart.

Local side, plugins/source-control/hooks/ at main cf6d15d — verified byte-identical to the installed plugin cache at source-control 0.55.88, and the same rule is present unchanged back to 0.55.31 (pr-linkage-validator.sh:92-93 there), so this is not a stale-cache artifact.

A concrete repro: a PR body carrying Refs: #1234 on its own line, the four contract sections, and no closing keyword. pr-body-linkage-gate.sh blocks the gh pr create; the pr-contract step accepts it.

Related

Refs: #630

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9DAKHEMJr77LfCskkyit6

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions