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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
id: bugfix-1137
title: gitea-forge-preset-is-broken-a
protocol: bugfix
phase: fix
plan_phases: []
current_plan_phase: null
gates:
merge-approval:
status: pending
iteration: 1
build_complete: false
history: []
started_at: '2026-07-06T19:06:00.466Z'
updated_at: '2026-07-06T19:12:31.039Z'
76 changes: 76 additions & 0 deletions packages/codev/scripts/forge/gitea/_lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Shared helpers for the Gitea forge preset scripts.
#
# This file is SOURCED, not executed (`. "$(dirname "$0")/_lib.sh"`), so it has
# no shebang and defines only functions/vars. POSIX sh only — no bashisms — the
# scripts are #!/bin/sh and forge runs them via `sh -c`. It is not a forge
# concept: forge.ts builds presets from an explicit KNOWN_CONCEPTS allowlist, so
# a leading-underscore file in this directory is never registered as a concept.

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

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

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

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