From 03e0750e997882dbf0e5192a8e7446af7f3e36b8 Mon Sep 17 00:00:00 2001 From: pseudo Date: Mon, 6 Jul 2026 13:06:00 -0600 Subject: [PATCH 1/4] 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/4] 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/4] [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/4] [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)', () => {