[Bugfix #1455] Add a pr-create forge concept so non-GitHub forges can open PRs - #1458
[Bugfix #1455] Add a pr-create forge concept so non-GitHub forges can open PRs#1458pseudoseed wants to merge 16 commits into
Conversation
…rge concept
`pr-create` was the one forge operation with no concept behind it. It was
absent from KNOWN_CONCEPTS, no provider shipped a script for it, and every
protocol prompt wrote `gh pr create` literally — so a project with
`forge.provider: gitea` fully configured still shelled out to `gh` at the
single most important write in the protocol, and only worked if someone kept
a `gh`→forge shim on PATH.
Contract (env in, JSON out — the shape every other concept uses, so it stays
callable from executeForgeCommand):
in: CODEV_PR_TITLE, CODEV_PR_BODY, and optional CODEV_PR_BASE / _HEAD /
_REPO / _DRAFT
out: {"number": <int>, "url": "<web url>"}
- scripts/forge/github/pr-create.sh — `gh pr create` with the flags it already
took, so nothing changes for GitHub users.
- scripts/forge/gitea/pr-create.sh — `tea pulls create --description` (not
`--body`), tea's rendered output pushed to stderr, and the new PR looked up
via `tea pulls list --output json` instead of parsing that rendered view.
- scripts/forge/gitlab/pr-create.sh — `glab mr create`, marked UNVERIFIED
(`glab` is not installed here); without it the gitlab preset falls through
to `gh`, which is this bug.
- porch substitutes `{{pr_create_command}}` into phase prompts, mirroring the
existing `pr-merge` injection, and falls back to "open the PR manually" when
the concept is disabled.
Verified end to end against a live Forgejo with tea 0.14.2: the created PR's
body, read back from the server, is byte-identical to the input (428 bytes,
quotes/backticks/$VAR/backslash/fenced block all intact), and base, head and
title match. On 0.14.2 with a single login and an explicit --head, `tea pulls
create` needs no --repo/--login and does not prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Porch substitutes every occurrence of the token, so the sentence explaining the token rendered as "Porch substitutes /abs/path/pr-create.sh with your forge's pr-create concept command". Found by rendering the real BUGFIX pr prompt through the locally built porch. The prose now refers to "the command above", and the regression test pins exactly one occurrence per prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…QUEST_CHANGES) Three real defects, each with a test that fails without the fix. 1. Inline overrides received empty inputs (codex). The prompts set the inputs as an assignment prefix — `CODEV_PR_TITLE=… <cmd>`. A script reading the environment works, but an inline override, which is the documented form (`"pr-merge": "glab mr merge \"$CODEV_PR_NUMBER\" --yes"`), has its `"$CODEV_PR_TITLE"` argument expanded by the calling shell BEFORE the assignment applies, so it got "". The prompts now export. Pinned by a test that renders the shipped prompt, extracts the bash block and executes it against an inline override. 2. `codev doctor` reported `pr-create … set not found` (claude). extractExecutable returns a script's first substantive command, which is `set -e` here, so doctor looked for `set` on PATH and could no longer tell a Gitea user that `tea` is missing — for the one write that matters. Added a `# forge-executable: <tool>` declaration honoured ahead of the heuristic, and a shell-builtin skip list. Verified against the built dist: github→gh, gitea→tea, gitlab→glab. 3. `gitea/pr-create.sh` looked the new PR up without `--limit`, unlike every sibling gitea script. On a repo with more open PRs than tea's default page, it would create the PR and then exit 1 saying it could not find it. Now `--limit 200`, matching the house convention. Also: the disabled-concept fallback rendered as a `#` comment — valid shell that exits 0 without opening a PR. It now writes to stderr and returns false. gemini APPROVE. Full suite 4888 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing an empty one CMAP round 2 (codex, REQUEST_CHANGES): the scripts validated CODEV_PR_TITLE but not CODEV_PR_BODY. `--body ""` / `--description ""` succeeds everywhere, so a caller who forgot the variable entirely got a bodyless PR at exit 0 — exactly the silent failure cluesmith#1455's testing notes describe. The scripts now distinguish unset from deliberately empty (`${CODEV_PR_BODY+x}`) and fail before reaching the forge CLI, with a test per provider covering both cases. Also documents CODEV_PR_LOGIN (read by the gitea script, previously named nowhere) in the script header, the contract and forge.md — claude's minor note on the same round. gemini APPROVE, claude APPROVE. Full suite 4891 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t concept counts CMAP round 3 non-blocking notes (all three lanes APPROVE): - `gitea/pr-create.sh` read `.head` as a plain string (true on tea 0.14.2) while sibling `pr-exists.sh` reads `.head.ref`. Accept either shape — a lookup miss here reports failure for a PR that was actually created, which invites a duplicate on retry. - `forge.ts` and `arch.md` still said "15 concepts" and omitted `pr-create` (along with `issue-search` and `repo-archive`). Now 18, listed. Re-verified live against Forgejo after the change (PR cluesmith#17: body, base and head correct read back from the server; closed and branch deleted). Full suite 4891. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rop the lookup The gitea `pr-create` ran `tea pulls create` (whose output is a rendered, ANSI-decorated view, not parseable) and then searched for the PR it had just made with `tea pulls list --limit 200`. That search was built on a disproven assumption. cluesmith#1146 established, and this change re-confirmed against live Forgejo 15.0.2, that Gitea caps every list response at the server's `max_response_items` — default 50. `settings/api` reports 50, and a `?limit=200` request returns exactly 50 items where paging at 50 returns 53. So `--limit 200` silently truncates: on a busy repo the just-created PR falls off the first page, and pr-create reported created the PR but could not find an open pull for head '<branch>' and exited 1 for a PR that exists — inviting a duplicate retry at the single most important write in the protocol. Rather than paginate the lookup, remove it. `tea api -X POST repos/{owner}/{repo}/pulls` RETURNS the created PR — `number` and `html_url` — in its response body, so there is nothing to search, nothing to race, and nothing to truncate. It also drops the `<user>:<branch>` head-matching heuristic: the API resolves an owner-qualified head itself. Live verification against tea 0.14.2 + Forgejo 15.0.2 turned up three defects in the obvious version of that change. Each is the same bug class as cluesmith#1455 itself — an operation accepted and then silently not performed — so each is handled in code, not left as a caveat. 1. `tea api` EXITS 0 on HTTP errors, printing the error body. Since the whole change replaces a lookup with a single call, trusting that exit code would reintroduce cluesmith#1455's silent success inside the fix for it: a 404 or 422 would be reported as a created PR. The response is therefore asserted to BE a PR object — an object carrying a numeric `number` AND a non-empty browser URL — and anything else fails loudly with the response body. Pinned by tests that feed an error object, an array, a string-typed `number`, a numberless object, `null` and an empty body, all at exit 0. The one case where `number` is present but the URL is not gets its own message: the PR WAS created, so it names the number and says not to retry. Reading that as "nothing happened" is how duplicates get opened. 2. `base` is REQUIRED by the API — it answers `[Base]: Required` — where `tea pulls create` defaulted it client-side. Silently posting against the wrong base would be worse than erroring, so an unset CODEV_PR_BASE now resolves the repo's default branch explicitly, and fails with a clear message if that cannot be resolved. 3. `draft: true` in the payload is SILENTLY IGNORED (the response comes back `draft: false`), so CODEV_PR_DRAFT=1 would have been an accepted-and-ignored flag. Gitea marks a draft by a `WIP:` title prefix — exactly what `tea pulls create --draft` does — so that is now implemented, and verified server-side to produce `draft: true`. Also verified live: `{owner}`/`{repo}` are substituted by tea from the repo context, with `--repo owner/name` supplying it when the cwd has no Gitea remote (checked with https and scp-style remotes, and from a GitHub-remote cwd); `url` on the create response is the browser page, so `.html_url // .url` lands the right one in the contract; and the body round-trips byte-identically, being built with `jq --arg` and fed on stdin (`-d @-`) rather than surviving an argv round-trip. The unresolvable-repo case used to surface as a bare `404 page not found`; it now names CODEV_PR_REPO as the remedy, matching the fail-fast ergonomics of `_lib.sh#gitea_repo` in cluesmith#1146 without taking a dependency on that PR — this change stands alone and the two can merge in either order. Tests: the gitea half of the concept suite is rewritten against a `tea api` stub. Every new case fails against the previous script and passes against this one, including an explicit assertion that no `pulls`/`list`/`--limit` call is made at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…luesmith#1458 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…luesmith#1458 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reconciled with #1146 — the
|
…luesmith#1458 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…luesmith#1458 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
915fa62 to
35d575c
Compare
Summary
pr-createis now a forge concept, so PR creation routes through the same dispatcher as the other 17 concepts instead of shelling out toghunconditionally.Fixes #1455
Root cause
Four linked gaps, not one:
packages/codev/src/lib/forge.ts—KNOWN_CONCEPTShad nopr-create. Everything derives from that list (getDefaultCommands,buildPresetFromScripts,resolveAllConceptsforcodev doctor,validateForgeConfig), sogetForgeCommand('pr-create', …)returnednullfor every provider and a hand-writtenforge["pr-create"]override was reported by doctor as an unknown concept — and read by nothing.pr-create.shin any provider directory.gh pr createwritten literally into 7 prompt files per tree (14 total).pr-merge(porch/next.ts:227and:768).Reads and
pr-mergeroute correctly, so a Gitea project looks fully configured right up to the one write that matters.The contract
The issue asked for agreement on the signature before anyone wrote it. This PR uses environment variables in, JSON on stdout — the shape every other concept already has:
CODEV_PR_TITLE(required),CODEV_PR_BODY(required — set it to""for an empty body; an absent one is rejected),CODEV_PR_BASE,CODEV_PR_HEAD,CODEV_PR_REPO,CODEV_PR_DRAFT(optional; gitea also readsCODEV_PR_LOGIN){"number": <int>, "url": "<web url>"}— the browser URL, never an API endpointThe alternative — a CLI-compatible wrapper taking
--title/--body/--base/--headon argv — would have preserved the existing prompt text verbatim, butexecuteForgeCommand()passes inputs asCODEV_*env and parses stdout, so an argv contract would makepr-createthe one concept unreachable from TypeScript. Happy to flip it if you'd rather have the passthrough.Deliberately not included: a
CODEV_PR_BODY_FILEinput. The issue's own testing note describes a shim that silently dropped--body-fileand posted empty bodies at exit 0 for months; one way in is one thing to get wrong.What changed
forge.ts—pr-createinKNOWN_CONCEPTS(one line; presets, doctor and config validation follow).forge-contracts.ts—PrCreateResult.scripts/forge/github/pr-create.sh—gh pr createwith the flags it already took. Behaviour for GitHub users is unchanged.scripts/forge/gitea/pr-create.sh—tea pulls create --description(not--body). tea's rendered, line-wrapped, ANSI-decorated output goes to stderr; the new PR is then looked up by head branch throughtea pulls list --fields index,url,head --output json, which is machine-readable, rather than parsed out of that view.scripts/forge/gitlab/pr-create.sh— scope deviation, argued rather than chosen silently. The issue asked for two scripts. Without a gitlab script the preset falls through to the GitHub default and runsgh, which is the bug this closes. It is markedglabis not installed in the authoring environment — same convention as the existinggitea/issue-search.sh. Say the word and I'll drop it.porch/prompts.ts—{{pr_create_command}}template variable, resolved per project config, falling back to "open the PR manually" when the concept is disabled.export, not an assignment prefix — see the CMAP section below. PIR moved off--body-filetoCODEV_PR_BODY="$(cat codev/reviews/….md)".extractExecutablehonours a# forge-executable: <tool>declaration, socodev doctorreports the CLI a script actually needs rather than the first line of a guard clause.codev/resources/commands/forge.mdand the byte-identical.claude/.codexforge SKILL.md pair.bugfix-685-close-keyword.test.ts— its PR-body-template regex now acceptsCODEV_PR_BODY=alongside--body. Guard intent unchanged.Verification
Live Forgejo,
tea0.14.2 — three scratch PRs, all closed and their branches deleted afterwards:{"number":15,"url":"https://…/pulls/15"}and nothing else on stdout. The body was then read back from the server over the REST API: 428 bytes sent, 428 received, byte-identical —"quotes",`backticks`,$VAR,\backslash, a fenced code block, checkboxes and an em dash all intact. Server-sidebase,headandtitlematched the inputs. This is the assertion the issue asked for, not "exit 0".CODEV_PR_HEAD: thegit rev-parse --abbrev-ref HEADdefault and tea's own base default both resolved correctly.--head,tea pulls createneeds no--repo/--loginand does not prompt. Both are still forwarded when set, for multi-login hosts and for the 0.11.x autodetect-prompt failure.This PR was opened with
scripts/forge/github/pr-create.sh—CODEV_PR_REPO=cluesmith/codev,CODEV_PR_HEAD=pseudoseed:builder/bugfix-1455.Regression tests —
bugfix-1455-pr-create-concept.test.ts(dispatcher routing per provider; the scripts' contract exercised against fakegh/teaon PATH, including the multi-line tricky body byte-for-byte, the--description-not---bodymapping, cross-repo<user>:<branch>head matching, and the failure paths) andbugfix-1455-pr-create-prompt.test.ts(porch renders the right command for github/gitea/override/disabled). Removingpr-createfromKNOWN_CONCEPTSturns 7 of them red.Review: two CMAP rounds, four defects fixed
Round 1 — gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES:
CODEV_PR_TITLE=… <cmd>). A script reading the environment works, but an inline override — the documented form, e.g."pr-merge": "glab mr merge \"$CODEV_PR_NUMBER\" --yes"— has its"$CODEV_PR_TITLE"argument expanded by the calling shell before the assignment applies, so it got"". The prompts nowexport. Pinned by a test that renders the shipped prompt, extracts the bash block and executes it against an inline override.codev doctorreportedpr-create … set not found(claude).extractExecutablereturns a script's first substantive command, which isset -ehere — so doctor looked forseton PATH and could no longer tell a Gitea user thatteais missing. Fixed with the# forge-executable:declaration plus a shell-builtin skip list; verified against the builtdist.--limit(claude), unlike every sibling gitea script. On a repo with more open PRs than tea's default page it would create the PR and then exit 1 claiming it couldn't find it. Now--limit 200.Round 2 — gemini APPROVE, claude APPROVE, codex REQUEST_CHANGES:
--body ""succeeds on every forge — so a caller who omitted the variable got a bodyless PR at exit 0, the same silent failure the issue's testing notes describe. The scripts now distinguish unset from deliberately empty and fail before reaching the forge CLI.Also taken: the disabled-concept fallback used to render as a
#comment (valid shell, exit 0, no PR) and now fails loudly;CODEV_PR_LOGINis documented where it's read.Round 3 — gemini APPROVE, codex APPROVE, claude APPROVE, with two cosmetic notes taken:
gitea/pr-create.shnow accepts.headas either a string (tea 0.14.2) or the object-with-.refshape sibling scripts assume, and the stale "15 concepts" counts inforge.tsandarch.mdare corrected to 18. Re-verified live against Forgejo afterwards.Test plan
pnpm buildcleancli-tower-mode.e2e.test.tsexpectshttp://localhost:…and this machine's Tower binds0.0.0.0)Disclosure: one unrelated test is timing-sensitive on my machine
spec-1280-measurement-instrument.test.tsintermittently fails here withTest timed out in 60000ms— usually 1 test, sometimes 2, alwaysPHASE_ITERS is a linear comparison constantand/or the determinism test. Both shell out toscripts/measure-prompt-surface.shtwice.codev/state/bugfix-1323_thread.mdwhen the 60 s budgets were set. Two calls per test lands right on the budget.upstream/main, and both the test file and the script are byte-identical to main — so main fails identically on this machine.vitest runwith output to a file, 4884 passed).I deliberately did not raise those budgets in this PR — that's an unrelated test, and papering over a real performance signal inside a bugfix is the kind of scope creep a reviewer should reject. Worth its own issue if the numbers reproduce on your hardware.
Out of scope, found while testing (no changes made)
Three pre-existing
gitearead-concept bugs, all in #1137/#1146 territory rather than this one:tea pulls view <n> --output jsonignores<n>and returns a list of all pulls, sogitea/pr-view.sh'sjq '.url = (.html_url // .url)'receives an array.tea pulls listrejects--fields description, sogitea/pr-list.sh'sdescription → bodymapping can't populate.tea pulls list --output jsonreturnsheadas a plain branch string, butgitea/pr-exists.shfilters on.head.ref.Also:
gh pr edit --body-fileis still hardcoded in the PIR review prompt.pr-editisn't a concept and adding one is a separate change.🤖 Generated with Claude Code
Update (2026-08-14): gitea
pr-createno longer looks the PR upThe gitea script created the PR with
tea pulls createand then searched for it withtea pulls list --limit 200. That--limit 200was added here as a fix for a review defect, but it rests on an assumption the sibling PR #1146 disproves: Gitea caps every list response atmax_response_items, default 50 —--limit 200does not raise the cap, it silently truncates. On a busy repo the just-created PR falls off the first page and this script exited 1 for a PR that exists, inviting a duplicate retry.Re-confirmed live against Forgejo 15.0.2, not inferred:
settings/apireportsmax_response_items: 50, and a?limit=200request returned exactly 50 items on a list where paging at 50 returned 53.Fix: delete the lookup rather than paginate it.
tea api -X POST repos/{owner}/{repo}/pullsreturns the created PR —numberandhtml_url— in its response body. Nothing to search, nothing to race, nothing to truncate, and the<user>:<branch>head-matching heuristic goes too (the API resolves an owner-qualified head itself).Live testing turned up three defects in the obvious version of that change, each the same bug class as #1455 itself — an operation accepted and then silently not performed — so each is handled in code rather than noted as a caveat:
tea apiexits 0 on HTTP errors. Since this replaces a lookup with a single call, trusting the exit code would reintroduce pr-create is not a forge concept: gh pr create is hardcoded in the skeleton prompts, so non-GitHub forges need a gh shim #1455's silent success inside the fix for it. The response is asserted to be a PR object — an object with a numericnumberand a non-empty browser URL — or it fails loudly with the body. The case wherenumberis present but the URL is not gets its own message naming the number and saying explicitly not to retry: the PR was created, and reading that as "nothing happened" is how duplicates get opened.base([Base]: Required), wheretea pulls createdefaulted it client-side. An unsetCODEV_PR_BASEnow resolves the repo's default branch explicitly.draft: truein the payload is silently ignored (response comes backdraft: false), soCODEV_PR_DRAFT=1would have been an accepted-and-ignored flag. Gitea's draft marker is aWIP:title prefix — whattea pulls create --draftdoes — now implemented and verified server-side.Full detail, including the verified
{owner}/{repo}placeholder behaviour and byte-exact body round-trip, is in the reconcile comment.Testing
bugfix-1455-pr-create-concept.test.tsis rewritten against atea apistub. Every new case was run against the previous script first and fails there, so the pin is real — including an explicit assertion that nopulls/list/--limitcall is made at all.@cluesmith/codevunit suite on this branch: 3218 passed, 126 failed (67 files).upstream/mainin the same worktree: 3176 passed, 126 failed — the same 67 files and same 126 tests. Zero regressions; this branch adds 42 passing tests. The pre-existing failures areagent-farm/terminal/consolidate(shellper sockets, SQLite state), environment-dependent: no builtdist/, live Tower on the same state.pseudoseed/research; every scratch PR closed, every scratch branch deleted. Noteatoken scope widened.Merge order with the sibling PR — verified, not assumed
#1146 and #1458 come from the same fork and both touch
packages/codev/scripts/forge/gitea/, so the ordering question is fair. The answer:Either order is safe. There is no dependency and no conflict.
git merge-treeon the two branch tips merges cleanly. The only file both touch is the builder thread log, which is the identical blob on both branches and auto-merges.pr-create.shdoes notsource _lib.shand does not callgitea_repoortea_api_paged. Nothing in it resolves against #1146._lib.shandpr-view.share byte-identical to #1146's versions andpr-create.shbyte-identical to #1458's — no silent blending. Thebugfix-693invariant (every entry under each provider dir is a*.sh) still holds with_lib.shpresent.Does #1458 duplicate something #1146 makes shared?
The paginator: no, and it shouldn't.
_lib.sh#tea_api_pagedexists to walk a truncating list endpoint.pr-createno longer lists anything — it reads the new PR out of the create response — so there is no pagination for it to share. That is the point of the reconcile rather than an oversight.Repo resolution: yes, there are two paths, and this is worth a follow-up.
. _lib.sh→gitea_repo(), which honoursCODEV_REPO, else derivesowner/repofrom the origin remote, and fails fast namingCODEV_REPOas the remedy.pr-create: tea's own{owner}/{repo}placeholders, withCODEV_PR_REPOforwarded astea --repo.They were kept separate deliberately, for two reasons rather than by omission:
pr-createtakesCODEV_PR_REPO; the read concepts takeCODEV_REPO.gitea_repo()reads the latter and takes no argument, sopr-createcould not call it without changing its signature — which would mean editing a [Bugfix #1137] Fix gitea forge preset against the real tea CLI #1146 file from [Bugfix #1455] Add a pr-create forge concept so non-GitHub forges can open PRs #1458 and creating exactly the merge-order coupling this avoids.--repodoes more than fill a path. It also supplies tea's repo/login context, verified working from a cwd whose remote is not a Gitea host. A path-only helper does not do that.Recommended follow-up (not done here, deliberately): once both PRs have landed, unify the two behind one helper that takes the override variable as a parameter — e.g.
gitea_repo "$CODEV_PR_REPO"— so there is one repo-resolution path with one error message. Doing it now would couple two independent PRs; doing it never leaves two paths that will drift. It is a small, mechanical change against a tree where both are already present.The one ergonomic gap that split created has been closed in the meantime: an unresolvable repo used to surface from
pr-createas a bare404 page not found, and now namesCODEV_PR_REPOas the remedy, matchinggitea_repo()'s fail-fast message.