Skip to content

[Bugfix #1455] Add a pr-create forge concept so non-GitHub forges can open PRs - #1458

Open
pseudoseed wants to merge 16 commits into
cluesmith:mainfrom
pseudoseed:builder/bugfix-1455
Open

[Bugfix #1455] Add a pr-create forge concept so non-GitHub forges can open PRs#1458
pseudoseed wants to merge 16 commits into
cluesmith:mainfrom
pseudoseed:builder/bugfix-1455

Conversation

@pseudoseed

@pseudoseed pseudoseed commented Aug 14, 2026

Copy link
Copy Markdown

Summary

pr-create is now a forge concept, so PR creation routes through the same dispatcher as the other 17 concepts instead of shelling out to gh unconditionally.

Fixes #1455

Root cause

Four linked gaps, not one:

  1. packages/codev/src/lib/forge.tsKNOWN_CONCEPTS had no pr-create. Everything derives from that list (getDefaultCommands, buildPresetFromScripts, resolveAllConcepts for codev doctor, validateForgeConfig), so getForgeCommand('pr-create', …) returned null for every provider and a hand-written forge["pr-create"] override was reported by doctor as an unknown concept — and read by nothing.
  2. No pr-create.sh in any provider directory.
  3. gh pr create written literally into 7 prompt files per tree (14 total).
  4. Nothing injected a resolved command into the PR-opening prompts, although the precedent existed for pr-merge (porch/next.ts:227 and :768).

Reads and pr-merge route 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:

Inputs 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 reads CODEV_PR_LOGIN)
Output {"number": <int>, "url": "<web url>"} — the browser URL, never an API endpoint
Failure non-zero exit, diagnostics on stderr, no JSON

The alternative — a CLI-compatible wrapper taking --title/--body/--base/--head on argv — would have preserved the existing prompt text verbatim, but executeForgeCommand() passes inputs as CODEV_* env and parses stdout, so an argv contract would make pr-create the one concept unreachable from TypeScript. Happy to flip it if you'd rather have the passthrough.

Deliberately not included: a CODEV_PR_BODY_FILE input. The issue's own testing note describes a shim that silently dropped --body-file and posted empty bodies at exit 0 for months; one way in is one thing to get wrong.

What changed

  • forge.tspr-create in KNOWN_CONCEPTS (one line; presets, doctor and config validation follow).
  • forge-contracts.tsPrCreateResult.
  • scripts/forge/github/pr-create.shgh pr create with the flags it already took. Behaviour for GitHub users is unchanged.
  • scripts/forge/gitea/pr-create.shtea 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 through tea pulls list --fields index,url,head --output json, which is machine-readable, rather than parsed out of that view.
  • scripts/forge/gitlab/pr-create.shscope 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 runs gh, which is the bug this closes. It is marked ⚠️ UNVERIFIED in-file because glab is not installed in the authoring environment — same convention as the existing gitea/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.
  • 14 prompt files across both trees now read:
    export CODEV_PR_TITLE=""
    export CODEV_PR_BODY="$(cat <<'EOF'
    
    EOF
    )"
    
    {{pr_create_command}}
    export, not an assignment prefix — see the CMAP section below. PIR moved off --body-file to CODEV_PR_BODY="$(cat codev/reviews/….md)".
  • extractExecutable honours a # forge-executable: <tool> declaration, so codev doctor reports the CLI a script actually needs rather than the first line of a guard clause.
  • Docs: codev/resources/commands/forge.md and the byte-identical .claude/.codex forge SKILL.md pair.
  • bugfix-685-close-keyword.test.ts — its PR-body-template regex now accepts CODEV_PR_BODY= alongside --body. Guard intent unchanged.

Verification

Live Forgejo, tea 0.14.2 — three scratch PRs, all closed and their branches deleted afterwards:

  • Explicit base/head: the script printed {"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-side base, head and title matched the inputs. This is the assertion the issue asked for, not "exit 0".
  • No CODEV_PR_HEAD: the git rev-parse --abbrev-ref HEAD default and tea's own base default both resolved correctly.
  • Answering the issue's open question: on tea 0.14.2, with a single configured login and an explicit --head, tea pulls create needs no --repo/--login and 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.shCODEV_PR_REPO=cluesmith/codev, CODEV_PR_HEAD=pseudoseed:builder/bugfix-1455.

Regression testsbugfix-1455-pr-create-concept.test.ts (dispatcher routing per provider; the scripts' contract exercised against fake gh/tea on PATH, including the multi-line tricky body byte-for-byte, the --description-not---body mapping, cross-repo <user>:<branch> head matching, and the failure paths) and bugfix-1455-pr-create-prompt.test.ts (porch renders the right command for github/gitea/override/disabled). Removing pr-create from KNOWN_CONCEPTS turns 7 of them red.

Review: two CMAP rounds, four defects fixed

Round 1 — gemini APPROVE, codex REQUEST_CHANGES, claude REQUEST_CHANGES:

  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 — 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 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. Fixed with the # forge-executable: declaration plus a shell-builtin skip list; verified against the built dist.
  3. The gitea lookup had no --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:

  1. An absent body posted an empty PR (codex). The scripts validated the title but not the body, and --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_LOGIN is documented where it's read.

Round 3 — gemini APPROVE, codex APPROVE, claude APPROVE, with two cosmetic notes taken: gitea/pr-create.sh now accepts .head as either a string (tea 0.14.2) or the object-with-.ref shape sibling scripts assume, and the stale "15 concepts" counts in forge.ts and arch.md are corrected to 18. Re-verified live against Forgejo afterwards.

Test plan

  • Regression tests added; they fail without the fix
  • pnpm build clean
  • Unit suite: 4891 passed / 0 failed (see the disclosure below)
  • CLI integration suite: 90 passed
  • Tower integration suite: 173 passed, 1 pre-existing failure unrelated to this change (cli-tower-mode.e2e.test.ts expects http://localhost:… and this machine's Tower binds 0.0.0.0)
  • Live end-to-end against a real Forgejo

Disclosure: one unrelated test is timing-sensitive on my machine

spec-1280-measurement-instrument.test.ts intermittently fails here with Test timed out in 60000ms — usually 1 test, sometimes 2, always PHASE_ITERS is a linear comparison constant and/or the determinism test. Both shell out to scripts/measure-prompt-surface.sh twice.

  • That script takes 25–30 s per invocation on this machine at ~17% CPU (process-spawn bound), against the ~2.0 s recorded in codev/state/bugfix-1323_thread.md when the 60 s budgets were set. Two calls per test lands right on the budget.
  • Nothing here touches it: this branch is 0 commits behind upstream/main, and both the test file and the script are byte-identical to main — so main fails identically on this machine.
  • The whole suite passes when that file's runtime isn't competing with the rest (vitest run with 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 gitea read-concept bugs, all in #1137/#1146 territory rather than this one:

  • tea pulls view <n> --output json ignores <n> and returns a list of all pulls, so gitea/pr-view.sh's jq '.url = (.html_url // .url)' receives an array.
  • tea pulls list rejects --fields description, so gitea/pr-list.sh's description → body mapping can't populate.
  • tea pulls list --output json returns head as a plain branch string, but gitea/pr-exists.sh filters on .head.ref.

Also: gh pr edit --body-file is still hardcoded in the PIR review prompt. pr-edit isn't a concept and adding one is a separate change.

🤖 Generated with Claude Code


Update (2026-08-14): gitea pr-create no longer looks the PR up

The gitea script created the PR with tea pulls create and then searched for it with tea pulls list --limit 200. That --limit 200 was 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 at max_response_items, default 50--limit 200 does 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/api reports max_response_items: 50, and a ?limit=200 request 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}/pulls returns the created PR — number and html_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:

  1. tea api exits 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 numeric number and a non-empty browser URL — or it fails loudly with the body. The case where number is 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.
  2. The API requires base ([Base]: Required), where tea pulls create defaulted it client-side. An unset CODEV_PR_BASE now resolves the repo's default branch explicitly.
  3. draft: true in the payload is silently ignored (response comes back draft: false), so CODEV_PR_DRAFT=1 would have been an accepted-and-ignored flag. Gitea's draft marker is a WIP: title prefix — what tea pulls create --draft does — 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

  • The gitea half of bugfix-1455-pr-create-concept.test.ts is rewritten against a tea api stub. Every new case was run against the previous script first and fails there, so the pin is real — including an explicit assertion that no pulls / list / --limit call is made at all.
  • Full @cluesmith/codev unit suite on this branch: 3218 passed, 126 failed (67 files).
  • Baseline, same suite on unmodified upstream/main in 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 are agent-farm / terminal / consolidate (shellper sockets, SQLite state), environment-dependent: no built dist/, live Tower on the same state.
  • All Gitea verification ran against scratch repo pseudoseed/research; every scratch PR closed, every scratch branch deleted. No tea token 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.

Question Answer How it was checked
Do they conflict? No. git merge-tree on 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.
Must one merge first? No. #1458's pr-create.sh does not source _lib.sh and does not call gitea_repo or tea_api_paged. Nothing in it resolves against #1146.
Does the merged result hold together? Yes. In the merged tree, _lib.sh and pr-view.sh are byte-identical to #1146's versions and pr-create.sh byte-identical to #1458's — no silent blending. The bugfix-693 invariant (every entry under each provider dir is a *.sh) still holds with _lib.sh present.

Does #1458 duplicate something #1146 makes shared?

The paginator: no, and it shouldn't. _lib.sh#tea_api_paged exists to walk a truncating list endpoint. pr-create no 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.

They were kept separate deliberately, for two reasons rather than by omission:

  1. Different contract. pr-create takes CODEV_PR_REPO; the read concepts take CODEV_REPO. gitea_repo() reads the latter and takes no argument, so pr-create could 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.
  2. --repo does 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-create as a bare 404 page not found, and now names CODEV_PR_REPO as the remedy, matching gitea_repo()'s fail-fast message.

pseudoseed and others added 15 commits August 13, 2026 22:27
…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>
pseudoseed added a commit to pseudoseed/codev that referenced this pull request Aug 14, 2026
…luesmith#1458

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pseudoseed added a commit to pseudoseed/codev that referenced this pull request Aug 14, 2026
…luesmith#1458

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pseudoseed

Copy link
Copy Markdown
Author

Reconciled with #1146 — the --limit 200 lookup is gone

tl;dr: gitea/pr-create.sh no longer looks the new PR up at all. tea api -X POST repos/{owner}/{repo}/pulls returns the created PR in its response body, so there is nothing to search, nothing to race and nothing to truncate.

The problem

This PR's 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:

tea pulls list --state open --limit 200 --fields index,url,head --output json

That --limit 200 was added here as a fix for a review defect, but it rests on an assumption #1146 disproves: Gitea caps every list response at the server's max_response_items, default 50. --limit 200 does not raise the cap — it silently truncates.

Re-confirmed live against Forgejo 15.0.2, not inferred:

  • GET settings/api{"max_response_items":50, …}
  • a ?limit=200 request returned exactly 50 items on a list where paging at 50 returned 53

So on a busy repo the just-created PR falls off the first page and this script prints

pr-create: created the PR but could not find an open pull for head '<branch>'

and exits 1 for a PR that exists — inviting a duplicate retry at the single most important write in the protocol.

The fix: delete the lookup, don't paginate it

Routing the lookup through #1146's paginated passthrough was the obvious option. Returning the PR directly is strictly better, and it turns out to be possible: the REST create returns the full PR object, number and html_url included. That also removes the <user>:<branch> head-matching heuristic — the API resolves an owner-qualified head itself.

Three defects live testing found in the obvious version of that change

Each is the same bug class as #1455 itself — an operation accepted and then silently not performed — so each is handled in code, not noted as a caveat.

1. tea api exits 0 on HTTP errors. It prints the error body and returns 0. Since this change replaces a lookup with a single call, trusting that exit code would reintroduce #1455's silent success inside the fix for it — a 404 or 422 reported as a created PR. The response is therefore asserted to be a PR object: an object with a numeric number and a non-empty browser URL. Anything else fails loudly with the body. Pinned by a test table feeding 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 explicitly not to retry. Reading that as "nothing happened" is how duplicates get opened.

2. The API requires base. Without it: {"message":"[Base]: Required"}. tea pulls create defaulted it client-side, so moving to REST would have broken every caller relying on that default. Silently posting against the wrong base is worse than erroring, so an unset CODEV_PR_BASE now resolves the repo's default branch explicitly and fails clearly if it cannot.

3. draft: true in the payload is silently ignored — the response comes back draft: false. 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 (tea 0.14.2 + Forgejo 15.0.2)

  • {owner}/{repo} are substituted by tea from the repo context, and --repo owner/name supplies that context when the cwd has no Gitea remote. Checked with https and scp-style Gitea remotes, and from a GitHub-remote cwd.
  • url on the create response is the browser page (unlike the GET shape, where url is the API endpoint), so .html_url // .url lands the right one in PrCreateResult.
  • The body round-trips byte-identically: built with jq --arg and fed on stdin (-d @-) rather than surviving an argv round-trip.
  • Error paths: duplicate head, missing branch, unresolvable repo — all exit 1 with a useful message.

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 #1146.

Merge order

None. This deliberately does not source #1146's _lib.sh: pr-create takes CODEV_PR_REPO (a different input from the read concepts' CODEV_REPO), and tea's own {owner}/{repo} placeholders cover it. The two PRs stay independent and can merge in either order.

Testing

The gitea half of bugfix-1455-pr-create-concept.test.ts is rewritten against a tea api stub. Every new case was checked against the previous script first and fails there, so the pin is real rather than decorative — including an explicit assertion that no pulls / list / --limit call is made at all.

All Gitea verification ran against scratch repo pseudoseed/research; every scratch PR is closed and every scratch branch deleted. No tea token scope was widened.

…luesmith#1458

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pseudoseed added a commit to pseudoseed/codev that referenced this pull request Aug 14, 2026
…luesmith#1458

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pseudoseed
pseudoseed force-pushed the builder/bugfix-1455 branch from 915fa62 to 35d575c Compare August 14, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pr-create is not a forge concept: gh pr create is hardcoded in the skeleton prompts, so non-GitHub forges need a gh shim

1 participant