From 03e0750e997882dbf0e5192a8e7446af7f3e36b8 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 6 Jul 2026 13:06:00 -0600 Subject: [PATCH 1/5] chore(porch): bugfix-1137 init bugfix --- .../status.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml diff --git a/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml b/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml new file mode 100644 index 000000000..c6418ea48 --- /dev/null +++ b/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml @@ -0,0 +1,14 @@ +id: bugfix-1137 +title: gitea-forge-preset-is-broken-a +protocol: bugfix +phase: investigate +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:06:00.469Z' From 09d4a546b97643d2f7c96047ca22c42ff052791c Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 6 Jul 2026 13:12:31 -0600 Subject: [PATCH 2/5] chore(porch): bugfix-1137 fix phase-transition --- .../bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml b/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml index c6418ea48..f1b31df65 100644 --- a/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml +++ b/codev/projects/bugfix-1137-gitea-forge-preset-is-broken-a/status.yaml @@ -1,7 +1,7 @@ id: bugfix-1137 title: gitea-forge-preset-is-broken-a protocol: bugfix -phase: investigate +phase: fix plan_phases: [] current_plan_phase: null gates: @@ -11,4 +11,4 @@ iteration: 1 build_complete: false history: [] started_at: '2026-07-06T19:06:00.466Z' -updated_at: '2026-07-06T19:06:00.469Z' +updated_at: '2026-07-06T19:12:31.039Z' From 540dc1bee604828a423abf30864830e91f010752 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 6 Jul 2026 13:30:45 -0600 Subject: [PATCH 3/5] [Bugfix #1137] Fix: Route gitea forge reads through `tea api` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gitea preset invoked `tea list/view/whoami/comment`, whose flattened `--fields` output (or missing flags/subcommands) doesn't match the Gitea REST shape that forge-contracts.ts and the jq normalizers assume. Route the read concepts through `tea api`, the raw REST passthrough that returns exactly that shape: - user-identity: `tea api user | jq .login` (`tea whoami` has no --output json) - pr-view: `tea api repos//pulls/N` → PrViewResult - pr-list: `tea api repos//pulls?state=open` → PrListItem[] (now also populates real reviewRequests/isDraft/body) - pr-exists: `tea api repos//pulls?state=all` with nested .head.ref/.merged - issue-view: `tea api repos//issues/N` + a second call for the comments ARRAY (Gitea's issue object reports `comments` as an int count, which would crash consumers' `.comments.filter(...)`) - recently-merged: `tea api repos//pulls?state=closed`, filter .merged, using the real .merged_at - issue-comment: `tea comments add` (`tea issues` has no `comment` subcommand) `tea api` needs an explicit owner/repo path segment (unlike `tea `, which auto-detects it from the local git remote), and most concepts are invoked without CODEV_REPO set, so each api-based script derives owner/repo from the origin remote, honoring CODEV_REPO when present. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scripts/forge/gitea/issue-comment.sh | 7 ++- .../codev/scripts/forge/gitea/issue-view.sh | 37 ++++++++++-- .../codev/scripts/forge/gitea/pr-exists.sh | 26 ++++++-- packages/codev/scripts/forge/gitea/pr-list.sh | 59 +++++++++---------- packages/codev/scripts/forge/gitea/pr-view.sh | 22 ++++++- .../scripts/forge/gitea/recently-merged.sh | 38 ++++++------ .../scripts/forge/gitea/user-identity.sh | 8 ++- 7 files changed, 136 insertions(+), 61 deletions(-) diff --git a/packages/codev/scripts/forge/gitea/issue-comment.sh b/packages/codev/scripts/forge/gitea/issue-comment.sh index bea4e8316..addf6ccc0 100755 --- a/packages/codev/scripts/forge/gitea/issue-comment.sh +++ b/packages/codev/scripts/forge/gitea/issue-comment.sh @@ -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" diff --git a/packages/codev/scripts/forge/gitea/issue-view.sh b/packages/codev/scripts/forge/gitea/issue-view.sh index db53b03e4..f2ead28e1 100755 --- a/packages/codev/scripts/forge/gitea/issue-view.sh +++ b/packages/codev/scripts/forge/gitea/issue-view.sh @@ -1,6 +1,35 @@ #!/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 []. +REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +COMMENTS_JSON="$(tea api "repos/${REPO}/issues/${CODEV_ISSUE_ID}/comments" 2>/dev/null)" +[ -n "$COMMENTS_JSON" ] || COMMENTS_JSON="[]" +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} + } ] + }' diff --git a/packages/codev/scripts/forge/gitea/pr-exists.sh b/packages/codev/scripts/forge/gitea/pr-exists.sh index db8365012..d8d8c9b0d 100755 --- a/packages/codev/scripts/forge/gitea/pr-exists.sh +++ b/packages/codev/scripts/forge/gitea/pr-exists.sh @@ -1,6 +1,24 @@ #!/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. +REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +tea api "repos/${REPO}/pulls?state=all&limit=200" \ + | jq --arg branch "$CODEV_BRANCH_NAME" \ + '[.[] | select(.head.ref == $branch and (.state == "open" or .merged == true))] | length > 0' diff --git a/packages/codev/scripts/forge/gitea/pr-list.sh b/packages/codev/scripts/forge/gitea/pr-list.sh index d320c5cc2..a6deac289 100755 --- a/packages/codev/scripts/forge/gitea/pr-list.sh +++ b/packages/codev/scripts/forge/gitea/pr-list.sh @@ -1,36 +1,35 @@ #!/bin/sh -# Forge concept: pr-list (Gitea via tea CLI) +# Forge concept: pr-list (Gitea via tea CLI) — open pulls +# Output: JSON [{number, title, url, reviewDecision, body, createdAt, author, +# reviewRequests, isDraft}] (PrListItem in forge-contracts.ts) # -# Normalize tea's PR shape to the GitHub-compatible shape codev expects -# (see PrListItem in codev/src/lib/forge-contracts.ts): -# index -> number (int) -# description -> body -# created -> createdAt -# author (string) -> author.login -# reviewDecision -> "" (Gitea has no GitHub-equivalent review-decision summary) -# reviewRequests -> [] (verified against tea 0.14.1: `pulls list` exposes -# no `reviewers` field, and its JSON output is limited -# to the selectable `--fields`, so requested reviewers -# are unreachable here. The VSCode sort silently skips -# the review-requested bucket when empty.) -# isDraft -> false (verified: tea 0.14.1 `pulls list` exposes no -# `draft` field among its selectable `--fields`.) -# The underlying Gitea API PR object does carry `draft` and `requested_reviewers`, -# but only the raw `tea api` passthrough can reach them — populating these two -# fields for Gitea would mean reworking this concept onto `tea api`, which is a -# separate, larger change than #787's scope. -exec tea pulls list --limit 200 \ - --fields index,title,state,author,url,created,description \ - --output json \ +# `tea pulls list --fields …,description` errors ("invalid field 'description'") +# and its flattened output can't carry a PR body, draft flag, or requested +# reviewers. Route through the raw REST passthrough instead, whose PR objects +# expose all of them. `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). +# +# Field mapping: +# .number -> number (already an int in the REST shape) +# .html_url -> url (browser page; Gitea `.url` is the API endpoint) +# .body -> body +# .created_at -> createdAt +# .user.login -> author.login +# .requested_reviewers[].login -> reviewRequests (user logins; teams have no login → dropped) +# .draft -> isDraft +# reviewDecision -> "" (Gitea has no GitHub-equivalent review-decision summary) +REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +tea api "repos/${REPO}/pulls?state=open&limit=200" \ | jq '[.[] | { - number: (.index | tonumber), + number, title, - state, - url, + url: (.html_url // .url), reviewDecision: "", - body: (.description // ""), - createdAt: .created, - author: {login: .author}, - reviewRequests: [], - isDraft: false + body: (.body // ""), + createdAt: .created_at, + author: {login: .user.login}, + reviewRequests: [ (.requested_reviewers // [])[] | .login // empty ], + isDraft: (.draft // false) }]' diff --git a/packages/codev/scripts/forge/gitea/pr-view.sh b/packages/codev/scripts/forge/gitea/pr-view.sh index f1292762e..0cde02fa9 100755 --- a/packages/codev/scripts/forge/gitea/pr-view.sh +++ b/packages/codev/scripts/forge/gitea/pr-view.sh @@ -1,3 +1,23 @@ #!/bin/sh # Forge concept: pr-view (Gitea via tea CLI) -exec tea pulls view "$CODEV_PR_NUMBER" --output json +# Input: CODEV_PR_NUMBER +# Output: JSON {title, body, state, author{login}, baseRefName, headRefName, +# additions, deletions} (see PrViewResult in forge-contracts.ts) +# +# `tea pulls view N --output json` returns a table header / empty list rather +# than the PR object, so route through the raw REST passthrough. `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). +REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +tea api "repos/${REPO}/pulls/${CODEV_PR_NUMBER}" | jq '{ + title, + body: (.body // ""), + state, + author: {login: .user.login}, + baseRefName: .base.ref, + headRefName: .head.ref, + additions: (.additions // 0), + deletions: (.deletions // 0) +}' diff --git a/packages/codev/scripts/forge/gitea/recently-merged.sh b/packages/codev/scripts/forge/gitea/recently-merged.sh index 4c2d30955..3cd9e083c 100755 --- a/packages/codev/scripts/forge/gitea/recently-merged.sh +++ b/packages/codev/scripts/forge/gitea/recently-merged.sh @@ -1,27 +1,25 @@ #!/bin/sh # Forge concept: recently-merged (Gitea via tea CLI) +# Output: JSON [{number, title, url, body, createdAt, mergedAt, headRefName}] +# (MergedPrItem in forge-contracts.ts) # -# `tea pulls list --state closed` returns both merged PRs and closed-without- -# merge PRs. Filter to merged only via `.merged == true` (the same predicate -# scripts/forge/gitea/pr-exists.sh already relies on), then map to the -# GitHub-compatible shape: -# index -> number (int) -# created -> createdAt -# updated -> mergedAt (tea exposes no merged_at field via --fields; -# close-then-edit overestimates merged time -# but is acceptable for the 24h overview window) -# head.ref -> headRefName -# description -> body -exec tea pulls list --state closed --limit 1000 \ - --fields index,title,state,author,url,created,updated,head,description,merged \ - --output json \ +# `tea pulls list --fields …,head,description,merged` errors on the `description` +# field and emits `.head` as a string, so it can't populate `body` or +# `.head.ref`. Route through the raw REST passthrough instead, whose closed +# pulls carry `.merged`, `.merged_at`, nested `.head.ref`, and `.body`. Keep +# only merged pulls (closed-without-merge have `.merged == false`). `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). +REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +tea api "repos/${REPO}/pulls?state=closed&limit=200" \ | jq '[.[] | select(.merged == true) | { - number: (.index | tonumber), + number, title, - state, - url, - body: (.description // ""), - createdAt: .created, - mergedAt: .updated, + url: (.html_url // .url), + body: (.body // ""), + createdAt: .created_at, + mergedAt: .merged_at, headRefName: (.head.ref // "") }]' diff --git a/packages/codev/scripts/forge/gitea/user-identity.sh b/packages/codev/scripts/forge/gitea/user-identity.sh index 2b8f78523..c296a3c71 100755 --- a/packages/codev/scripts/forge/gitea/user-identity.sh +++ b/packages/codev/scripts/forge/gitea/user-identity.sh @@ -1,3 +1,9 @@ #!/bin/sh # Forge concept: user-identity (Gitea via tea CLI) -tea whoami --output json | jq -r ".login" +# Output: plain text username +# +# `tea whoami` has no `--output json` flag (its only documented option is +# --help), so it can't feed a jq pipeline. Route through the raw REST +# passthrough instead: `tea api user` returns the Gitea `User` object, whose +# `.login` is the authenticated username (mirrors `gh api user --jq .login`). +tea api user | jq -r ".login" From e203ab5960f62ecb6df81ce45eeeff55c7f509eb Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 6 Jul 2026 13:30:46 -0600 Subject: [PATCH 4/5] [Bugfix #1137] Test: Add gitea `tea api` regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stubs a fake `tea` on PATH answering `api ` with captured Gitea REST fixtures (tea isn't in CI, per #920), points the scripts at a throwaway repo with a gitea remote, runs each real script, and asserts the normalized output conforms to forge-contracts.ts — incl. comments-as-array, merged-only filtering, open/merged/closed pr-exists cases, and CODEV_REPO override. Also updates the #568 pr-exists assertion for gitea to match the new `state=all` query param (was `--state all` flag). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bugfix-1137-gitea-tea-api.test.ts | 214 ++++++++++++++++++ .../bugfix-568-pr-exists-state-all.test.ts | 7 +- 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts diff --git a/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts b/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts new file mode 100644 index 000000000..a980a4d47 --- /dev/null +++ b/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts @@ -0,0 +1,214 @@ +/** + * Regression test for bugfix #1137: the gitea forge preset was written against + * the Gitea REST API JSON shape but invoked the `tea` CLI's flattened + * ` list/view` output (or non-existent flags/subcommands), so every + * read concept either errored or emitted the wrong shape. + * + * The fix routes the read concepts through `tea api `, whose raw + * passthrough returns exactly the Gitea REST shape the jq normalizers and + * `forge-contracts.ts` already assume. + * + * `tea` isn't available in CI (see the in-repo #920 note), so this test stubs a + * fake `tea` on PATH that answers `api ` (and `comments add`) with + * captured Gitea REST fixtures, points the scripts at a throwaway git repo with + * a gitea remote, runs each real script, and asserts the normalized output + * conforms to the contract in forge-contracts.ts. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const giteaDir = resolve(__dirname, '..', '..', 'scripts', 'forge', 'gitea'); + +// A fake `tea` binary. It only implements `api ` (the surface the +// fixed scripts use) plus `comments add`. Each endpoint returns the raw Gitea +// REST shape — nested objects, real `.merged`/`.merged_at`/`.draft`, integer +// `comments` count on the issue object, etc. +const FAKE_TEA = `#!/bin/sh +if [ "$1" = "comments" ] && [ "$2" = "add" ]; then + # comments add + echo "commented" + exit 0 +fi +[ "$1" = "api" ] || { echo "fake-tea: unsupported: $*" >&2; exit 3; } +case "$2" in + user) + echo '{"login":"octo","id":7}' ;; + repos/acme/widgets/pulls/42) + echo '{"number":42,"title":"Add widget","body":"PR body","state":"open","user":{"login":"alice"},"base":{"ref":"main"},"head":{"ref":"feature/x"},"additions":10,"deletions":3}' ;; + "repos/acme/widgets/pulls?state=open&limit=200") + echo '[{"number":42,"title":"Add widget","html_url":"https://git.example.com/acme/widgets/pulls/42","url":"https://git.example.com/api/v1/repos/acme/widgets/pulls/42","body":"PR body","state":"open","created_at":"2026-07-01T10:00:00Z","user":{"login":"alice"},"requested_reviewers":[{"login":"bob"},{"login":null}],"draft":true}]' ;; + "repos/acme/widgets/pulls?state=all&limit=200") + echo '[{"number":42,"state":"open","merged":false,"head":{"ref":"feature/x"}},{"number":40,"state":"closed","merged":true,"head":{"ref":"feature/done"}},{"number":39,"state":"closed","merged":false,"head":{"ref":"feature/abandoned"}}]' ;; + "repos/acme/widgets/pulls?state=closed&limit=200") + echo '[{"number":40,"title":"Done PR","html_url":"https://git.example.com/acme/widgets/pulls/40","body":"merged body","state":"closed","merged":true,"merged_at":"2026-07-05T12:00:00Z","created_at":"2026-07-02T09:00:00Z","head":{"ref":"feature/done"}},{"number":39,"title":"Abandoned","state":"closed","merged":false,"head":{"ref":"feature/abandoned"}}]' ;; + repos/acme/widgets/issues/99) + echo '{"number":99,"title":"Bug here","body":"issue body","state":"open","html_url":"https://git.example.com/acme/widgets/issues/99","url":"https://git.example.com/api/v1/repos/acme/widgets/issues/99","comments":2}' ;; + repos/acme/widgets/issues/99/comments) + echo '[{"body":"On it! Working on a fix now.","created_at":"2026-07-06T08:00:00Z","user":{"login":"carol"}},{"body":"second","created_at":"2026-07-06T09:00:00Z","user":{"login":"dave"}}]' ;; + *) echo "fake-tea: no fixture for: $2" >&2; exit 4 ;; +esac +`; + +let fixture: string; +let binDir: string; +let repoDir: string; +let runEnv: NodeJS.ProcessEnv; + +function hasJq(): boolean { + try { + execFileSync('jq', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const jqAvailable = hasJq(); + +/** Run a gitea forge script under the fake `tea`, return trimmed stdout. */ +function runScript(name: string, env: Record = {}): string { + return execFileSync('sh', [join(giteaDir, name)], { + cwd: repoDir, + env: { ...runEnv, ...env }, + encoding: 'utf-8', + }).trim(); +} + +describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through `tea api`', () => { + beforeAll(() => { + fixture = mkdtempSync(join(tmpdir(), 'codev-1137-')); + binDir = join(fixture, 'bin'); + repoDir = join(fixture, 'repo'); + mkdirSync(binDir, { recursive: true }); + mkdirSync(repoDir, { recursive: true }); + + const teaPath = join(binDir, 'tea'); + writeFileSync(teaPath, FAKE_TEA, { mode: 0o755 }); + chmodSync(teaPath, 0o755); + + // Throwaway repo with a scp-style gitea remote → owner/repo = acme/widgets. + execFileSync('git', ['init', '-q'], { cwd: repoDir }); + execFileSync('git', ['remote', 'add', 'origin', 'git@git.example.com:acme/widgets.git'], { cwd: repoDir }); + + runEnv = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` }; + }); + + afterAll(() => { + rmSync(fixture, { recursive: true, force: true }); + }); + + it('user-identity emits the bare login (not JSON)', () => { + expect(runScript('user-identity.sh')).toBe('octo'); + }); + + it('pr-view returns the PrViewResult shape from the PR object', () => { + const pr = JSON.parse(runScript('pr-view.sh', { CODEV_PR_NUMBER: '42' })); + expect(pr).toEqual({ + title: 'Add widget', + body: 'PR body', + state: 'open', + author: { login: 'alice' }, + baseRefName: 'main', + headRefName: 'feature/x', + additions: 10, + deletions: 3, + }); + }); + + it('pr-list normalizes to PrListItem[] incl. real reviewRequests/isDraft/body', () => { + const list = JSON.parse(runScript('pr-list.sh')); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ + number: 42, + title: 'Add widget', + url: 'https://git.example.com/acme/widgets/pulls/42', + reviewDecision: '', + body: 'PR body', + createdAt: '2026-07-01T10:00:00Z', + author: { login: 'alice' }, + reviewRequests: ['bob'], // null-login (team) reviewers dropped + isDraft: true, + }); + expect(typeof list[0].number).toBe('number'); + }); + + it('pr-exists is true for an OPEN pull on the branch', () => { + expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'feature/x' })).toBe('true'); + }); + + it('pr-exists is true for a MERGED pull on the branch', () => { + expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'feature/done' })).toBe('true'); + }); + + it('pr-exists is false for a closed-not-merged branch', () => { + expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'feature/abandoned' })).toBe('false'); + }); + + it('pr-exists is false when no PR matches the branch', () => { + expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'no-such-branch' })).toBe('false'); + }); + + it('issue-view returns body, browser url, and comments as an ARRAY', () => { + const issue = JSON.parse(runScript('issue-view.sh', { CODEV_ISSUE_ID: '99' })); + expect(issue.title).toBe('Bug here'); + expect(issue.body).toBe('issue body'); + expect(issue.state).toBe('open'); + // html_url (browser page), NOT the API endpoint + expect(issue.url).toBe('https://git.example.com/acme/widgets/issues/99'); + // Contract requires an array — Gitea's issue object reports `comments` as an + // integer count, which would crash `issue.comments.filter(...)`. + expect(Array.isArray(issue.comments)).toBe(true); + expect(issue.comments).toEqual([ + { body: 'On it! Working on a fix now.', createdAt: '2026-07-06T08:00:00Z', author: { login: 'carol' } }, + { body: 'second', createdAt: '2026-07-06T09:00:00Z', author: { login: 'dave' } }, + ]); + }); + + it('recently-merged keeps merged pulls only and uses merged_at', () => { + const merged = JSON.parse(runScript('recently-merged.sh')); + expect(merged).toHaveLength(1); + expect(merged[0]).toEqual({ + number: 40, + title: 'Done PR', + url: 'https://git.example.com/acme/widgets/pulls/40', + body: 'merged body', + createdAt: '2026-07-02T09:00:00Z', + mergedAt: '2026-07-05T12:00:00Z', + headRefName: 'feature/done', + }); + }); + + it('issue-comment uses `tea comments add` and exits 0', () => { + // Would exit non-zero (throwing) if it invoked the non-existent + // `tea issues comment` subcommand. + expect(runScript('issue-comment.sh', { CODEV_ISSUE_ID: '99', CODEV_COMMENT_BODY: 'hi' })).toBe('commented'); + }); + + it('CODEV_REPO overrides the git-remote-derived owner/repo', () => { + // A repo whose remote does NOT resolve to acme/widgets still works when + // CODEV_REPO is supplied explicitly (the repo-archive-style callers). + const other = mkdtempSync(join(tmpdir(), 'codev-1137-other-')); + try { + execFileSync('git', ['init', '-q'], { cwd: other }); + execFileSync('git', ['remote', 'add', 'origin', 'https://git.example.com/someone/else.git'], { cwd: other }); + const out = execFileSync('sh', [join(giteaDir, 'pr-view.sh')], { + cwd: other, + env: { ...runEnv, CODEV_REPO: 'acme/widgets', CODEV_PR_NUMBER: '42' }, + encoding: 'utf-8', + }).trim(); + expect(JSON.parse(out).title).toBe('Add widget'); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/codev/src/commands/porch/__tests__/bugfix-568-pr-exists-state-all.test.ts b/packages/codev/src/commands/porch/__tests__/bugfix-568-pr-exists-state-all.test.ts index e8f54c1bb..48cc69f63 100644 --- a/packages/codev/src/commands/porch/__tests__/bugfix-568-pr-exists-state-all.test.ts +++ b/packages/codev/src/commands/porch/__tests__/bugfix-568-pr-exists-state-all.test.ts @@ -63,9 +63,12 @@ describe('pr-exists forge scripts', () => { expect(fs.existsSync(scriptPath)).toBe(true); }); - it('fetches all pull states (--state all) to catch merged pulls (#568)', () => { + it('fetches all pull states (state=all) to catch merged pulls (#568)', () => { const content = fs.readFileSync(scriptPath, 'utf-8'); - expect(content).toContain('--state all'); + // #1137: routed through `tea api …/pulls?state=all` (the raw REST + // passthrough) instead of `tea pulls list --state all`, whose flattened + // output can't satisfy the `.head.ref` / `.merged` predicate. + expect(content).toContain('state=all'); }); it('filters out closed-not-merged PRs (#653)', () => { From 3c2e3c2bdd84112a36601413275acfee17193081 Mon Sep 17 00:00:00 2001 From: pseudo Date: Wed, 5 Aug 2026 01:56:11 -0600 Subject: [PATCH 5/5] [Bugfix #1137] Fix: Paginate gitea reads, factor REPO fail-fast, warn on degraded comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #1146 review feedback: 1. Pagination (blocking). Gitea caps list responses at max_response_items (default 50), so the raw `&limit=200` passthrough silently truncated — pr-exists could false-negative a PR beyond the first ~50 (blocking a porch pr_exists gate) and recently-merged could miss on a busy repo. New shared helper `_lib.sh#tea_api_paged` walks page=1..N at limit=50, concatenates the arrays, and stops on a short/empty page with a hard 100-page ceiling. Chosen behavior: paginates, ceiling 100 pages. Wired into pr-exists, pr-list, recently-merged; output shape unchanged (same jq normalizers). 2. REPO derivation, fail-fast + factored. The CODEV_REPO/origin-derivation was duplicated in five scripts. Factored into `_lib.sh#gitea_repo`, sourced by issue-view, pr-exists, pr-list, pr-view, recently-merged. It now validates the result is a clean owner/repo and, if not, prints a stderr message naming CODEV_REPO as the remedy and exits non-zero (was a confusing `repos//…` 404). POSIX sh, $0-relative source; not a forge concept (KNOWN_CONCEPTS allowlist). 3. Degraded comments warn. issue-view still degrades a failed comments fetch to [], but now writes a stderr warning so it's distinguishable from a genuinely uncommented issue. stdout stays pure JSON (parsed by forge.ts). Co-Authored-By: Claude Opus 4.8 --- packages/codev/scripts/forge/gitea/_lib.sh | 76 +++++++++ .../codev/scripts/forge/gitea/issue-view.sh | 12 +- .../codev/scripts/forge/gitea/pr-exists.sh | 9 +- packages/codev/scripts/forge/gitea/pr-list.sh | 9 +- packages/codev/scripts/forge/gitea/pr-view.sh | 3 +- .../scripts/forge/gitea/recently-merged.sh | 9 +- .../bugfix-1137-gitea-tea-api.test.ts | 149 ++++++++++++++++-- 7 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 packages/codev/scripts/forge/gitea/_lib.sh diff --git a/packages/codev/scripts/forge/gitea/_lib.sh b/packages/codev/scripts/forge/gitea/_lib.sh new file mode 100644 index 000000000..cf289b870 --- /dev/null +++ b/packages/codev/scripts/forge/gitea/_lib.sh @@ -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///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=&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" +} diff --git a/packages/codev/scripts/forge/gitea/issue-view.sh b/packages/codev/scripts/forge/gitea/issue-view.sh index f2ead28e1..b9350d0bf 100755 --- a/packages/codev/scripts/forge/gitea/issue-view.sh +++ b/packages/codev/scripts/forge/gitea/issue-view.sh @@ -17,10 +17,16 @@ # 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 []. -REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +# 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)" -[ -n "$COMMENTS_JSON" ] || COMMENTS_JSON="[]" +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, diff --git a/packages/codev/scripts/forge/gitea/pr-exists.sh b/packages/codev/scripts/forge/gitea/pr-exists.sh index d8d8c9b0d..9cc437d3a 100755 --- a/packages/codev/scripts/forge/gitea/pr-exists.sh +++ b/packages/codev/scripts/forge/gitea/pr-exists.sh @@ -18,7 +18,12 @@ # 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. -REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" -tea api "repos/${REPO}/pulls?state=all&limit=200" \ +# +# `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' diff --git a/packages/codev/scripts/forge/gitea/pr-list.sh b/packages/codev/scripts/forge/gitea/pr-list.sh index a6deac289..73c84f4be 100755 --- a/packages/codev/scripts/forge/gitea/pr-list.sh +++ b/packages/codev/scripts/forge/gitea/pr-list.sh @@ -20,8 +20,13 @@ # .requested_reviewers[].login -> reviewRequests (user logins; teams have no login → dropped) # .draft -> isDraft # reviewDecision -> "" (Gitea has no GitHub-equivalent review-decision summary) -REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" -tea api "repos/${REPO}/pulls?state=open&limit=200" \ +# +# The open-pulls list is paginated (Gitea caps a page at max_response_items, +# default 50), so tea_api_paged walks every page rather than silently truncating +# at ~50 open PRs (see _lib.sh). +. "$(dirname "$0")/_lib.sh" +REPO="$(gitea_repo)" || exit 1 +tea_api_paged "repos/${REPO}/pulls" "state=open" \ | jq '[.[] | { number, title, diff --git a/packages/codev/scripts/forge/gitea/pr-view.sh b/packages/codev/scripts/forge/gitea/pr-view.sh index 0cde02fa9..faf085d15 100755 --- a/packages/codev/scripts/forge/gitea/pr-view.sh +++ b/packages/codev/scripts/forge/gitea/pr-view.sh @@ -10,7 +10,8 @@ # 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). -REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" +. "$(dirname "$0")/_lib.sh" +REPO="$(gitea_repo)" || exit 1 tea api "repos/${REPO}/pulls/${CODEV_PR_NUMBER}" | jq '{ title, body: (.body // ""), diff --git a/packages/codev/scripts/forge/gitea/recently-merged.sh b/packages/codev/scripts/forge/gitea/recently-merged.sh index 3cd9e083c..7f134ce47 100755 --- a/packages/codev/scripts/forge/gitea/recently-merged.sh +++ b/packages/codev/scripts/forge/gitea/recently-merged.sh @@ -12,8 +12,13 @@ # 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). -REPO="${CODEV_REPO:-$(git remote get-url origin 2>/dev/null | sed -E -e 's#\.git$##' -e 's#.*[/:]([^/]+/[^/]+)$#\1#')}" -tea api "repos/${REPO}/pulls?state=closed&limit=200" \ +# +# The closed-pulls list is paginated (Gitea caps a page at max_response_items, +# default 50), so on a busy repo the most-recent merges could push older ones +# past the first page — 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=closed" \ | jq '[.[] | select(.merged == true) | { number, title, diff --git a/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts b/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts index a980a4d47..1e61b6b99 100644 --- a/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts +++ b/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts @@ -8,6 +8,16 @@ * passthrough returns exactly the Gitea REST shape the jq normalizers and * `forge-contracts.ts` already assume. * + * PR #1146 review follow-up: + * - list reads (`pr-exists`, `pr-list`, `recently-merged`) now PAGINATE via + * the shared `tea_api_paged` helper (Gitea caps a page at max_response_items, + * default 50, so `&limit=200` silently truncated). The fake `tea` below + * serves a full 50-item page 1 + a short page 2 and the tests assert an item + * that only exists on page 2 is found. + * - the `owner/repo` derivation is factored into `_lib.sh#gitea_repo` and fails + * fast (stderr + non-zero exit) when there's no usable origin remote. + * - `issue-view` warns on stderr when the comments fetch degrades to []. + * * `tea` isn't available in CI (see the in-repo #920 note), so this test stubs a * fake `tea` on PATH that answers `api ` (and `comments add`) with * captured Gitea REST fixtures, points the scripts at a throwaway git repo with @@ -25,7 +35,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; const giteaDir = resolve(__dirname, '..', '..', 'scripts', 'forge', 'gitea'); @@ -33,6 +43,10 @@ const giteaDir = resolve(__dirname, '..', '..', 'scripts', 'forge', 'gitea'); // fixed scripts use) plus `comments add`. Each endpoint returns the raw Gitea // REST shape — nested objects, real `.merged`/`.merged_at`/`.draft`, integer // `comments` count on the issue object, etc. +// +// The paginated list endpoints (page=1 full at limit 50, page=2 short) prove the +// scripts walk past the server's page cap: each carries a "signature" item plus +// filler, and a distinct item that lives ONLY on page 2. const FAKE_TEA = `#!/bin/sh if [ "$1" = "comments" ] && [ "$2" = "add" ]; then # comments add @@ -45,16 +59,42 @@ case "$2" in echo '{"login":"octo","id":7}' ;; repos/acme/widgets/pulls/42) echo '{"number":42,"title":"Add widget","body":"PR body","state":"open","user":{"login":"alice"},"base":{"ref":"main"},"head":{"ref":"feature/x"},"additions":10,"deletions":3}' ;; - "repos/acme/widgets/pulls?state=open&limit=200") - echo '[{"number":42,"title":"Add widget","html_url":"https://git.example.com/acme/widgets/pulls/42","url":"https://git.example.com/api/v1/repos/acme/widgets/pulls/42","body":"PR body","state":"open","created_at":"2026-07-01T10:00:00Z","user":{"login":"alice"},"requested_reviewers":[{"login":"bob"},{"login":null}],"draft":true}]' ;; - "repos/acme/widgets/pulls?state=all&limit=200") - echo '[{"number":42,"state":"open","merged":false,"head":{"ref":"feature/x"}},{"number":40,"state":"closed","merged":true,"head":{"ref":"feature/done"}},{"number":39,"state":"closed","merged":false,"head":{"ref":"feature/abandoned"}}]' ;; - "repos/acme/widgets/pulls?state=closed&limit=200") - echo '[{"number":40,"title":"Done PR","html_url":"https://git.example.com/acme/widgets/pulls/40","body":"merged body","state":"closed","merged":true,"merged_at":"2026-07-05T12:00:00Z","created_at":"2026-07-02T09:00:00Z","head":{"ref":"feature/done"}},{"number":39,"title":"Abandoned","state":"closed","merged":false,"head":{"ref":"feature/abandoned"}}]' ;; + + # --- pr-exists: state=all, paginated ------------------------------------- + # page 1 = 50 items (open feature/x, merged feature/done, closed-not-merged + # feature/abandoned, + 47 open pad). page 2 = 1 merged item on feature/deep. + "repos/acme/widgets/pulls?state=all&limit=50&page=1") + jq -cn '[{number:42,state:"open",merged:false,head:{ref:"feature/x"}},{number:40,state:"closed",merged:true,head:{ref:"feature/done"}},{number:39,state:"closed",merged:false,head:{ref:"feature/abandoned"}}] + [range(47)|{number:(1000+.),state:"open",merged:false,head:{ref:("pad-"+(.|tostring))}}]' ;; + "repos/acme/widgets/pulls?state=all&limit=50&page=2") + echo '[{"number":900,"state":"closed","merged":true,"head":{"ref":"feature/deep"}}]' ;; + + # --- pr-list: state=open, paginated -------------------------------------- + # page 1 = the rich #42 item + 49 pad (50 total). page 2 = 1 item (#900). + "repos/acme/widgets/pulls?state=open&limit=50&page=1") + jq -cn '[{number:42,title:"Add widget",html_url:"https://git.example.com/acme/widgets/pulls/42",url:"https://git.example.com/api/v1/repos/acme/widgets/pulls/42",body:"PR body",state:"open",created_at:"2026-07-01T10:00:00Z",user:{login:"alice"},requested_reviewers:[{login:"bob"},{login:null}],draft:true}] + [range(49)|{number:(1000+.),title:"pad",html_url:"u",body:"",state:"open",created_at:"d",user:{login:"pad"},requested_reviewers:[],draft:false}]' ;; + "repos/acme/widgets/pulls?state=open&limit=50&page=2") + echo '[{"number":900,"title":"Deep open PR","html_url":"https://git.example.com/acme/widgets/pulls/900","body":"deep","state":"open","created_at":"2026-07-01T11:00:00Z","user":{"login":"erin"},"requested_reviewers":[],"draft":false}]' ;; + + # --- recently-merged: state=closed, paginated ---------------------------- + # page 1 = 50 items, only #40 merged (the rest merged:false pad). page 2 = 1 + # merged item (#901) — so a merged PR beyond page 1 must still surface. + "repos/acme/widgets/pulls?state=closed&limit=50&page=1") + jq -cn '[{number:40,title:"Done PR",html_url:"https://git.example.com/acme/widgets/pulls/40",body:"merged body",state:"closed",merged:true,merged_at:"2026-07-05T12:00:00Z",created_at:"2026-07-02T09:00:00Z",head:{ref:"feature/done"}},{number:39,title:"Abandoned",state:"closed",merged:false,head:{ref:"feature/abandoned"}}] + [range(48)|{number:(2000+.),title:"pad",state:"closed",merged:false,head:{ref:"pad"}}]' ;; + "repos/acme/widgets/pulls?state=closed&limit=50&page=2") + echo '[{"number":901,"title":"Deep merge","html_url":"https://git.example.com/acme/widgets/pulls/901","body":"deep merged","state":"closed","merged":true,"merged_at":"2026-07-06T12:00:00Z","created_at":"2026-07-03T09:00:00Z","head":{"ref":"feature/deep-merge"}}]' ;; + + # --- issue-view ---------------------------------------------------------- repos/acme/widgets/issues/99) echo '{"number":99,"title":"Bug here","body":"issue body","state":"open","html_url":"https://git.example.com/acme/widgets/issues/99","url":"https://git.example.com/api/v1/repos/acme/widgets/issues/99","comments":2}' ;; repos/acme/widgets/issues/99/comments) echo '[{"body":"On it! Working on a fix now.","created_at":"2026-07-06T08:00:00Z","user":{"login":"carol"}},{"body":"second","created_at":"2026-07-06T09:00:00Z","user":{"login":"dave"}}]' ;; + # issue 98: the issue object fetches fine but its comments endpoint fails, + # exercising the degraded (stderr-warned) []-comments path. + repos/acme/widgets/issues/98) + echo '{"number":98,"title":"No comments reachable","body":"body","state":"open","html_url":"https://git.example.com/acme/widgets/issues/98","comments":5}' ;; + repos/acme/widgets/issues/98/comments) + echo "fake-tea: comments endpoint down" >&2; exit 7 ;; + *) echo "fake-tea: no fixture for: $2" >&2; exit 4 ;; esac `; @@ -84,6 +124,20 @@ function runScript(name: string, env: Record = {}): string { }).trim(); } +/** Run a script capturing stdout, stderr and exit status (for failure paths). */ +function runScriptFull( + name: string, + env: Record = {}, + cwd: string = repoDir, +): { status: number | null; stdout: string; stderr: string } { + const r = spawnSync('sh', [join(giteaDir, name)], { + cwd, + env: { ...runEnv, ...env }, + encoding: 'utf-8', + }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; +} + describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through `tea api`', () => { beforeAll(() => { fixture = mkdtempSync(join(tmpdir(), 'codev-1137-')); @@ -127,8 +181,8 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` it('pr-list normalizes to PrListItem[] incl. real reviewRequests/isDraft/body', () => { const list = JSON.parse(runScript('pr-list.sh')); - expect(list).toHaveLength(1); - expect(list[0]).toMatchObject({ + const first = list.find((p: { number: number }) => p.number === 42); + expect(first).toMatchObject({ number: 42, title: 'Add widget', url: 'https://git.example.com/acme/widgets/pulls/42', @@ -139,7 +193,13 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` reviewRequests: ['bob'], // null-login (team) reviewers dropped isDraft: true, }); - expect(typeof list[0].number).toBe('number'); + expect(typeof first.number).toBe('number'); + }); + + it('pr-list paginates: a PR only on page 2 still appears (51 total)', () => { + const list = JSON.parse(runScript('pr-list.sh')); + expect(list).toHaveLength(51); // 50 (page 1) + 1 (page 2) + expect(list.some((p: { number: number }) => p.number === 900)).toBe(true); }); it('pr-exists is true for an OPEN pull on the branch', () => { @@ -158,6 +218,12 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'no-such-branch' })).toBe('false'); }); + it('pr-exists paginates: a merged PR only on page 2 is found', () => { + // page 1 is a full 50 items; feature/deep exists ONLY on page 2, so this + // would false-negative (and block a porch pr_exists gate) without paging. + expect(runScript('pr-exists.sh', { CODEV_BRANCH_NAME: 'feature/deep' })).toBe('true'); + }); + it('issue-view returns body, browser url, and comments as an ARRAY', () => { const issue = JSON.parse(runScript('issue-view.sh', { CODEV_ISSUE_ID: '99' })); expect(issue.title).toBe('Bug here'); @@ -174,10 +240,21 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` ]); }); + it('issue-view degrades to [] comments AND warns on stderr when the fetch fails', () => { + // Issue 98's comments endpoint errors. stdout must stay pure JSON with an + // empty array; stderr must carry a trace so [] is distinguishable from + // "genuinely no comments". + const { status, stdout, stderr } = runScriptFull('issue-view.sh', { CODEV_ISSUE_ID: '98' }); + expect(status).toBe(0); + const issue = JSON.parse(stdout); + expect(issue.comments).toEqual([]); + expect(stderr).toContain('comments fetch failed for issue 98'); + }); + it('recently-merged keeps merged pulls only and uses merged_at', () => { const merged = JSON.parse(runScript('recently-merged.sh')); - expect(merged).toHaveLength(1); - expect(merged[0]).toEqual({ + const done = merged.find((p: { number: number }) => p.number === 40); + expect(done).toEqual({ number: 40, title: 'Done PR', url: 'https://git.example.com/acme/widgets/pulls/40', @@ -186,6 +263,19 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` mergedAt: '2026-07-05T12:00:00Z', headRefName: 'feature/done', }); + // closed-not-merged pulls are excluded. + expect(merged.some((p: { number: number }) => p.number === 39)).toBe(false); + }); + + it('recently-merged paginates: a merged PR only on page 2 is included', () => { + const merged = JSON.parse(runScript('recently-merged.sh')); + expect(merged).toHaveLength(2); // #40 (page 1) + #901 (page 2) + const deep = merged.find((p: { number: number }) => p.number === 901); + expect(deep).toMatchObject({ + number: 901, + mergedAt: '2026-07-06T12:00:00Z', + headRefName: 'feature/deep-merge', + }); }); it('issue-comment uses `tea comments add` and exits 0', () => { @@ -211,4 +301,39 @@ describe.skipIf(!jqAvailable)('bugfix #1137: gitea preset routes reads through ` rmSync(other, { recursive: true, force: true }); } }); + + it('fails fast (non-zero + stderr naming CODEV_REPO) with no usable origin remote', () => { + const bare = mkdtempSync(join(tmpdir(), 'codev-1137-noremote-')); + try { + execFileSync('git', ['init', '-q'], { cwd: bare }); + // No origin remote at all. + const { status, stdout, stderr } = runScriptFull( + 'pr-exists.sh', + { CODEV_BRANCH_NAME: 'feature/x' }, + bare, + ); + expect(status).not.toBe(0); + expect(stdout.trim()).toBe(''); + expect(stderr).toContain('CODEV_REPO'); + } finally { + rmSync(bare, { recursive: true, force: true }); + } + }); + + it('fails fast with a garbage origin URL that has no owner/repo', () => { + const garbage = mkdtempSync(join(tmpdir(), 'codev-1137-garbage-')); + try { + execFileSync('git', ['init', '-q'], { cwd: garbage }); + execFileSync('git', ['remote', 'add', 'origin', 'https://example.com/'], { cwd: garbage }); + const { status, stderr } = runScriptFull( + 'issue-view.sh', + { CODEV_ISSUE_ID: '99' }, + garbage, + ); + expect(status).not.toBe(0); + expect(stderr).toContain('CODEV_REPO'); + } finally { + rmSync(garbage, { recursive: true, force: true }); + } + }); });