Decisions

ADR-145: PR title format with ticket ID prefix

On 2026-05-05 the fleet merged 18 PRs against this and sister repos. Every PR cited its driving Warp ticket in the *body* (`Closes warp#NNN`), and `gyrum-complete-pr` correctly fired the auto-tally close-verb extractor…

#145

ADR-145: PR title format with ticket ID prefix

Status: Proposed Date: 2026-05-05

Context

On 2026-05-05 the fleet merged 18 PRs against this and sister repos. Every PR cited its driving Warp ticket in the body (Closes warp#NNN), and gyrum-complete-pr correctly fired the auto-tally close-verb extractor (per warp#1055) so the tickets closed on merge. But the title-level surface — gh pr list, the GitHub web UI's notification feed, the email/Slack PR-review notification, the git log --oneline history after squashing, and the repo's release-page commit roll-up — never sees the ticket. An operator scanning the merge stream sees fourteen feat: ... and four fix: ... lines and has to click through each to recover which warp item drove the change.

This is a load-bearing readability problem because the ticket — not the PR — is the primary key the rest of the fleet's surfaces (Warp board, fleet-velocity event log, agent-cost ledger from warp#1357, per-project changelog from warp#1359) keep their state against. When the title is ticket-blind, every downstream surface that reads PR titles has to either re-derive the ticket from the body (the auto-tally extractor — the close-verb-gated parser at ~/.gyrum/devtools/test/auto-tally-pr-title.test.sh per warp#1055 contract — already does this on merge, but every additional reader paying that cost again is duplicate work) or settle for a ticket-blind view (and lose the cross-surface join).

This ADR is sub-ticket warp#1361 of EPIC warp#1358. It locks the contract — title format, regex, edge cases, enforcement schedule — so Phase-1 children warp#1362 / warp#1363 (gyrum-start-work emit; gyrum-review-pr / gyrum-complete-pr gate) can implement against a stable shape. Sister to warp#1330 (close-verb distinction) and warp#1325 (review-pr body splice) — same family of narrowing the ticket↔PR linkage discipline.

Decision

PR titles use the format [ID:warp#NNN]: <type>: <description>, where <type> is a conventional-commits type and the segment after the ]: prefix is exactly conventional-commits.

Format spec

The canonical regex (copy-paste-stable for both gyrum-start-work emission and gyrum-review-pr / gyrum-complete-pr validation):

^\[ID:warp#\d+(?:,\s*warp#\d+)*\]:\s+(feat|fix|chore|docs|refactor|test|perf|build|ci|style)!?:\s+.+$

The !? after the type allows the conventional-commits breaking-change marker (feat!:, fix!:) so title-format gating does not collide with the release-tooling convention for major-version-bumping changes.

Examples

  • Single ticket: [ID:warp#1361]: feat: ADR for PR title ticket-ID prefix
  • Multi-ticket (a single PR closes more than one ticket — common for atomic ADR-cluster landings): [ID:warp#1352, warp#1353]: feat: dual-extractor close-verb gate
  • Breaking change: [ID:warp#1400]: feat!: drop legacy auto-tally bare-mention extractor (illustrative; warp#1400 is a placeholder ticket number for the example)
  • Bug fix: [ID:warp#1330]: fix: auto-tally close-verb gate excludes parenthesised refs

No-ticket exempt path

PRs created with gyrum-start-work --no-ticket <name> (the explicitly-trivial-work escape hatch — typo fix, comment tweak; per the global preference rule on warp-ticket-first) emit plain conventional-commits titles with no ID prefix:

chore: typo in CLAUDE.md
docs: fix broken link in ADR-110

The validation regex matches either the prefix-form or the plain-form, so --no-ticket PRs pass without a special case in callers. The validator distinguishes by inspecting the --no-ticket audit log entry: if the PR was started with that flag, plain-form is required (a prefix-form title on a --no-ticket PR is a structural mismatch and the audit gate flags it); otherwise prefix-form is required.

Conventional-commits tooling compatibility

The segment after [ID:warp#NNN]: is exactly conventional-commits. Existing tooling that keys on ^(feat|fix|chore|...): continues to work after a one-line strip-prefix step:

title_for_cc_tools = re.sub(r"^\[ID:warp#\d+(?:,\s*warp#\d+)*\]:\s+", "", title)

This means release-please, semantic-release, GitHub's release-notes auto-categoriser, and any pull_request_target workflow that reads github.event.pull_request.title and matches the conventional-commits regex can be adapted by a single regex substitution at the point of consumption — no replacement of the underlying tool, no fork.

Title-generation responsibility

gyrum-start-work <name> --ticket warp#NNN emits the draft-PR title as [ID:warp#NNN]: feat: <name> at draft creation time. The current emission (gh pr create --draft --title "feat: $name" at start-work.sh:200) is updated to prepend the prefix when --ticket is supplied. With --no-ticket, emission is unchanged (plain feat: $name).

When --ticket is supplied with a comma-separated list (already supported by warp-claim semantics), the emitted prefix preserves the order: [ID:warp#1352, warp#1353]: feat: <name>.

Title-validation responsibility

Two checkpoints, both already structured for it:

  1. gyrum-review-pr runs the title-format regex check during the structural-checks loop, alongside the existing commit-message-format warn (review-pr.sh:699-707). A failing title is a structural-fail (the PR is not review-ready until it matches).

  2. gyrum-complete-pr runs the same regex check as a final-line gate before merge. Defence-in-depth: review-pr can be skipped (with audited reason); complete-pr is the merge boundary and the regex MUST pass there.

Soft-warn period

For the first 7 calendar days after this ADR merges, the gyrum-review-pr title check is advisory-only — it prints ⚠ title format and the suggested fix but does not return non-zero. After day 7, it promotes to hard-gate (returns non-zero, structural-fail). gyrum-complete-pr follows the same schedule — advisory for 7 days, hard-gate after.

The 7-day soft-warn lets the fleet adapt without spiking merge-pain: on day 1 every in-flight PR is non-conformant, and forcing them to retitle in lockstep with the ADR landing is gratuitous churn. By day 7 every fresh PR has the format, so the hard-gate only rejects regressions.

Forward-only enforcement

No retroactive backfill of historical PR titles. The decision applies to PRs opened after this ADR merges. PRs opened before but merged after are exempt — the ADR's contract is the title-at-creation-time, not the title-at-merge-time. Backfill would churn every closed-PR URL's display title, break every link that quotes the old title, and produce zero downstream benefit (the auto-tally extractor already pulled the ticket from the body at merge time; rewriting the title now changes nothing about the historical record).

Tooling-integrations inventory (lockstep update path)

Every consumer of PR titles inside the fleet that keys on a regex shape is enumerated below, with the lockstep update path. Implementing children (warp#1362, warp#1363) update each in turn.

Consumer File / location What it does Lockstep update
gyrum-start-work (title emission) ~/.gyrum/devtools/start-work.sh:200 Emits draft-PR title as feat: $name Prepend [ID:warp#NNN]: when --ticket is supplied; unchanged on --no-ticket.
gyrum-review-pr (commit-msg warn) ~/.gyrum/devtools/review-pr.sh:699-707 Warns when commit titles miss conventional-commits prefix Add a sibling check on PR title (not commits) using the new regex; soft-warn for 7 days, hard-gate after.
gyrum-complete-pr (final-line gate) ~/.gyrum/devtools/complete-pr.sh Pre-merge checks Add title-format regex check; soft-warn for 7 days, hard-gate after.
auto-tally PR-title extractor ~/.gyrum/devtools/test/auto-tally-pr-title.test.sh (warp#1055 contract) Extracts close-verb-gated Closes warp#NNN from PR titles Unchanged. The new format is [ID:warp#NNN]: ... — no Closes/Fixes/Resolves verb, so the warp#1055 close-verb gate correctly does not extract from the prefix. The body still carries the close-verb. The extractor's verb-gate stays the source of truth for closes; the title prefix is for display.
release-please / semantic-release (downstream, when adopted) not yet used in dark-factory; will be adopted per the per-project changelog initiative (warp#1359) Reads PR title to categorise release notes One-line strip-prefix (re.sub(r"^\[ID:warp#\d+(?:,\s*warp#\d+)*\]:\s+", "", title)) at the point of consumption; tools work unchanged after.
GitHub Actions title-matchers .github/workflows/*.yml keying on github.event.pull_request.title Project-specific gates (e.g. validate-projects.yml) None currently match on the conventional-commits prefix in this repo. Future workflows that want to gate on type adopt the strip-prefix-first pattern.
gh pr list / GitHub web UI external Display Unchanged consumer; this is the primary beneficiary — the ticket is now visible.

Consequences

Easier:

  • An operator scanning gh pr list or the GitHub PR feed sees the ticket without click-through.
  • Cross-surface joins (PR ↔ ticket ↔ changelog entry ↔ velocity event) become title-level, not body-level.
  • The ticket is the primary key in every surface that reads PR titles, including future agents that resume work from gh pr list output.
  • Multi-ticket PRs (atomic ADR-cluster landings, dual-fix PRs) make the multi-ticket linkage visible without parsing the body.

Harder:

  • One-week soft-warn window operationally — the validator is in two states (advise, gate) for a finite period; the rollover requires a calendar-driven change in review-pr.sh and complete-pr.sh. The soft-warn period is itself logged as a Warp item so the rollover is not forgotten.
  • gyrum-start-work is now branchless on the --ticket flag — when neither --ticket nor --no-ticket is passed, the global rule (warp-ticket-first) already requires the ticket; the implementing child clarifies the error message to point at the new title-format requirement.
  • Conventional-commits tooling needs the strip-prefix step. For tools the fleet doesn't yet run (release-please, semantic-release), this is a docs note in the per-project changelog ADR (warp#1359). For tools the fleet does run (none currently key on conventional-commits prefix in PR titles), zero immediate change.

Signed up to operate:

  • The 7-day soft-warn rollover (a one-line change in two scripts at the calendar deadline).
  • Periodic audit that the format is being honoured — gh pr list --search "is:pr created:>YYYY-MM-DD" filtered through the regex; if drift exceeds a threshold, the gate is failing closed somewhere and warrants investigation.
  • Forward-only is irrevocable. If a future operator asks "why does PR #423 have a plain title and PR #621 has the prefix?", the answer is "this ADR; backfill was deemed lower-value than display-stability".

Alternatives considered

  • Body-only ticket reference (status quo). The auto-tally extractor closes the ticket on merge from the body, so tickets do close correctly. But the display surface (PR list, notifications, web UI) is ticket-blind, which is the cost this ADR is paid to remove. Status quo lost because every downstream surface paying its own body-extraction cost is duplicate work the title-prefix collapses.

  • Branch-name encoding (e.g. warp-1361/feat/adr-pr-title-format). Branch names are visible in gh pr list already, so embedding the ticket there would surface it without a title change. Lost on two grounds: (1) branch names are operator-readable, not machine-stable — slashes have side-effects with some Git tooling, and the warp-NNN segment varies in punctuation across implementations; (2) once a PR is merged, the branch is deleted — the ticket evaporates from the merged-PR view, defeating the purpose. The title persists past merge.

  • Bare warp#NNN mention in title (no ID-prefix structure). A title like feat: ADR for PR title prefix (warp#1361) already gets the ticket into the title surface. Lost because it collides with the warp#1055 close-verb gate (auto-tally would either close on bare mentions, regressing the warp#1055 fix, or never close, regressing the ticket-on-merge contract); and it doesn't survive squash-merge title shortening as cleanly as a leading ID prefix does.

  • Hard-gate from day one (no soft-warn period). Simpler — no calendar rollover to track. Lost because every in-flight PR on the day of merge would be non-conformant, and forcing the fleet into a same-day retitle of all open PRs is operator-pain for zero structural benefit. The 7-day window matches the rollover shape used in similar gate-introduction ADRs.

  • Retroactive backfill of historical PR titles. Would let the prefix be a uniform property of every PR in the repo. Lost because the auto-tally extractor already drained the ticket information from historical bodies; rewriting closed-PR titles changes display but not behaviour, breaks every external link that quotes the old title, and burns a non-trivial GitHub API rate budget for cosmetic uniformity.


Supersedes: none Superseded by: (leave blank until a later ADR reverses this one)

Refs:

  • warp#1358 (parent EPIC: per-project changelog + user-visible /changes page)
  • warp#1330 (sister: auto-tally close-verb distinction)
  • warp#1325 (sister: review-pr body splice)
  • warp#1342 (sister: review-pr READY-TO-MERGE directive)
  • warp#1055 (auto-tally PR-title close-verb gate — preserved unchanged by this ADR)
  • ADR-110 (observe-and-file: option for soft-warn period observer enforcement)