Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
id: bugfix-1137
title: gitea-forge-preset-is-broken-a
protocol: bugfix
phase: fix
plan_phases: []
current_plan_phase: null
gates:
merge-approval:
status: pending
iteration: 1
build_complete: false
history: []
started_at: '2026-07-06T19:06:00.466Z'
updated_at: '2026-07-06T19:12:31.039Z'
175 changes: 175 additions & 0 deletions codev/state/task-24AO_thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# task-24AO — rebase PR #1146, reconcile it with PR #1458

Two open PRs against `cluesmith/codev` overlapped and neither builder saw the other.
This builder rebased one and reconciled the other. Both branches live on the
**pseudoseed fork only** — we have no push access to `cluesmith/codev`, and neither
PR was merged (the maintainer merges).

## Task 1 — rebase #1146 (`builder/bugfix-1137`)

2063 commits behind `upstream/main`, 5 commits of its own, `mergeable=CONFLICTING`.

Rebased onto `upstream/main`. Exactly one file conflicted, twice — `gitea/pr-view.sh`:

- **Conflict 1** (against commit 3, "Route gitea forge reads through `tea api`"):
upstream had landed PIR #1179, which added `url` mapped from Gitea's `html_url`
(Gitea's own `url` is the API endpoint and would render raw JSON in a browser).
#1146 rewrote the same script onto `tea api` with an explicit normalizer that
emitted **no `url` at all**. Naively taking either side loses something.
Resolution: keep #1146's `tea api` routing **and** re-add `url: (.html_url // .url)`.
`forge-contracts.ts` documents that mapping for Gitea by name, so dropping it
would have silently regressed #1179.
- **Conflict 2** (against commit 5, which factors REPO derivation into
`_lib.sh#gitea_repo`): same file, same hunk. Kept the factored `gitea_repo` call
plus the `html_url` mapping.

**Behaviour change from the resolution, stated plainly:** gitea `pr-view` now emits
a `url` field it did not emit on the pre-rebase branch. That is a restoration of
upstream's behaviour, not a new invention — but it is a real output change, so
commit 4's test fixture gained `html_url`/`url` and the assertion now pins that the
**browser page**, not the API endpoint, reaches the contract.

Two smaller deviations, both deliberate:

- `_lib.sh` is committed `100755`, not `100644`. `scripts/postinstall.mjs` chmods
every `scripts/forge/**/*.sh` to 755 unconditionally, so 644 is a mode that never
survives an install and leaves a permanently dirty worktree for anyone who runs
`pnpm install`. It is sourced, not executed; the bit is harmless.
- The test fixture change was applied **inside commit 4** (via an interactive rebase
stop), not as a trailing fixup, so every commit stays green in isolation.

Verified: all 5 commits pass the forge suites individually
(`bugfix-1137-gitea-tea-api`, `bugfix-568-pr-exists-state-all`, `forge`,
`bugfix-693-forge-exec-bit`). An earlier per-commit run was **invalid** — the
`git checkout`s silently failed on a dirty `_lib.sh` and re-tested HEAD three
times. Caught and redone.

## Task 2 — the reconcile

#1146's core finding: Gitea caps every list response at `max_response_items`,
default 50, so `&limit=200` **silently truncates**. #1458's `gitea/pr-create.sh`
created the PR with `tea pulls create` and then looked it up with
`tea pulls list --limit 200` — the exact call #1146 disproves. On a busy repo the
just-created PR falls off the page and pr-create exits 1 for a PR that exists,
inviting a duplicate retry. That `--limit 200` had been added as a *fix* for a
review defect, so it was a fix built on a false premise.

Confirmed the premise is false, live on Forgejo 15.0.2: `settings/api` reports
`max_response_items: 50`, and `?limit=200` returns exactly 50 items on a list where
paging at 50 returns 53.

**Chose option (b) — drop list-and-search entirely.** It is possible:
`tea api -X POST repos/{owner}/{repo}/pulls` returns the created PR, `number` and
`html_url` included. Nothing to search, nothing to race, nothing to truncate. It
also deletes the `<user>:<branch>` head-matching heuristic — the API resolves an
owner-qualified head itself.

### What live verification changed about the design

Every one of these was found by testing, not by reading docs. Three of them are
**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. The
architect independently flagged the same three; the resolutions below are what
shipped.

| Finding | Consequence |
|---|---|
| `tea api` **exits 0 on HTTP errors**, printing the error body | The whole change replaces a lookup with one call, so trusting the exit code would reintroduce #1455's silent success *inside the fix for it*. The response is asserted to BE a PR object — numeric `number` AND non-empty browser URL — or it fails loudly with the body. Duplicate head, missing branch and unresolvable repo all exited 0 before. |
| The API **requires** `base` (`[Base]: Required`); `tea pulls create` defaulted it client-side | Posting against the wrong base silently is worse than erroring. An unset `CODEV_PR_BASE` now resolves the repo's default branch explicitly, and fails clearly if it cannot. |
| `draft: true` in the payload is **silently ignored** (response comes back `draft: false`) | `CODEV_PR_DRAFT=1` would have been an accepted-and-ignored flag. Gitea marks drafts by a `WIP:` title prefix — what `tea pulls create --draft` does. Implemented, and verified server-side to produce `draft: true`. |
| `{owner}`/`{repo}` are substituted by tea from repo context; `--repo` supplies it | No dependency on #1146's `_lib.sh`. Verified with https and scp-style remotes, and from a GitHub-remote cwd. |
| POST's `url` is the browser page (unlike GET, where `url` is the API endpoint) | `.html_url // .url` covers both. |

**One subtlety inside the first row.** If the response carries a numeric `number`
but no usable URL, the PR *was* created and only the URL is missing. Exiting 1 is
still right, but a generic failure message would read as "nothing happened" and
invite the duplicate retry this entire change exists to prevent. That case gets its
own message naming the PR number and saying explicitly not to retry.

### Deliberate divergence from #1146's siblings

The read concepts derive owner/repo via `_lib.sh#gitea_repo`; pr-create uses tea's
`{owner}`/`{repo}` placeholders instead. Reasons: pr-create's input is
`CODEV_PR_REPO`, not `CODEV_REPO` (different contract), and sourcing `_lib.sh` would
make #1458 depend on #1146 merging first. **The two PRs stay independent and can
merge in either order.** The one thing `gitea_repo` gave that placeholders did not
was a good error message, so pr-create now names `CODEV_PR_REPO` as the remedy on a
404 rather than leaking a bare `404 page not found`.

## Scope note

The reconcile required altering #1458 — `pr-create.sh` exists only on that branch.
It was added as **one new commit on top**, not a rebase: #1458 was 0 behind
`upstream/main` and its history is untouched.

## Verification and cleanup

All Gitea work ran against scratch repo `pseudoseed/research` (zero CI workflows, so
it steals no runner slots). Nine scratch PRs (#18–#26) created and **all closed**;
every scratch branch deleted; no leftover files on `main`. Confirmed empty
afterwards. No `tea` token scope was widened — everything needed was already in
scope.

Tests: the new gitea cases were checked against the **old** script first and all of
them fail there, so the regression pin is real rather than decorative. The
exit-0-on-error assertion is pinned by a table of six non-PR payloads (error
object, array, string-typed `number`, numberless object, `null`, empty body), each
served at exit 0.

## Merge order — verified, not asserted

Both PRs come from the same fork and both touch `packages/codev/scripts/forge/gitea/`, so the
maintainer would otherwise have to derive the ordering. Checked rather than assumed:

- `git merge-tree` on the two branch tips merges **cleanly**. The only file both touch is this
thread log, which is the **identical blob** on both branches (that is why they were kept
byte-identical) and auto-merges.
- In the merged tree, `_lib.sh` and `pr-view.sh` are byte-identical to the #1146 versions and
`pr-create.sh` byte-identical to the #1458 version — no silent blending.
- The `bugfix-693` invariant (every entry under a provider dir is a `*.sh`) still holds with
`_lib.sh` present.

**Either order is safe.** #1458's `pr-create.sh` does not source `_lib.sh` and calls neither
`gitea_repo` nor `tea_api_paged`.

**Duplication, honestly stated.** The paginator is *not* duplicated and should not be —
`tea_api_paged` walks a truncating list endpoint, and pr-create no longer lists anything. But
**repo resolution now has two paths**: the read concepts use `_lib.sh#gitea_repo` (reads
`CODEV_REPO`, else derives from origin, fails fast), pr-create uses tea's `{owner}`/`{repo}`
placeholders with `CODEV_PR_REPO` forwarded as `--repo`. Kept separate deliberately —
`gitea_repo()` takes no argument and reads the *other* env var, so consuming it would have meant
editing a #1146 file from #1458 and creating the coupling this avoids; and `--repo` also supplies
tea's login/host context, which a path-only helper does not.

Both PR bodies now carry this in full, with the recommended follow-up: **once both land**, unify
behind `gitea_repo "$CODEV_PR_REPO"` so there is one path and one error message. Not done here on
purpose — doing it now couples two independent PRs.

## Final test numbers (always reported against a control)

Same worktree, same command, three runs:

| Tree | Passed | Failed | Files failed |
|---|---|---|---|
| **Baseline — unmodified `upstream/main`** | 3176 | 126 | 67 |
| #1146 branch (rebased tip) | 3193 (+17) | 126 | 67 |
| #1458 branch | 3218 (+42) | 126 | 67 |

The *same* 67 files and *same* 126 tests fail in all three, so: **zero regressions on either
branch.** The failures are `agent-farm` / `terminal` / `consolidate` (shellper sockets, SQLite
state) — environment-dependent: this worktree has no built `dist/`, which those tests spawn from,
and a live Tower runs against the same state. None is in a file either PR touches; every forge
suite passes.

A raw pass/fail count with no control is unreadable — 126 failures looks alarming until the
baseline shows it is the environment. Report it with the control every time.

## Open items for the maintainer

- Both PRs are pushed to the pseudoseed fork; neither is merged.
- `_lib.sh#tea_api_paged` in #1146 concatenates pages with `jq -s 'add'`. Because
`tea api` exits 0 on HTTP errors, an error page reaches jq as an object and the
add fails with a raw jq type error rather than the API's message. It *does* fail
rather than silently mis-page, so this is a diagnosability wart, not a
correctness bug — flagged, deliberately not fixed here to keep the rebase faithful
to the original commits.
76 changes: 76 additions & 0 deletions packages/codev/scripts/forge/gitea/_lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Shared helpers for the Gitea forge preset scripts.
#
# This file is SOURCED, not executed (`. "$(dirname "$0")/_lib.sh"`), so it has
# no shebang and defines only functions/vars. POSIX sh only — no bashisms — the
# scripts are #!/bin/sh and forge runs them via `sh -c`. It is not a forge
# concept: forge.ts builds presets from an explicit KNOWN_CONCEPTS allowlist, so
# a leading-underscore file in this directory is never registered as a concept.

# Resolve owner/repo for the `tea api` path.
#
# `tea api` needs an explicit owner/repo in the path (unlike `tea pulls`/`tea
# issues`, which auto-detect it from the local git remote). Honor CODEV_REPO
# when set, else derive owner/repo from origin's URL (handles https, ssh, and
# scp-style remotes, with or without a .git suffix).
#
# Fails fast: if the result isn't a clean `owner/repo` (no origin remote, an
# unusual URL, etc.), print a stderr message naming CODEV_REPO as the remedy and
# return non-zero so the caller can `exit 1` — otherwise `tea api "repos//…"`
# fails later with a confusing 404. Callers must use: REPO="$(gitea_repo)" || exit 1
gitea_repo() {
_repo="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}"
_owner=${_repo%%/*}
_rest=${_repo#*/}
# Valid iff exactly one slash, both sides non-empty:
# - "$_owner" = "$_repo" → no slash at all
# - -z "$_owner" / -z "$_rest" → empty owner or repo (e.g. "/x", "x/")
# - "$_rest" != "${_rest%/*}" → a second slash (e.g. "a/b/c")
if [ -z "$_repo" ] || [ "$_owner" = "$_repo" ] || [ -z "$_owner" ] || [ -z "$_rest" ] || [ "$_rest" != "${_rest%/*}" ]; then
echo "gitea forge: could not determine owner/repo from the 'origin' remote; set CODEV_REPO=owner/repo" >&2
return 1
fi
printf '%s' "$_repo"
}

# Page size to request per page. Gitea caps list responses at the server's
# `max_response_items` (default 50), so `&limit=200` silently truncates to ~50
# with no client-side pagination. Requesting 50 matches that default cap; a
# server tuned higher just returns more per page (fewer round-trips).
GITEA_PAGE_LIMIT=50

# Hard ceiling on pages fetched, so a misbehaving server that never returns a
# short page can't spin forever. 100 pages × 50 = 5000 items — far beyond any
# real open-PR / recently-merged / all-pulls window we page over.
GITEA_MAX_PAGES=100

# Fetch a paginated Gitea list endpoint and emit ONE concatenated JSON array on
# stdout, so the caller's existing jq normalizer sees the same shape as before.
#
# Usage: tea_api_paged "repos/<owner>/<repo>/pulls" "state=all"
# $1 = API path (no page params)
# $2 = extra query string (may be empty), e.g. "state=open"
#
# Loops page=1,2,3… appending "&limit=<N>&page=<page>", concatenates each page's
# array, and stops when a page returns fewer than the requested limit (the last
# page) or an empty/blank response, bounded by GITEA_MAX_PAGES.
tea_api_paged() {
_path="$1"
_query="$2"
_page=1
_acc='[]'
while [ "$_page" -le "$GITEA_MAX_PAGES" ]; do
if [ -n "$_query" ]; then
_url="${_path}?${_query}&limit=${GITEA_PAGE_LIMIT}&page=${_page}"
else
_url="${_path}?limit=${GITEA_PAGE_LIMIT}&page=${_page}"
fi
_resp="$(tea api "$_url")" || return 1
# Blank body or an empty array → no more pages.
[ -n "$_resp" ] || break
_count="$(printf '%s' "$_resp" | jq 'length')" || return 1
_acc="$(printf '%s\n%s' "$_acc" "$_resp" | jq -s 'add')" || return 1
[ "$_count" -lt "$GITEA_PAGE_LIMIT" ] && break
_page=$((_page + 1))
done
printf '%s' "$_acc"
}
7 changes: 6 additions & 1 deletion packages/codev/scripts/forge/gitea/issue-comment.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
#!/bin/sh
# Forge concept: issue-comment (Gitea via tea CLI)
exec tea issues comment "$CODEV_ISSUE_ID" "$CODEV_COMMENT_BODY"
# Input: CODEV_ISSUE_ID, CODEV_COMMENT_BODY
# Output: exit code only
#
# `tea issues` has no `comment` subcommand (its subcommands are list/create/
# edit/close). Commenting lives under the top-level `tea comments add`.
exec tea comments add "$CODEV_ISSUE_ID" "$CODEV_COMMENT_BODY"
43 changes: 39 additions & 4 deletions packages/codev/scripts/forge/gitea/issue-view.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
#!/bin/sh
# Forge concept: issue-view (Gitea via tea CLI)
# Sets `url` to the issue's browser page (`html_url`). Gitea's own `url` field is
# the API endpoint (would render raw JSON in a browser), so we prefer `html_url`
# and fall back to the existing `url` only if `html_url` is absent.
tea issues view "$CODEV_ISSUE_ID" --output json | jq '.url = (.html_url // .url)'
# Input: CODEV_ISSUE_ID
# Output: JSON {title, body, state, url, comments[]} (IssueViewResult)
#
# `tea issues view N --output json` returns a flattened single-element list
# (no body/html_url/url), so route through the raw REST passthrough. `tea api`
# needs an explicit owner/repo in the path (unlike `tea issues`, which
# auto-detects it from the local git remote), so resolve it here: honor
# CODEV_REPO when set, else derive owner/repo from origin's URL (handles
# https, ssh, and scp-style remotes, with or without a .git suffix).
#
# `url` is mapped to the issue's browser page (`html_url`); Gitea's own `url`
# is the API endpoint (would render raw JSON in a browser), so we fall back to
# it only if `html_url` is absent.
#
# Gitea's issue object reports `comments` as an integer count, not the array
# the contract requires (consumers call `.comments.filter(...)`), so the
# comments array is fetched separately and merged in. A failed/empty comments
# fetch degrades to [], but warns on stderr so the degraded path is
# distinguishable from a genuinely uncommented issue (stdout stays pure JSON —
# it's parsed by forge.ts).
. "$(dirname "$0")/_lib.sh"
REPO="$(gitea_repo)" || exit 1
COMMENTS_JSON="$(tea api "repos/${REPO}/issues/${CODEV_ISSUE_ID}/comments" 2>/dev/null)"
if [ -z "$COMMENTS_JSON" ]; then
echo "gitea forge: comments fetch failed for issue ${CODEV_ISSUE_ID}; reporting 0 comments" >&2
COMMENTS_JSON="[]"
fi
tea api "repos/${REPO}/issues/${CODEV_ISSUE_ID}" \
| jq --argjson comments "$COMMENTS_JSON" '{
title,
body: (.body // ""),
state,
url: (.html_url // .url),
comments: [ $comments[] | {
body: (.body // ""),
createdAt: .created_at,
author: {login: .user.login}
} ]
}'
31 changes: 27 additions & 4 deletions packages/codev/scripts/forge/gitea/pr-exists.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
#!/bin/sh
# Forge concept: pr-exists (Gitea via tea CLI)
# Returns true for open or merged pulls only. Closed-not-merged pulls are excluded.
# --state all fetches pulls in all states; without it, only open pulls are returned.
# Gitea: merged PRs have state="closed" + merged=true; abandoned PRs have state="closed" + merged=false
tea pulls list --state all --fields index --output json | jq "[.[] | select(.head.ref == \"$CODEV_BRANCH_NAME\" and (.state == \"open\" or (.state == \"closed\" and .merged == true)))] | length > 0"
# Input: CODEV_BRANCH_NAME
# Output: "true" or "false"
#
# Returns true for OPEN or MERGED pulls only; closed-not-merged pulls are
# excluded. `tea pulls list` emits `.head` as a string (not `{ref}`) and reports
# merged PRs as state "merged" with no `.merged` boolean, so its output can't
# satisfy the `.head.ref` / `.merged` predicate below. Route through the raw
# REST passthrough, whose PR objects carry nested `.head.ref` and a `.merged`
# bool. `tea api` needs an explicit owner/repo in the path (unlike `tea pulls`,
# which auto-detects it from the local git remote), so resolve it here: honor
# CODEV_REPO when set, else derive owner/repo from origin's URL (handles https,
# ssh, and scp-style remotes, with or without a .git suffix).
#
# Caveat (Gitea behavior, not a codev bug): for a merged PR whose source branch
# was deleted, Gitea returns `.head.ref == "refs/pull/N/head"` instead of the
# original branch name, so a branch-name match won't hit a merged+deleted
# branch. That doesn't affect the "does an open/merged PR exist for the branch
# I'm about to push" use case.
#
# `state=all` is paginated (Gitea caps a page at max_response_items, default 50)
# so a branch whose PR isn't in the most recent ~50 would false-negative and
# block a porch pr_exists gate — tea_api_paged walks every page (see _lib.sh).
. "$(dirname "$0")/_lib.sh"
REPO="$(gitea_repo)" || exit 1
tea_api_paged "repos/${REPO}/pulls" "state=all" \
| jq --arg branch "$CODEV_BRANCH_NAME" \
'[.[] | select(.head.ref == $branch and (.state == "open" or .merged == true))] | length > 0'
Loading