fix(ci): replace gh pr CLI with Gitea v1 REST in workflows + scripts (#80)
All checks were successful
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 8s
Auto-sync main → staging / sync-staging (push) Successful in 24s
Block internal-flavored paths / Block forbidden paths (push) Successful in 22s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (push) Successful in 22s
CI / Detect changes (push) Successful in 21s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 16s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 20s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 15s
E2E API Smoke Test / detect-changes (push) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 18s
Handlers Postgres Integration / detect-changes (push) Successful in 17s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
auto-tag-runtime / tag (push) Successful in 42s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 15s
CI / Platform (Go) (push) Successful in 10s
CI / Python Lint & Test (push) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
CI / Canvas (Next.js) (push) Successful in 12s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 12s
CI / Shellcheck (E2E scripts) (push) Successful in 20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 10s
Ops Scripts Tests / Ops scripts (unittest) (push) Successful in 44s
CI / Canvas Deploy Reminder (push) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 16s
publish-workspace-server-image / build-and-push (push) Successful in 2m18s

Class A of #75 sweep. 23 bash + 9 python tests pass. Live-integration verified against prod Gitea. Approved by security-auditor.
This commit is contained in:
claude-ceo-assistant 2026-05-07 23:39:22 +00:00
commit e39fc92074
3 changed files with 277 additions and 37 deletions

View File

@ -57,17 +57,42 @@ jobs:
id: bump id: bump
if: steps.skip.outputs.skip != 'true' if: steps.skip.outputs.skip != 'true'
env: env:
GH_TOKEN: ${{ github.token }} # Gitea-shape token (act_runner forwards GITHUB_TOKEN as a
# short-lived per-run secret with read access to this repo).
# We hit `/api/v1/repos/.../pulls?state=closed` directly
# because `gh pr list` calls Gitea's GraphQL endpoint, which
# returns HTTP 405 (issue #75 / post-#66 sweep).
GITEA_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
GITEA_API_URL: ${{ github.server_url }}/api/v1
PUSH_SHA: ${{ github.sha }}
run: | run: |
# The merged PR for this push commit. `gh pr list --search` finds # Find the merged PR whose merge_commit_sha matches this push.
# closed PRs whose merge commit matches; we take the first. # Gitea's `/repos/{owner}/{repo}/pulls?state=closed` returns
PR=$(gh pr list --state merged --search "${{ github.sha }}" --json number,labels --jq '.[0]' 2>/dev/null || echo "") # PRs sorted newest-first; we paginate up to 50 and jq-filter
# on `merge_commit_sha == PUSH_SHA`. Bounded — auto-tag fires
# per push to main, so the matching PR is always among the
# most recent closures. 50 is comfortably more than the
# ~10-20 staging→main promotes that close in any reasonable
# window.
set -euo pipefail
PRS_JSON=$(curl --fail-with-body -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"${GITEA_API_URL}/repos/${REPO}/pulls?state=closed&sort=newest&limit=50" \
2>/dev/null || echo "[]")
PR=$(printf '%s' "$PRS_JSON" \
| jq -c --arg sha "$PUSH_SHA" \
'[.[] | select(.merged_at != null and .merge_commit_sha == $sha)] | .[0] // empty')
if [ -z "$PR" ] || [ "$PR" = "null" ]; then if [ -z "$PR" ] || [ "$PR" = "null" ]; then
echo "No merged PR found for ${{ github.sha }} — defaulting to patch bump." echo "No merged PR found for ${PUSH_SHA} — defaulting to patch bump."
echo "kind=patch" >> "$GITHUB_OUTPUT" echo "kind=patch" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
LABELS=$(echo "$PR" | jq -r '.labels[].name') # Gitea returns labels under `.labels[].name`, same shape as
# GitHub's REST. The previous `gh pr list --json number,labels`
# output was identical; jq filter unchanged.
LABELS=$(printf '%s' "$PR" | jq -r '.labels[]?.name // empty')
if echo "$LABELS" | grep -qx 'release:major'; then if echo "$LABELS" | grep -qx 'release:major'; then
echo "kind=major" >> "$GITHUB_OUTPUT" echo "kind=major" >> "$GITHUB_OUTPUT"
elif echo "$LABELS" | grep -qx 'release:minor'; then elif echo "$LABELS" | grep -qx 'release:minor'; then

View File

@ -17,12 +17,23 @@
# #
# Used by .github/workflows/auto-promote-stale-alarm.yml. Logic lives # Used by .github/workflows/auto-promote-stale-alarm.yml. Logic lives
# here (not inline in the workflow YAML) so we can: # here (not inline in the workflow YAML) so we can:
# - Unit-test it with a stubbed `gh` (see test-check-stale-promote-pr.sh) # - Unit-test it with a fixture (see test-check-stale-promote-pr.sh)
# - Run it ad-hoc by an operator: `scripts/check-stale-promote-pr.sh` # - Run it ad-hoc by an operator: `scripts/check-stale-promote-pr.sh`
# - Reuse the same surface in any sibling workflow that needs the same # - Reuse the same surface in any sibling workflow that needs the same
# check (SSOT — one detector, many callers). # check (SSOT — one detector, many callers).
# #
# Requires: `gh` CLI, `jq`. `GH_TOKEN` env in the workflow context. # Requires: `curl`, `jq`. `GITEA_TOKEN` (or `GITHUB_TOKEN` / `GH_TOKEN`
# for back-compat) in the workflow context. Reads `GITHUB_SERVER_URL`
# / `GITEA_API_URL` for the Gitea base, defaulting to
# https://git.moleculesai.app/api/v1.
#
# Post-2026-05-06 (Gitea migration, issue #75): the previous version
# called `gh pr list/view/comment`, all of which hit GitHub.com's
# GraphQL or /api/v3 REST shapes. Gitea exposes /api/v1/ only (no
# GraphQL → 405, no /api/v3 → 404). So this script now talks to the
# Gitea v1 API directly via curl. The fixture-driven unit tests are
# unchanged — they bypass the live fetch via PR_FIXTURE and still pass
# the historical (GitHub-shape) JSON which `detect_stale` consumes.
set -euo pipefail set -euo pipefail
@ -36,14 +47,15 @@ set -euo pipefail
# alarming. Override via env for tests + edge ops. # alarming. Override via env for tests + edge ops.
STALE_HOURS="${STALE_HOURS:-4}" STALE_HOURS="${STALE_HOURS:-4}"
# Repo defaults to the current `gh` context. Tests pass --repo explicitly. # Repo defaults to GITHUB_REPOSITORY (act_runner sets this in workflow
# context). Tests pass --repo explicitly.
REPO="${GITHUB_REPOSITORY:-}" REPO="${GITHUB_REPOSITORY:-}"
# Whether to post a comment to the PR. Off by default to avoid noise on # Whether to post a comment to the PR. Off by default to avoid noise on
# manual ad-hoc runs; the cron workflow turns it on. # manual ad-hoc runs; the cron workflow turns it on.
POST_COMMENT="${POST_COMMENT:-false}" POST_COMMENT="${POST_COMMENT:-false}"
# Where to read the open-PR JSON from. Empty = call `gh` live. Tests # Where to read the open-PR JSON from. Empty = call Gitea live. Tests
# point this at a fixture file. # point this at a fixture file.
PR_FIXTURE="${PR_FIXTURE:-}" PR_FIXTURE="${PR_FIXTURE:-}"
@ -51,6 +63,17 @@ PR_FIXTURE="${PR_FIXTURE:-}"
# the staleness math is deterministic. # the staleness math is deterministic.
NOW_OVERRIDE="${NOW_OVERRIDE:-}" NOW_OVERRIDE="${NOW_OVERRIDE:-}"
# Gitea API base. act_runner forwards github.server_url as
# GITHUB_SERVER_URL; for the molecule-ai fleet that's
# https://git.moleculesai.app. Append /api/v1 to get the REST root.
# Override directly via GITEA_API_URL for tests / non-default hosts.
GITEA_API_URL="${GITEA_API_URL:-${GITHUB_SERVER_URL:-https://git.moleculesai.app}/api/v1}"
# Token. Workflow context sets GITHUB_TOKEN; we accept GITEA_TOKEN as
# the explicit name and GH_TOKEN for back-compat with operator habits
# from the GitHub era. First non-empty wins.
GITEA_TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-${GH_TOKEN:-}}}"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--repo) REPO="$2"; shift 2 ;; --repo) REPO="$2"; shift 2 ;;
@ -83,7 +106,7 @@ now_epoch() {
fi fi
} }
# Parse RFC3339 timestamps the way GitHub emits them (e.g. # Parse RFC3339 timestamps the way Gitea / GitHub emit them (e.g.
# "2026-05-05T23:15:00Z"). gnu-date uses -d, bsd-date uses -j -f. Cover # "2026-05-05T23:15:00Z"). gnu-date uses -d, bsd-date uses -j -f. Cover
# both because the workflow runs on ubuntu-latest (gnu) but operators # both because the workflow runs on ubuntu-latest (gnu) but operators
# may run this script on macOS (bsd). # may run this script on macOS (bsd).
@ -106,14 +129,100 @@ to_epoch() {
# Fetch open auto-promote PRs # Fetch open auto-promote PRs
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Gitea v1 returns PRs with the canonical Gitea shape (number, title,
# created_at, html_url, mergeable, state). The previous GitHub-CLI
# version returned a derived `mergeStateStatus` / `reviewDecision`
# pair which only GitHub computes — Gitea doesn't expose them
# natively. Rebuild equivalents:
#
# mergeStateStatus = BLOCKED ↔ Gitea: state==open AND mergeable==true
# AND no APPROVED review yet
# (i.e. branch protection is gating
# the auto-merge pending an approval)
# reviewDecision = REVIEW_REQUIRED ↔ Gitea: 0 APPROVED reviews
#
# This mirrors the SAME silent-block failure mode the GitHub version
# detected: auto-merge armed, branch protection requires 1 review,
# nobody's approved yet.
#
# Implementation: pull the open PR list base=main, then for each PR
# pull /pulls/{n}/reviews and synthesize the GitHub-shape JSON the
# rest of the script + the test fixtures consume.
fetch_prs() { fetch_prs() {
if [ -n "$PR_FIXTURE" ]; then if [ -n "$PR_FIXTURE" ]; then
cat "$PR_FIXTURE" cat "$PR_FIXTURE"
return 0 return 0
fi fi
gh pr list --repo "$REPO" \ if [ -z "$GITEA_TOKEN" ]; then
--base main --head staging --state open \ echo "::error::GITEA_TOKEN / GITHUB_TOKEN unset — cannot fetch PRs from $GITEA_API_URL" >&2
--json number,title,createdAt,mergeStateStatus,reviewDecision,url return 1
fi
local prs_json
prs_json="$(curl --fail-with-body -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"${GITEA_API_URL}/repos/${REPO}/pulls?state=open&base=main&limit=50" \
2>/dev/null)" || {
echo "::error::Failed to fetch PRs from ${GITEA_API_URL}/repos/${REPO}/pulls" >&2
return 1
}
# Filter to head=staging (the auto-promote shape) and synthesize
# mergeStateStatus + reviewDecision per PR. Approval count via
# /pulls/{n}/reviews. Errors fall through to 0-approvals (treated
# as REVIEW_REQUIRED) preserving the existing "fail-safe — alarm if
# uncertain" semantic.
local synthesized="[]"
while IFS= read -r pr; do
[ -z "$pr" ] && continue
[ "$pr" = "null" ] && continue
local num
num="$(printf '%s' "$pr" | jq -r '.number')"
[ -z "$num" ] && continue
[ "$num" = "null" ] && continue
local approved_count
approved_count="$(curl --fail-with-body -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"${GITEA_API_URL}/repos/${REPO}/pulls/${num}/reviews" 2>/dev/null \
| jq '[.[] | select(.state == "APPROVED" and (.dismissed // false) == false)] | length' \
2>/dev/null || echo 0)"
local mergeable
mergeable="$(printf '%s' "$pr" | jq -r '.mergeable')"
local merge_state="UNKNOWN"
local review_decision="REVIEW_REQUIRED"
if [ "$mergeable" = "true" ]; then
if [ "$approved_count" -ge 1 ]; then
merge_state="CLEAN"
review_decision="APPROVED"
else
# mergeable but no approving review — exactly the wedge state
# the alarm targets.
merge_state="BLOCKED"
review_decision="REVIEW_REQUIRED"
fi
else
# not mergeable (conflicts, behind, failed checks) — different
# failure mode, the author owns the fix; the alarm doesn't fire.
merge_state="DIRTY"
review_decision="REVIEW_REQUIRED"
fi
synthesized="$(printf '%s' "$synthesized" \
| jq -c --argjson pr "$pr" \
--arg ms "$merge_state" \
--arg rd "$review_decision" \
'. + [{
number: $pr.number,
title: $pr.title,
createdAt: $pr.created_at,
mergeStateStatus: $ms,
reviewDecision: $rd,
url: $pr.html_url
}]')"
done < <(printf '%s' "$prs_json" \
| jq -c '.[] | select(.head.ref == "staging")' 2>/dev/null)
printf '%s\n' "$synthesized"
} }
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -171,18 +280,40 @@ post_comment() {
if [ "$POST_COMMENT" != "true" ]; then if [ "$POST_COMMENT" != "true" ]; then
return 0 return 0
fi fi
if [ -z "$GITEA_TOKEN" ]; then
echo "::warning::GITEA_TOKEN unset — cannot post stale-alarm comment on PR #$pr_num" >&2
return 0
fi
# Idempotency: only one alarm comment per PR. Look for the marker # Idempotency: only one alarm comment per PR. Look for the marker
# string in existing comments before posting a new one. # string in existing comments before posting a new one. Gitea's
# /repos/{owner}/{repo}/issues/{n}/comments returns the same shape
# for issues + PRs (PRs are issues internally on Gitea, same as
# GitHub's REST).
local existing local existing
existing="$(gh pr view "$pr_num" --repo "$REPO" --json comments \ existing="$(curl --fail-with-body -sS \
--jq '.comments[] | select(.body | test("scripts/check-stale-promote-pr.sh per issue #2975")) | .databaseId' \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
"${GITEA_API_URL}/repos/${REPO}/issues/${pr_num}/comments?limit=50" 2>/dev/null \
| jq -r '.[] | select(.body | test("scripts/check-stale-promote-pr.sh per issue #2975")) | .id' \
| head -n1)" | head -n1)"
if [ -n "$existing" ]; then if [ -n "$existing" ]; then
echo "::notice::PR #$pr_num already has a stale-alarm comment ($existing) — not re-posting" echo "::notice::PR #$pr_num already has a stale-alarm comment ($existing) — not re-posting"
return 0 return 0
fi fi
comment_body "$age_h" | gh pr comment "$pr_num" --repo "$REPO" --body-file - local body
echo "::notice::Posted stale-alarm comment on PR #$pr_num (age=${age_h}h)" body="$(comment_body "$age_h")"
if curl --fail-with-body -sS \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
"${GITEA_API_URL}/repos/${REPO}/issues/${pr_num}/comments" \
-d "$(jq -nc --arg b "$body" '{body: $b}')" \
>/dev/null 2>&1; then
echo "::notice::Posted stale-alarm comment on PR #$pr_num (age=${age_h}h)"
else
echo "::warning::Failed to POST stale-alarm comment on PR #$pr_num" >&2
fi
} }
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@ -19,9 +19,15 @@ Exit codes:
0 no collisions 0 no collisions
1 collision detected; output names the conflicting PR(s) for the author 1 collision detected; output names the conflicting PR(s) for the author
Designed to run from a GitHub Actions PR check. Reads PR metadata via the Designed to run from a Gitea Actions PR check. Reads PR metadata via direct
GitHub CLI (gh) which is preinstalled on ubuntu-latest runners. Runs in HTTP calls to Gitea's REST API (`/api/v1/`), which on the molecule-ai fleet
under 10s against a typical PR. lives at https://git.moleculesai.app. Runs in under 10s against a typical PR.
Post-2026-05-06 (Gitea migration, issue #75): the previous version called
the GitHub CLI (``gh pr list``, ``gh pr diff``). On Gitea those calls hit
either the GraphQL endpoint (HTTP 405) or /api/v3 (HTTP 404). This module
now talks to /api/v1 directly via urllib so it works against any Gitea
host without a `gh` install or extra dependencies.
""" """
from __future__ import annotations from __future__ import annotations
@ -31,12 +37,70 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
MIGRATIONS_DIR = "workspace-server/migrations" MIGRATIONS_DIR = "workspace-server/migrations"
MIGRATION_FILE_RE = re.compile(r"^(\d+)_[^/]+\.(up|down)\.sql$") MIGRATION_FILE_RE = re.compile(r"^(\d+)_[^/]+\.(up|down)\.sql$")
def _gitea_api_url() -> str:
"""Resolve the Gitea API base URL.
act_runner forwards github.server_url as GITHUB_SERVER_URL; for the
molecule-ai fleet that's https://git.moleculesai.app. Append /api/v1
to get the REST root. Override directly via GITEA_API_URL for tests
or non-default hosts.
"""
env_override = os.environ.get("GITEA_API_URL", "").rstrip("/")
if env_override:
return env_override
server = os.environ.get("GITHUB_SERVER_URL", "https://git.moleculesai.app").rstrip("/")
return f"{server}/api/v1"
def _gitea_token() -> str:
"""Resolve the Gitea token from env. GITEA_TOKEN wins; falls back
to GITHUB_TOKEN (set by act_runner) and GH_TOKEN (operator habit
from the GitHub era)."""
return (
os.environ.get("GITEA_TOKEN")
or os.environ.get("GITHUB_TOKEN")
or os.environ.get("GH_TOKEN")
or ""
)
def _gitea_get(path: str, params: dict[str, str] | None = None) -> bytes | None:
"""GET against /api/v1; returns response body or None on HTTP error.
Errors return None (not raise) because callers handle missing data
by emitting an actionable workflow message rather than crashing the
PR check on a transient API blip.
"""
base = _gitea_api_url()
qs = ""
if params:
qs = "?" + urllib.parse.urlencode(params)
url = f"{base}/{path.lstrip('/')}{qs}"
req = urllib.request.Request(url)
token = _gitea_token()
if token:
req.add_header("Authorization", f"token {token}")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
return resp.read()
except urllib.error.HTTPError as e:
sys.stderr.write(f"Gitea API HTTP {e.code} on {path}: {e.reason}\n")
return None
except (urllib.error.URLError, TimeoutError) as e:
sys.stderr.write(f"Gitea API network error on {path}: {e}\n")
return None
def run(cmd: list[str], check: bool = True) -> str: def run(cmd: list[str], check: bool = True) -> str:
"""Run a subprocess and return stdout. Raise on non-zero when check=True.""" """Run a subprocess and return stdout. Raise on non-zero when check=True."""
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
@ -96,32 +160,49 @@ def open_prs_with_migration_prefix(
repo: str, prefix: int, exclude_pr: int repo: str, prefix: int, exclude_pr: int
) -> list[dict]: ) -> list[dict]:
"""Return open PRs (other than `exclude_pr`) that add a migration with """Return open PRs (other than `exclude_pr`) that add a migration with
`prefix`. Uses `gh pr diff` per PR we only need to walk PRs that are `prefix`. Walks open PRs via Gitea's `/repos/{owner}/{repo}/pulls` and
actually in flight, so the cost is bounded by open-PR count. pulls each one's changed-file list via `/pulls/{n}/files`. The cost is
bounded by open-PR count, which is small (<100) on this repo. The
return shape mimics the GitHub CLI's `--json number,headRefName`:
``[{"number": int, "headRefName": str}, ...]``.
""" """
out = run([ body = _gitea_get(
"gh", "pr", "list", "--repo", repo, "--state", "open", f"repos/{repo}/pulls",
"--json", "number,headRefName", "--limit", "100", {"state": "open", "limit": "50"},
]) )
prs = json.loads(out) if body is None:
# Best-effort: a transient Gitea blip shouldn't fail the PR
# check (the base-branch collision check runs locally and is
# the more common failure mode).
return []
prs = json.loads(body)
matches: list[dict] = [] matches: list[dict] = []
for pr in prs: for pr in prs:
num = pr["number"] num = pr["number"]
if num == exclude_pr: if num == exclude_pr:
continue continue
try: # Gitea returns the head ref under .head.ref (REST shape);
files = run([ # GitHub CLI's --json headRefName flattens it. Normalize on
"gh", "pr", "diff", str(num), "--repo", repo, "--name-only", # the way out so callers see the historical shape.
], check=False) head_ref_name = (pr.get("head") or {}).get("ref", "")
except Exception: # noqa: BLE001 files_body = _gitea_get(f"repos/{repo}/pulls/{num}/files", {"limit": "100"})
if files_body is None:
continue continue
for raw in files.splitlines(): try:
files = json.loads(files_body)
except json.JSONDecodeError:
continue
for f in files:
# Gitea's /pulls/{n}/files returns objects with `.filename`
# (same as GitHub's REST). Older Gitea versions emit
# `.name` instead — handle both.
raw = f.get("filename") or f.get("name") or ""
path = Path(raw.strip()) path = Path(raw.strip())
if not path.name: if not path.name:
continue continue
m = MIGRATION_FILE_RE.match(path.name) m = MIGRATION_FILE_RE.match(path.name)
if m and int(m.group(1)) == prefix: if m and int(m.group(1)) == prefix:
matches.append(pr) matches.append({"number": num, "headRefName": head_ref_name})
break break
return matches return matches
@ -138,7 +219,10 @@ def main() -> int:
pr_number = int(pr_number_env) pr_number = int(pr_number_env)
base_ref = os.environ.get("BASE_REF", "origin/staging") base_ref = os.environ.get("BASE_REF", "origin/staging")
head_ref = os.environ.get("HEAD_REF", "HEAD") head_ref = os.environ.get("HEAD_REF", "HEAD")
repo = os.environ.get("GITHUB_REPOSITORY", "Molecule-AI/molecule-core") # Default kept lowercase to match the Gitea-canonical org name
# (post-2026-05-06 migration). Tests + workflow context override
# via GITHUB_REPOSITORY which act_runner sets per-run.
repo = os.environ.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
added = migrations_in_diff(base_ref, head_ref) added = migrations_in_diff(base_ref, head_ref)
if not added: if not added: