forbid conventional-commit type prefixes in PR titles #25
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
public/chiche-plugins!25
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/enforce-pr-title-no-type-prefix"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal: a PR title can never carry a conventional-commit type prefix --
feat:,fix:, or the scopedfix(module):-- because a deterministic gate strips it at write time, not merely because prose discourages it.Value: med
Effort: 2h
Why
The rule already exists:
task-newsays the title carries no conventional-committype prefix and
task-implement/worktree-and-branches.mdhas a## Title stylesection saying the same. The type belongs in a label, which
task-newStep 9 callsmandatory precisely because the title dropped it.
It is still being ignored. PR #24 on this repo shipped as
feat: add prioritize-next-features plugin, andfj_write.py's own usage examplesdemonstrated the anti-pattern (
pr-title 9 "feat: ship it",pr-create ... --title "feat: x") -- so the one artifact a model reads right beforewriting a title taught it the wrong shape.
Three gaps explain the drift:
where one is written to the forge. Nothing catches a bad title after the fact.
feat(scope):/fix(module):-- the worstvariant -- appears nowhere in the prose, so it does not pattern-match as forbidden.
task-orchestrateandtask-reviewhandle thetitle (review rewrites it at mark-ready) but never check its shape, so a bad title
survives all the way to merge.
Prose alone has already failed once. The fix layers a deterministic gate under it:
fj_write.pyis the single chokepoint every PR title passes through, so normalisingthere makes the invariant hold regardless of what the model typed.
Preserve the distinction: commit subjects stay conventional-commit style
(
feat: add OAuth login) -- they are git history, not the forge-rendered title. OnlyPR titles are affected. Issue titles are out of scope.
Steps
strip_type_prefix(title)helper toplugins/forgejo-cli/skills/forgejo-cli/scripts/fj_write.py. It removes aleading conventional-commit type token --
feat|fix|docs|chore|refactor|test|perf|build|ci|style|revert,with an optional
(scope), an optional!, then:and following space --matched case-insensitively and applied after any
WIP:draft prefix soWIP: feat: xnormalises toWIP: x. Strip repeatedly (a title is notallowed to smuggle
feat: fix: xthrough). Return the cleaned titleunchanged when no prefix is present.
cmd_title(PR path only) and incmd_create's PRbranch, before the title reaches the
fjargv. When it changes the title,print a one-line notice naming the stripped prefix and the resulting title,
so the caller sees the normalisation rather than a silent rewrite. Leave
issue-titleandissue-createuntouched.fj_write.py's own usage examples in the module docstring -- replacefeat: ship itandfeat: xwith prefix-free titles so the reference stopsteaching the anti-pattern.
source file at
<path>/<name>.pyis tested bytests/<path>/test_<name>.py,so the directory structure under
tests/is a byte-for-byte mirror of thesource layout and each test file's name is derived from its source file's.
Cover
strip_type_prefix()attests/plugins/forgejo-cli/skills/forgejo-cli/scripts/test_fj_write.py(plain form, scoped form,
!marker, case-insensitivity,WIP:preservation, doubled prefixes, idempotence, and the near-misses that must
NOT be stripped --
fixture loader,feature flags:). Stdlibunittest,matching the dependency-free scripts under test. Document the mirroring rule
in the test module's docstring.
stated:
plugins/tasks/skills/task-new/SKILL.md(the Title: paragraph in"PR Body Shape" and the common-mistakes row) and
plugins/tasks/skills/task-implement/worktree-and-branches.md(
## Title style). Both must forbidfeat:/fix:andfeat(scope):/fix(module):, and both must keep the existing note that commit subjectsstay conventional-commit style.
plugins/forgejo-cli/skills/forgejo-cli/SKILL.mdunder the write section:one line noting
pr-title/pr-createstrip a conventional-commit typeprefix, and that the type belongs on a label.
plugins/tasks/skills/task-review/SKILL.md:Step 12 already rewrites the title at mark-ready, so have it strip a
conventional-commit type prefix alongside
WIP:rather than onlyWIP:.Add a matching common-mistakes row.
plugins/tasks/skills/task-orchestrate/SKILL.mdcovering the same rule, sothe one skill that drives a PR to merge without going through
task-newalso states it.
feat: add prioritize-next-features plugintoadd prioritize-next-features pluginand confirm it carries theenhancementlabel (the type signal the title gives up).## Toolchainblock to a repo-rootCLAUDE.mddeclaring atest:command, so the new test tree is actually executed by
/task-orchestrateand
/task-reviewinstead of sitting inert. Landed astest: uv run --with pytest pytest tests -q-- pytest, notunittest discover: plugin directories contain hyphens(
forgejo-cli), so they are not importable packages and discovery cannotwalk the src-mirroring layout. The same
CLAUDE.mddocuments themirroring convention and the PR-title rule.
Notes
Why the script and not a git hook. A hook fires on commits, not on forge API
calls, and PR titles are set through
fj.fj_write.pyis the one path every PRtitle in this ecosystem passes through --
task-newStep 8,task-implement'sone-shot PR-create, and
task-reviewStep 12 all route through it via theforgejo-cliskill. Normalising there covers every caller with one change.Why strip rather than reject. A non-zero exit mid-pass would strand the
resolver with a pushed branch and no PR, and the correct title is mechanically
derivable from the bad one -- there is nothing to ask the user. Stripping is
idempotent and always leaves the title in the legal state. The printed notice keeps
it visible instead of silent.
Why
WIP:is handled first.WIP:is Forgejo's draft signal and must survive;only the type token after it is stripped.
fj's title edit re-derives the draftflag from the title, so the order matters -- getting it wrong would undraft the PR.
Test-tree mirroring: scope of what was possible here. The requirement is that
the test tree mirror the source tree exactly. This repo had no test tree and no
source tree to mirror when the work started: no
src/, notests/, nopyproject.toml, no test runner, and a CI workflow that only runs gitleaks. Itsfour Python files are standalone stdlib scripts scattered under
plugins/*/skills/*/scripts/. So the mirroring convention is established here andapplied to the one unit this PR adds, rather than retrofitted across the repo --
building out a repo-wide test tree for three pre-existing untested scripts is a
separate piece of work with its own verification, and folding it in would have made
this PR about test infrastructure rather than title hygiene. The convention and its
root (
tests/) are now fixed, so later work extends the mirror by adding files, notby redesigning the layout.
Toolchain: pytest, decided by the user. The repo had no
CLAUDE.mdat all, sonothing in the task suite knew how to run the new tests --
/task-reviewStep 8reads its commands from a
## Toolchainblock and skips what it does not find. Theresolver left it unticked rather than invent one; the user chose pytest during the
orchestrate pass. The originally-suggested
unittest discover -s tests -t .turned out to be unworkable: discovery importseach directory as a package, and
forgejo-cli/herdr-clicontain hyphens, so itfails with
Start directory is not importable. Making it work would have meantrenaming test directories away from their source names -- destroying the exact
mirror this PR establishes. pytest collects by path, needs no
__init__.py, andkeeps the mirror intact.
Rejected: a
--allow-type-prefixescape hatch.forgejo-cliis a genericForgejo wrapper, so an opt-out is defensible in principle, but no caller in this
repo wants one and the goal is that a bad title is unrepresentable. YAGNI -- add it
if a real consumer ever needs it.
Rejected: only fixing the prose. That is the status quo. The rule has been
written in two places since the skills were authored and PR #24 still shipped with
feat:.Scope boundary. Commit subjects keep conventional-commit prefixes
(
task-implementStep 5,worktree-and-branches.md) and branch names keep theirfeat//fix/prefixes. Neither is touched. Issue titles are unaffected.Resolver started. Worktree /Users/jerome/code/skills/.worktrees/enforce-pr-title-no-type-prefix. (Repo has no task:active label -- skipping the label, one-time UI setup.)
2026-07-19 -- resolver pass on
fix/enforce-pr-title-no-type-prefixat3fba213.Steps done: 9 of 10.
strip_type_prefix()+clean_pr_title()infj_write.py,wired into
cmd_title(PR path only) andcmd_create's PR branch; docstringexamples de-anti-patterned; mirrored test tree established with coverage for the
new helper; scoped-form prohibition named in
task-newandworktree-and-branches.md;task-reviewStep 12 now strips a type prefixalongside
WIP:;task-orchestratecommon-mistakes row added;forgejo-cliSKILL.md documents the normalisation. PR #24 retitled to
add prioritize-next-features plugin(it had already merged, so the retitle iscosmetic there, but it exercised the rule).
Spec changed mid-pass: the test-tree-mirrors-source-tree requirement arrived
after the body was written and is now Step 4 in the body. Worth flagging what it
could and could not mean here -- the repo had no
tests/and nosrc/to mirror(no test runner, no manifest, gitleaks-only CI, four standalone scripts under
plugins/*/skills/*/scripts/). So the convention is established and applied tothe one unit this PR adds (
tests/<source path>/test_<name>.py) rather thanretrofitted over three pre-existing untested scripts, which is separate work.
Rationale is in the body's Notes.
Deferred to /task-orchestrate: nothing runnable was executed this pass (no
install, lint, typecheck, or test run -- resolver is read-only). The new test
file has not been run.
Open loose end, needs a human call: this repo has no
## Toolchainblock inany CLAUDE.md/AGENTS.md, and no CLAUDE.md at all. Until one declares a
test:command,
/task-reviewStep 8 has nothing to run and the new test file is inert.Left as the one unticked Step rather than scaffolded silently, since writing a
Toolchain block needs the user's confirmation.
Orchestrate pass -- test loop green, last Step closed.
CLAUDE.mdwith a## Toolchainblock:test: uv run --with pytest pytest tests -q. The Step'soriginally-suggested
unittest discover -s tests -t .does not work here --discovery imports each directory as a package and
forgejo-clicontains ahyphen, so it dies with
Start directory is not importable. Fixing that wouldhave meant renaming test dirs away from their source names, destroying the exact
mirror. pytest collects by path and keeps the mirror intact. Rationale folded into
the body's Notes; Step 10 now ticked.
uv run --with pytest pytest tests -q).pr-title 99 "feat(module): ship it"->ship it;pr-title 99 "WIP: fix: broken thing"->WIP: broken thing(draft signalsurvives);
issue-title 99 "feat: keep this"-> unchanged, as specified.Ready for review.
/task-review verdict:
concernsLOC: +213/-12
Findings
mediummismatch --tests/plugins/forgejo-cli/skills/forgejo-cli/scripts/test_fj_write.py:10The module docstring ends with
Run: `uv run --no-project -m unittest discover -s tests -t .` from the repo root.That command is the one the PR body's Notes andCLAUDE.mdboth single out as impossible here -- running it fails withImportError: Start directory is not importable: '.../tests', exactly as the Notes predict. Step 4 asked this docstring to document the mirroring rule, and it does; but it also ships a run instruction that contradicts the## Toolchainblock'suv run --with pytest pytest tests -qand does not work. Replace it with the pytest invocation.mediumstale --branchfix/enforce-pr-title-no-type-prefixis behindorigin/mainby one commit (8cd81a7 feat: add prioritize-next-features plugin (#24)). It merges cleanly (git merge-treereturns a tree with no conflicts), so the diff and the green test/smoke evidence above still reflect what will land -- but catch the branch up before merge so the gates run against the real post-merge state./task-review verdict:
okLOC: +218/-12
Findings
lowbloat --plugins/forgejo-cli/skills/forgejo-cli/scripts/fj_write.py:28The new "PR titles carry no conventional-commit type prefix" paragraph was inserted inside the
Usage:example block rather than after it, orphaning the final example (fj_write.py --dry-run pr-merge 9) below the prose so it reads as a stray line detached from the list it belongs to. The content is correct and well-placed topically -- this is purely where the paragraph sits. Moving it below the last example line restores the block. Cosmetic; safe to fix on a later pass.WIP: forbid conventional-commit type prefixes in PR titlesto forbid conventional-commit type prefixes in PR titles