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..f1b31df65 --- /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: 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' 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-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..b9350d0bf 100755 --- a/packages/codev/scripts/forge/gitea/issue-view.sh +++ b/packages/codev/scripts/forge/gitea/issue-view.sh @@ -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} + } ] + }' diff --git a/packages/codev/scripts/forge/gitea/pr-exists.sh b/packages/codev/scripts/forge/gitea/pr-exists.sh index db8365012..9cc437d3a 100755 --- a/packages/codev/scripts/forge/gitea/pr-exists.sh +++ b/packages/codev/scripts/forge/gitea/pr-exists.sh @@ -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' diff --git a/packages/codev/scripts/forge/gitea/pr-list.sh b/packages/codev/scripts/forge/gitea/pr-list.sh index d320c5cc2..73c84f4be 100755 --- a/packages/codev/scripts/forge/gitea/pr-list.sh +++ b/packages/codev/scripts/forge/gitea/pr-list.sh @@ -1,36 +1,40 @@ #!/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) +# +# 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: (.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..faf085d15 100755 --- a/packages/codev/scripts/forge/gitea/pr-view.sh +++ b/packages/codev/scripts/forge/gitea/pr-view.sh @@ -1,3 +1,24 @@ #!/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). +. "$(dirname "$0")/_lib.sh" +REPO="$(gitea_repo)" || exit 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..7f134ce47 100755 --- a/packages/codev/scripts/forge/gitea/recently-merged.sh +++ b/packages/codev/scripts/forge/gitea/recently-merged.sh @@ -1,27 +1,30 @@ #!/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). +# +# 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: (.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" 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..1e61b6b99 --- /dev/null +++ b/packages/codev/src/__tests__/bugfix-1137-gitea-tea-api.test.ts @@ -0,0 +1,339 @@ +/** + * 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. + * + * 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 + * 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, spawnSync } 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. +// +// 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 + 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}' ;; + + # --- 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 +`; + +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(); +} + +/** 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-')); + 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')); + 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', + reviewDecision: '', + body: 'PR body', + createdAt: '2026-07-01T10:00:00Z', + author: { login: 'alice' }, + reviewRequests: ['bob'], // null-login (team) reviewers dropped + isDraft: true, + }); + 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', () => { + 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('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'); + 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('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')); + 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', + body: 'merged body', + createdAt: '2026-07-02T09:00:00Z', + 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', () => { + // 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 }); + } + }); + + 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 }); + } + }); +}); 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)', () => {