Skip to content

fix: repair a malformed title prefix instead of doubling it - #3580

Merged
eleshar merged 2 commits into
developfrom
fix/repair-malformed-title-prefix
Sep 26, 2026
Merged

eleshar merged 2 commits into
developfrom
fix/repair-malformed-title-prefix

Conversation

@eleshar

@eleshar eleshar commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Linked issues

Fixes #3578

Found by Qodo review of #3573.

Context

  • Severity/Impact: Low-Medium. A bulk title-normalisation run rewrites issue and PR titles, so every malformed title in a scanned set is mutated into a worse one.
  • Affected versions/environments: scripts/automation/normalize-issue-pr-titles.cjs and .js. The doubling behaviour predates fix: correct stale issue-type inference and CommonJS title normalisation #3568 and was asserted by an existing test.

Reproduction

  • Steps: run normalizeTitle("decision:Adopt GraphQL", "decision").
  • Expected vs Actual: expected decision: Adopt GraphQL. Actual decision: decision:Adopt GraphQL.

Root Cause

isAlreadyPrefixed() correctly requires whitespace after the colon — the Conventional Commits spec makes the terminal colon-and-space required, and both implementations enforce it. But normalizeTitle() treated that rejection as "no prefix present" and prepended one, doubling it. The ecosystem norm is to detect a malformed header rather than silently rewrite it (commitlint fails with type-enum instead of editing), and docs/ISSUE_PR_TITLE_GOVERNANCE.md §7 establishes enforcement by validator as this repo's pattern.

Fix Summary

  • Recognise a leading prefix independently of whether its spacing is canonical:
    • recognised prefix, canonical spacing → title left alone (unchanged)
    • recognised prefix, missing space → insert the single space, keeping one prefix
    • no recognised prefix → prepend the inferred prefix (unchanged)
  • Preserve the author's capitalisation: DECISION:Adopt GraphQL → DECISION: Adopt GraphQL.
  • A repaired title is stable, so a second pass is a no-op.
  • Move the recognised prefix list into a single constant that both regexes are built from, so the spacing check and the leading-prefix check cannot drift apart again — the same failure class that fix: correct stale issue-type inference and CommonJS title normalisation #3568 fixed between the two shipped files.
  • Applied identically to the .js twin; the two files remain byte-identical.

Verification

  • Tests added/updated to cover the bug

  • Manual verification steps — N/A, no UI; verified by executing both implementations

  • Negative/edge cases checked

  • Updated the two assertions that encoded the doubling (fix:Add something, bare feat:/fix:), and added three parity cases: repair in both implementations, never a doubled prefix across five prefixes with a second-pass no-op check, and capitalisation preservation.

  • Suite grew 96 → 98 tests, all passing. Full suite 282 suites / 5638 tests passed.

  • diff confirms the .cjs and .js files are byte-identical. semgrep 0 findings, markdownlint 0 issues, eslint 0 errors.

  • Changelog gate: 10 failing entries with and without the new entry — 0 new failures.

Risk & Rollback

  • Risk level: Low. Only title strings change; no configuration, field, label or API surface is involved.
  • The two updated assertions encoded the previous doubling. That was a deliberate, tested behaviour, so it is called out here rather than changed silently.
  • Edge cases checked: bare feat: → feat: (one prefix, now stable); feature: Some title still gains a prefix, because feature is not a recognised prefix; HTTP: protocol still gains one; a colon mid-title is untouched.
  • Rollback plan: revert the single commit.

Changelog

Malformed Title Prefixes Repaired — A title that already carries a recognised prefix but no space after the colon now has the space added, instead of gaining a second prefix. (#3578)

isAlreadyPrefixed() correctly requires whitespace after the colon, but
normalizeTitle() handled that rejection by prepending the prefix again, so a
malformed title was mutated into a worse one:

  decision:Adopt GraphQL  ->  decision: decision:Adopt GraphQL

Recognise a leading prefix independently of its spacing, insert the missing
space, and keep prepending only when the title carries no recognised prefix.
The author's capitalisation is preserved and a repaired title is stable, so a
second pass is a no-op.

The recognised prefix list moves into one constant that both regexes are built
from, so the spacing check and the leading-prefix check cannot drift apart
again. Applied to the .js twin as well; the two files stay byte-identical.
@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: lightspeedwp/.github/.coderabbit.yml

Review profile: CHILL

Plan: Advanced

Run ID: ce8ea039-eab3-40eb-ba62-5c8a25a42f02

📥 Commits

Reviewing files that changed from the base of the PR and between 063e839 and f9720b0.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • scripts/automation/__tests__/normalize-titles.test.js
  • scripts/automation/normalize-issue-pr-titles.cjs
  • scripts/automation/normalize-issue-pr-titles.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Repair malformed title prefixes without duplication

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 10-20 Minutes

Grey Divider

AI Description

• Repairs recognized prefixes missing post-colon whitespace instead of duplicating them.
• Preserves original prefix capitalization and makes normalization idempotent.
• Unifies prefix checks and adds parity regression coverage across both implementations.
Diagram

graph TD
  A["Input title"] --> B{"Canonical prefix?"} -- No --> D{"Known prefix?"} -- Yes --> E["Insert space"]
  B -- Yes --> C["No change"]
  D -- No --> F["Prepend inferred prefix"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Extract a shared normalizer module
  • ➕ Eliminates duplicated implementation across the JavaScript and CommonJS entry points.
  • ➕ Prevents future behavioral drift without relying on byte-parity checks.
  • ➖ Introduces module-loading and packaging changes beyond this targeted bug fix.
  • ➖ Could disrupt consumers that execute either script directly.

Recommendation: Keep the PR’s focused mirrored fix: deriving both regexes from one prefix constant addresses the immediate drift risk while preserving current script compatibility. A shared module could further reduce duplication, but should be evaluated separately with direct-execution and module-format coverage.

Files changed (4) +82 / -14

Bug fix (2) +52 / -6
normalize-issue-pr-titles.cjsRepair malformed prefixes in CommonJS normalizer +26/-3

Repair malformed prefixes in CommonJS normalizer

• Defines a single recognized-prefix list for canonical and leading-prefix checks. Repairs missing whitespace in place while preserving capitalization, and only prepends when no recognized prefix exists.

scripts/automation/normalize-issue-pr-titles.cjs

normalize-issue-pr-titles.jsRepair malformed prefixes in JavaScript normalizer +26/-3

Repair malformed prefixes in JavaScript normalizer

• Applies the same shared-prefix detection and in-place spacing repair as the CommonJS implementation, maintaining behavioral and byte parity.

scripts/automation/normalize-issue-pr-titles.js

Tests (1) +29 / -8
normalize-titles.test.jsCover malformed-prefix repair and idempotency +29/-8

Cover malformed-prefix repair and idempotency

• Updates prior doubling expectations and verifies repair behavior across both normalizers. Adds coverage for multiple prefixes, second-pass stability, and capitalization preservation.

scripts/automation/tests/normalize-titles.test.js

Documentation (1) +1 / -0
CHANGELOG.mdDocument malformed prefix repair +1/-0

Document malformed prefix repair

• Adds a Fixed entry explaining that recognized prefixes missing post-colon whitespace are repaired instead of duplicated.

CHANGELOG.md

@github-actions

Copy link
Copy Markdown
Contributor

PR Template Routing

Branch Type: fix
Scope: repair-malformed-title-prefix
Template: pr_bug.md
Labels Applied: type:bug

This PR was automatically routed based on the branch naming strategy.

@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

📋 Changelog Quality Validation

Metric Count
✅ Passing 114
❌ Failing 10
🆕 New failures in this PR 0
📦 Pre-existing failures 10

Status

✅ Validation PASSED - No new failures introduced by this PR.
Note: 10 pre-existing failure(s) remain in the Unreleased section.

No action required.

Resolves the CHANGELOG.md conflict in Unreleased > Fixed: both entries kept,
newest first.
@eleshar
eleshar merged commit 2552384 into develop Sep 26, 2026
25 checks passed
@eleshar
eleshar deleted the fix/repair-malformed-title-prefix branch September 26, 2026 05:00
@linear-code

linear-code Bot commented Sep 26, 2026

Copy link
Copy Markdown

GIT-2364

eleshar pushed a commit that referenced this pull request Sep 30, 2026
…essage

Three defects in the keep-current path, all of the same shape: state or a
guess that does not belong where it was.

The log array was declared outside the loop over open pull requests, so each
conflict comment carried every earlier pull request's API response as well —
publishing one pull request's status on another. The per-pull-request work
now lives in processPullRequest behind an injected client, which owns its own
log lines, and the workflow is a thin loop. Reintroducing a shared array
makes two tests fail, so the defect is covered rather than removed.

update-branch answers 422 for three unrelated situations. All were observed
here against a deliberately wrong expected_head_sha:

  202  Updating pull request branch.
  422  There are no new commits on the base branch.
  422  merge conflict between base and head
  422  head ref does not exist
  404  Not Found

The third is the text Mergify reported on #3580 and #3662 seconds after each
merged; read as a conflict it would have commented on a merged pull request
telling a human to resolve a conflict that does not exist. Unknown messages
now classify as an error rather than as a conflict, and a throttled call is
retryable, because GitHub documents 422 as "Validation failed, or the endpoint
has been spammed". Anything that is not a clean update, a no-op or a conflict
is raised as a warning and listed in the job summary rather than logged
quietly.

expected_head_sha is documented as the guard against a head that moved, but it
was not enforced when probed: a deliberately wrong value still returned 202
and updated the branch. The comment claimed otherwise and now says what was
actually observed, so it reads as defence in depth.

Comment lookup is paginated, so a pull request with more than a page of
earlier comments still finds its own and updates it instead of duplicating.
eleshar added a commit that referenced this pull request Sep 30, 2026
* ci(mergify): report update conflicts without a failing check

The Mergify `update` rule reported conclusion `failure` whenever it could
not merge develop into a pull request branch, and the action's schema
accepts only `bot_account`, so nothing in the rule could change that.
Observed across the 394 pull requests opened between 2026-09-03 and
2026-09-29: four failures, split between a genuine conflict (#3524,
#3532) and an update attempted on a merged pull request whose branch had
already been deleted (#3580, #3662).

Merging develop into open branches is now done by a workflow using
GitHub's update-branch API. A conflict is written as a comment naming
mergeable_state instead of being reported as a red check, and the job
reports success on every outcome. Same exclusions as the rule it
replaces: non-draft, same-repository, non-archived, base develop, any
staleness.

The up-to-date requirement is unchanged: develop-branch-ruleset still
sets strict_required_status_checks_policy, so a branch behind develop
that cannot be updated is still blocked from merging. The workflow is
not a required check and no ruleset setting was changed.

Closes #3574

* ci(mergify): validate the config against Mergify's published schema

`npm run validate:mergify` checks .github/mergify.yml against
https://docs.mergify.com/mergify-configuration-schema.json and names the
offending path on failure. The schema is a 176 kB download, so it is
fetched rather than vendored; the test asserts the same thing offline
only when a fixture is present.

The suite also probes the schema for any option that would change how a
failed update is reported. Today `actions.update` rejects every property
but `bot_account`, which is the evidence for removing the rule rather
than reconfiguring it, and the test to revisit if that ever changes.

* ci(mergify): note why the relative require path is correct

actions/github-script runs the script with the workspace as its working
directory, which is why the relative specifier resolves. Recorded so the
next reader does not 'fix' it to an absolute path and break it.

* fix(mergify): separate a real conflict from an already-current pull request

GitHub answers 422 for both, so status alone cannot classify the result.
Observed against this repository on 2026-09-30:

  {"message": "merge conflict between base and head", "status": "422"}
  {"message": "There are no new commits on the base branch.", "status": "422"}

The second is what most open pull requests return on a push to develop,
because most of them are not behind. Reading every 422 as a conflict would
post a false conflict comment on every open pull request each time
develop is pushed to, so the message is now part of the classification
and only a genuine conflict produces a comment.

Also captures the API's own message rather than Octokit's wrapper text,
since that message is what the classification depends on.

* docs(changelog): keep the new entry within the 250 character limit

The pr_submission validator reports CHK_MAX_LENGTH as a critical rule, so
the entry was adding one new failure against the base branch. Now 10
failures on both base and this branch.

* fix(mergify): scope conflict state per pull request and classify by message

Three defects in the keep-current path, all of the same shape: state or a
guess that does not belong where it was.

The log array was declared outside the loop over open pull requests, so each
conflict comment carried every earlier pull request's API response as well —
publishing one pull request's status on another. The per-pull-request work
now lives in processPullRequest behind an injected client, which owns its own
log lines, and the workflow is a thin loop. Reintroducing a shared array
makes two tests fail, so the defect is covered rather than removed.

update-branch answers 422 for three unrelated situations. All were observed
here against a deliberately wrong expected_head_sha:

  202  Updating pull request branch.
  422  There are no new commits on the base branch.
  422  merge conflict between base and head
  422  head ref does not exist
  404  Not Found

The third is the text Mergify reported on #3580 and #3662 seconds after each
merged; read as a conflict it would have commented on a merged pull request
telling a human to resolve a conflict that does not exist. Unknown messages
now classify as an error rather than as a conflict, and a throttled call is
retryable, because GitHub documents 422 as "Validation failed, or the endpoint
has been spammed". Anything that is not a clean update, a no-op or a conflict
is raised as a warning and listed in the job summary rather than logged
quietly.

expected_head_sha is documented as the guard against a head that moved, but it
was not enforced when probed: a deliberately wrong value still returned 202
and updated the branch. The comment claimed otherwise and now says what was
actually observed, so it reads as defence in depth.

Comment lookup is paginated, so a pull request with more than a page of
earlier comments still finds its own and updates it instead of duplicating.

* docs(validation): correct the stale validate:mergify header

The header claimed the script was "not wired into npm run validate:*", which
stopped being true when package.json gained the validate:mergify script in
this series. It now states what is actually true: it is wired as
npm run validate:mergify, it is deliberately not one of the ten steps in
validate:all, and no workflow runs it, because it downloads the schema and
every validate:all step works offline.

* test(mergify): cover the update-branch responses actually observed

Replaces the two classifier assertions that rested on unverified messages:

- "head branch was modified" for a stale expected_head_sha was never observed
  on this repository; a deliberately wrong value returned 202 and updated the
  branch. The test now pins the three 422 texts that were observed, so an
  unverified guess cannot drift back in.
- 403 asserted "gone", which merges two different problems: a token that cannot
  write the branch, and a throttled call. It is now "denied", with rate-limit
  text and 429 classified as retryable first.

A 422 whose message is none of the three is an error, not a conflict, so an
unfamiliar rejection is surfaced rather than posted on a pull request that may
have nothing wrong with it.

Also asserts the workflow keeps no state outside the per-pull-request loop and
delegates the API call to the module instead of duplicating it.

* fix(mergify): update branches with the bot App token, not GITHUB_TOKEN

A branch update is a push. A push made with the workflow's own GITHUB_TOKEN
leaves the new head's pull_request runs unusable, so a pull request this
workflow updates would sit BLOCKED with its required checks missing.

Measured on a scratch same-repository draft pull request on 2026-09-30, over
two separate updates. Before 039ac1b -> 1489e93 and 1489e93 -> 30105ad,
each new head had ten pull_request workflow runs, every one of them
action_required, and therefore no check runs at all: no "Route PR template and
apply labels", no "Validate changelog on PR", no actionlint.

That is documented behaviour, not a fault. From
https://docs.github.com/en/actions/concepts/security/github_token :
"pull_request events with the opened, synchronize, or reopened activity types:
when a workflow using GITHUB_TOKEN creates or updates a pull request, the
resulting pull_request event creates workflow runs in an approval-required
state. The pull request displays a banner in the merge box, and a user with
write access to the repository can start the runs by selecting Approve
workflows to run."

The merge commit is attributed to github-actions[bot], which is what makes the
push a GITHUB_TOKEN push.

Fixed with the same GitHub App documentation.yml already uses for exactly this
reason -- its comment reads "App token (not GITHUB_TOKEN) so the regeneration PR
triggers CI and can satisfy develop's required checks" -- with the same two
existing repository secrets, BOT_PR_APP_CLIENT_ID and BOT_PR_APP_PRIVATE_KEY. No
new secret and no new App permission is required: updateBranch needs
contents:write, and upserting a comment needs "Pull requests: write" or
"Issues: write" (https://docs.github.com/en/rest/issues/comments), both of which
the App already holds. permission-workflows stays absent, as in documentation.yml.

The job's own token drops to contents:read so a checkout cannot reach a
write-scoped token. The token step is skipped for forks, where repository
secrets are not exposed; those pull requests are skipped before any write.

* test(mergify): make the update and token assertions mutation-sensitive

Three assertions in these files passed on a broken implementation.

The upsert test asserted that comment_id was undefined, which is exactly what a
call that dropped it would produce, and its fixture comment had no id at all so
the two were indistinguishable. Dropping comment_id from the updateComment call
left 11 of 11 tests passing. The fixture now carries a real id and the assertion
checks for it: the same mutation now fails 1 of 11.

The token assertions matched the workflow as text, so the header comment -- which
mentions updateBranch, permission-workflows and contents: write while explaining
why they are absent -- could satisfy or break them. They now parse the workflow
and assert on the parsed step: the token inputs, the permissions granted and not
granted, the github-token expression, the fork guard, and that the script body
does not call the API itself.

Verified by mutation, each reverting one of the three changes above:
  no app-token step            -> 3 failed
  permission-workflows granted -> 1 failed
  job token contents: write    -> 1 failed

* fix(mergify): isolate a comment-write failure to its own pull request

paginate, createComment and updateComment were unguarded. The caller loops
over every open pull request in one run, so a rejection on the comment path --
a transient 5xx, or a secondary rate limit on the shared token -- escaped
processPullRequest, stopped the remaining pull requests, and failed the job,
which contradicts the never-red contract this workflow is built on.

Reproduced first: with the comment API throwing for pull request 111 while
112 was still to be processed, five tests failed, including the one asserting
that the second pull request is still reached. Now 16/16 pass.

The conflict itself is still reported. Only the comment is missed, and the
miss is visible three ways: a warning annotation, an outcome label in the job
summary that reads "conflict (comment not written, HTTP 503)", and
commentFailed/commentStatus on the returned result.

pulls.get is now split by status. A 404 means the pull request was merged or
deleted and is logged as gone; anything else is transient, so it warns and
returns retry instead of the previous "unreadable" that logged quietly through
info and looked like a clean pass.

The workflow's own loop now catches anything that still throws, so a single
pull request cannot stop the others even if a future edit leaves a path
unguarded. The pulls.list enumeration is guarded for the same reason: it is
the one call that decides how many pull requests there are.

Also tightens the existing-comment lookup, which matched on
body.includes(marker). Other bot comments here are type: Bot and mention this
workflow by name -- the Linear review comment carries the branch and workflow
names -- so a substring match could adopt and rewrite one of them. Now anchored
to the start of the body. Two reviewers have raised this across rounds; the
earlier deferral is now closed.

* fix(mergify): keep a missing App secret or a failed listing off the red path

Four findings from `coderabbit review --agent` on the pending diff, all valid.

The App token step gains continue-on-error. Without it, absent or invalid
BOT_PR_APP_* secrets fail the job on every run; with it the token is empty,
github-token falls back to the read-only GITHUB_TOKEN, the run updates nothing
and says so.

The pulls.list enumeration is wrapped. It is the one call that decides how many
pull requests there are, so leaving it to throw failed the whole job; it now
warns and proceeds with an empty list.

fetchSchema is bounded. https.get sets no timeout of its own, so a stalled
connection left the promise pending forever and the validator hung instead of
reporting a failure. The request is destroyed at 30s, which rejects the promise.

A periodic `schedule` was considered as a backstop for the gap a `synchronize`
push leaves -- a pull request can sit behind develop after its author resolves a
conflict -- and rejected on evidence, recorded in the workflow header. With 15
open pull requests, */15 is 96 runs a day and ~1,440 update-branch calls, almost
all wasted on the "no new commits" 422. The cost that matters is that
develop-branch-ruleset sets dismiss_stale_reviews_on_push: every update moves the
head and dismisses the approval, so a fixed cadence would repeatedly void
approvals given against an exact head. Scheduled workflows also run only from the
default branch and are disabled after 60 days of inactivity, making them the
least reliable trigger available. workflow_dispatch is the backstop instead.

* docs(mergify): correct the synchronize loop claim

An already-current result makes no further push, so a synchronize-triggered run
would terminate rather than loop. The reason for excluding it stands -- each
update is itself a push and would double the runs for no gain -- but the loop
claim was wrong and is corrected, with the de-confliction gap named instead.

Raised in review.

---------

Co-authored-by: Chris <support@lightspeedwp.agency>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: repair a malformed title prefix instead of prepending a second one

1 participant