Compare commits

..

2 Commits

Author SHA1 Message Date
Hermes Agent
5cd6c83b9b fix git pr metadata tmpfile cleanup
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-06-18 16:16:34 -05:00
Hermes Agent
b0b2c20da0 fix(framework/tools): eval injection, broken JSON, tmpfile leak (#548)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
F-01 (HIGH): issue-edit.sh and issue-assign.sh used string interpolation
+ eval to build CLI commands. Replace all eval sites with Bash arrays so
user-supplied values (title, body, labels) are never shell-expanded.
For the Gitea path, replace get_gitea_repo_args() (which emits %q-escaped
strings designed for eval) with get_repo_slug() + get_gitea_login() so
repo/login are passed as properly-quoted array elements.

F-07 (MED): milestone-create.sh built the GitHub API JSON payload by
string interpolation — a title containing " or $ broke the JSON. Rebuild
with jq -n --arg so all values are safely serialised. Optional description
key is omitted when empty, preserving existing behaviour.

F-13 (LOW): pr-metadata.sh created a mktemp tmpfile inside
curl_gitea_pull() but only removed it in success paths. Add
trap 'rm -f "$body_file"' EXIT immediately after mktemp so early-exit
paths (set -e, SIGINT) also clean up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt2D8TsnDwhtzEAPijsNmR
2026-06-18 13:51:18 -05:00
12 changed files with 252 additions and 150 deletions

View File

@@ -51,3 +51,48 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).` - PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`. - PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean. - CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
## 2026-06-18 — PR #549 functional blocker remediation
### Assignment
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
### Plan
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
2. Prove the new test is RED against the current PR head.
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
### Constraints / assumptions
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
### Remediation results
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
- GREEN evidence:
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
### Review remediation
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
### Second review remediation
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
### Final review gate
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).

View File

@@ -16,12 +16,7 @@
# After loading, service-specific env vars are exported. # After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details. # Run `load_credentials --help` for details.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
fi
_mosaic_require_jq() { _mosaic_require_jq() {
if ! command -v jq &>/dev/null; then if ! command -v jq &>/dev/null; then
@@ -39,19 +34,6 @@ _mosaic_read_cred() {
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE" jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
} }
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
_mosaic_tls_opt() {
local url="$1" host
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
echo "-k"; return
fi
echo ""
}
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env # Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
# Only writes when values differ to avoid unnecessary disk writes. # Only writes when values differ to avoid unnecessary disk writes.
_mosaic_sync_woodpecker_env() { _mosaic_sync_woodpecker_env() {
@@ -279,8 +261,7 @@ mosaic_http() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sk -w "\n%{http_code}" -X "$method" \
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${base_url}${endpoint}") "${base_url}${endpoint}")
@@ -298,8 +279,7 @@ mosaic_http_post() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sk -w "\n%{http_code}" -X POST \
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \
@@ -317,8 +297,7 @@ mosaic_http_patch() {
local base_url="${4:-}" local base_url="${4:-}"
local response local response
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}") response=$(curl -sk -w "\n%{http_code}" -X PATCH \
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
-H "$auth_header" \ -H "$auth_header" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$data" \ -d "$data" \

View File

@@ -98,27 +98,32 @@ case "$PLATFORM" in
;; ;;
gitea) gitea)
# tea issue edit syntax # tea issue edit syntax
REPO_ARGS=$(get_gitea_repo_args) || { REPO_SLUG=$(get_repo_slug) || {
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2 echo "Error: Could not resolve Gitea repo slug from remote" >&2
exit 1 exit 1
} }
CMD="tea issue edit $ISSUE $REPO_ARGS" REPO_LOGIN=$(get_gitea_login) || {
echo "Error: Could not resolve Gitea login for remote host" >&2
exit 1
}
REPO_ARGS=(--repo "$REPO_SLUG" --login "$REPO_LOGIN")
CMD=(tea issue edit "$ISSUE" "${REPO_ARGS[@]}")
NEEDS_EDIT=false NEEDS_EDIT=false
if [[ -n "$ASSIGNEE" ]]; then if [[ -n "$ASSIGNEE" ]]; then
# tea uses --assignees flag # tea uses --assignees flag
CMD="$CMD --assignees \"$ASSIGNEE\"" CMD+=(--assignees "$ASSIGNEE")
NEEDS_EDIT=true NEEDS_EDIT=true
fi fi
if [[ -n "$LABELS" ]]; then if [[ -n "$LABELS" ]]; then
# tea uses --labels flag (replaces existing) # tea uses --labels flag (replaces existing)
CMD="$CMD --labels \"$LABELS\"" CMD+=(--labels "$LABELS")
NEEDS_EDIT=true NEEDS_EDIT=true
fi fi
if [[ -n "$MILESTONE" ]]; then if [[ -n "$MILESTONE" ]]; then
MILESTONE_ID=$(tea milestones list $REPO_ARGS 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1) MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
if [[ -n "$MILESTONE_ID" ]]; then if [[ -n "$MILESTONE_ID" ]]; then
CMD="$CMD --milestone $MILESTONE_ID" CMD+=(--milestone "$MILESTONE_ID")
NEEDS_EDIT=true NEEDS_EDIT=true
else else
echo "Warning: Could not find milestone '$MILESTONE'" >&2 echo "Warning: Could not find milestone '$MILESTONE'" >&2
@@ -126,7 +131,7 @@ case "$PLATFORM" in
fi fi
if [[ "$NEEDS_EDIT" == true ]]; then if [[ "$NEEDS_EDIT" == true ]]; then
eval "$CMD" "${CMD[@]}"
echo "Issue #$ISSUE updated successfully" echo "Issue #$ISSUE updated successfully"
else else
echo "No changes specified" echo "No changes specified"

View File

@@ -63,24 +63,28 @@ fi
detect_platform >/dev/null detect_platform >/dev/null
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
CMD="gh issue edit $ISSUE_NUMBER" CMD=(gh issue edit "$ISSUE_NUMBER")
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\"" [[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\"" [[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$LABELS" ]] && CMD="$CMD --add-label \"$LABELS\"" [[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\"" [[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
eval $CMD "${CMD[@]}"
echo "Updated GitHub issue #$ISSUE_NUMBER" echo "Updated GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then elif [[ "$PLATFORM" == "gitea" ]]; then
REPO_ARGS=$(get_gitea_repo_args) || { REPO_SLUG=$(get_repo_slug) || {
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2 echo "Error: Could not resolve Gitea repo slug from remote" >&2
exit 1 exit 1
} }
CMD="tea issue edit $ISSUE_NUMBER $REPO_ARGS" REPO_LOGIN=$(get_gitea_login) || {
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\"" echo "Error: Could not resolve Gitea login for remote host" >&2
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\"" exit 1
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\"" }
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\"" CMD=(tea issue edit "$ISSUE_NUMBER" --repo "$REPO_SLUG" --login "$REPO_LOGIN")
eval $CMD [[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
"${CMD[@]}"
echo "Updated Gitea issue #$ISSUE_NUMBER" echo "Updated Gitea issue #$ISSUE_NUMBER"
else else
echo "Error: Unknown platform" echo "Error: Unknown platform"

View File

@@ -99,10 +99,15 @@ fi
case "$PLATFORM" in case "$PLATFORM" in
github) github)
# GitHub uses the API for milestone creation # GitHub uses the API for milestone creation
JSON_PAYLOAD="{\"title\":\"$TITLE\"" # Use jq to safely construct JSON so titles/descriptions containing
[[ -n "$DESCRIPTION" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"description\":\"$DESCRIPTION\"" # quotes or special characters do not corrupt the payload (F-07).
[[ -n "$DUE_DATE" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"due_on\":\"${DUE_DATE}T00:00:00Z\"" JSON_PAYLOAD=$(jq -n \
JSON_PAYLOAD="$JSON_PAYLOAD}" --arg t "$TITLE" \
--arg d "$DESCRIPTION" \
--arg due "${DUE_DATE}" \
'{"title": $t}
+ (if $d != "" then {"description": $d} else {} end)
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD" gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
echo "Milestone '$TITLE' created successfully" echo "Milestone '$TITLE' created successfully"

View File

@@ -72,11 +72,6 @@ elif values and all(v == "success" for v in values):
print("success") print("success")
elif any(v in {"pending", "running", "queued", "waiting"} for v in values): elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
print("pending") print("pending")
elif not values and not state:
# No pipeline/status of any kind reported for this commit. Distinct from
# "unknown" (an ambiguous/unrecognized status that should keep polling):
# this signals a repo/commit that simply has no CI configured.
print("no-status")
else: else:
print("unknown") print("unknown")
PY PY
@@ -147,21 +142,6 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
} }
gitea_get_default_branch() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
print((json.load(sys.stdin) or {}).get("default_branch", ""))
'
}
github_get_default_branch() {
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
}
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
-n|--number) -n|--number)
@@ -265,51 +245,6 @@ else
exit 1 exit 1
fi fi
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
#
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
# BRANCH's commit status. A repo whose default branch carries CI statuses
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
#
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
# (default branch also has no CI history, e.g. device-imaging class), where burning
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
#
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
REPO_HAS_CI=1
detect_repo_ci() {
local def_branch def_status
# Every early exit returns 0: a probe miss must leave the conservative
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
if [[ "$PLATFORM" == "github" ]]; then
def_branch=$(github_get_default_branch 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
else
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
[[ -n "$def_branch" ]] || return 0
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
fi
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
REPO_HAS_CI=0
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
else
REPO_HAS_CI=1
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
fi
}
detect_repo_ci || true
NO_CI_STREAK=0
NO_CI_MAX=3
while true; do while true; do
NOW_TS=$(date +%s) NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then if (( NOW_TS > DEADLINE_TS )); then
@@ -337,35 +272,11 @@ while true; do
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2 echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
exit 1 exit 1
;; ;;
no-status)
if [[ "$REPO_HAS_CI" == "1" ]]; then
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
# polling until it registers or the timeout fires. Reset the streak so
# a later genuine CI-less misread can't accumulate across this state.
NO_CI_STREAK=0
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
else
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
# history either). Empty polls => fast-exit green after NO_CI_MAX.
NO_CI_STREAK=$((NO_CI_STREAK + 1))
if (( NO_CI_STREAK >= NO_CI_MAX )); then
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
exit 0
fi
fi
sleep "$INTERVAL_SEC"
;;
pending|unknown) pending|unknown)
# A pipeline exists but hasn't reached a terminal state (or is
# transiently ambiguous) — keep waiting, and reset the no-CI streak
# since this commit is not in the "no CI at all" condition.
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
*) *)
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..." echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
NO_CI_STREAK=0
sleep "$INTERVAL_SEC" sleep "$INTERVAL_SEC"
;; ;;
esac esac

View File

@@ -57,12 +57,20 @@ curl_gitea_pull() {
local token basic_auth raw_code body_file http_code local token basic_auth raw_code body_file http_code
body_file=$(mktemp) body_file=$(mktemp)
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
cleanup_gitea_pull_body() {
local status=$?
rm -f -- "$body_file"
trap - RETURN
return "$status"
}
trap cleanup_gitea_pull_body RETURN
token=$(get_gitea_token "$HOST" || true) token=$(get_gitea_token "$HOST" || true)
if [[ -n "$token" ]]; then if [[ -n "$token" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true) raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" cat "$body_file" || return $?
rm -f "$body_file"
return 0 return 0
fi fi
http_code="$raw_code" http_code="$raw_code"
@@ -72,8 +80,7 @@ curl_gitea_pull() {
if [[ -n "$basic_auth" ]]; then if [[ -n "$basic_auth" ]]; then
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true) raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
if [[ "$raw_code" =~ ^2 ]]; then if [[ "$raw_code" =~ ^2 ]]; then
cat "$body_file" cat "$body_file" || return $?
rm -f "$body_file"
return 0 return 0
fi fi
http_code="$raw_code" http_code="$raw_code"
@@ -96,7 +103,6 @@ except Exception:
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response" message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}") print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
PY PY
rm -f "$body_file"
return 1 return 1
} }

View File

@@ -7,9 +7,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
REPO_DIR="$WORK_DIR/repo" REPO_DIR="$WORK_DIR/repo"
FIXTURE_DIR="$WORK_DIR/fixtures" FIXTURE_DIR="$WORK_DIR/fixtures"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR" rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
@@ -56,6 +57,150 @@ cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON'
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"} {"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
JSON JSON
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
output_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o)
output_file="$2"
shift 2
;;
-w|-H|-u)
shift 2
;;
-s|-S|-sS)
shift
;;
*)
shift
;;
esac
done
if [[ -z "$output_file" ]]; then
echo "curl stub expected -o <output_file>" >&2
exit 2
fi
case "${MOSAIC_STUB_CURL_MODE:-success}" in
success)
cat > "$output_file" <<'JSON'
{
"number": 1910,
"title": "Live curl path",
"state": "open",
"user": {"login": "edith"},
"head": {"ref": "fix/live-curl-path"},
"base": {"ref": "main"},
"html_url": "https://git.example.test/acme/widgets/pulls/1910"
}
JSON
printf '200'
;;
cat-fails-after-2xx)
rm -f -- "$output_file"
ln -s /nonexistent/pr-metadata-body "$output_file"
printf '200'
;;
*)
echo "unknown MOSAIC_STUB_CURL_MODE=${MOSAIC_STUB_CURL_MODE:-}" >&2
exit 2
;;
esac
SH
chmod +x "$STUB_DIR/curl"
assert_tmpdir_empty() {
local tmpdir="$1" leftover
leftover=$(find "$tmpdir" -mindepth 1 -print -quit)
if [[ -n "$leftover" ]]; then
echo "Expected tmpfile cleanup, found leftover: $leftover" >&2
find "$tmpdir" -mindepth 1 -maxdepth 1 -ls >&2
exit 1
fi
}
run_curl_success_case() {
local tmpdir="$WORK_DIR/tmp-success" stderr_file="$WORK_DIR/curl-success.stderr"
local output status
mkdir -p "$tmpdir"
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
MOSAIC_STUB_CURL_MODE="success" \
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
status=$?
set -e
if [[ "$status" -ne 0 ]]; then
echo "Expected curl success path to pass, got status $status" >&2
cat "$stderr_file" >&2
exit 1
fi
if grep -q "unbound variable" "$stderr_file"; then
echo "curl success path emitted unbound-variable cleanup noise" >&2
cat "$stderr_file" >&2
exit 1
fi
assert_tmpdir_empty "$tmpdir"
PR_METADATA_OUTPUT="$output" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["PR_METADATA_OUTPUT"])
assert data["number"] == 1910, data
assert data["baseRefName"] == "main", data
assert data["headRefName"] == "fix/live-curl-path", data
PY
}
run_curl_early_exit_cleanup_case() {
local tmpdir="$WORK_DIR/tmp-early-exit" stderr_file="$WORK_DIR/curl-early-exit.stderr"
local output status
mkdir -p "$tmpdir"
set +e
output=$(cd "$REPO_DIR" && \
PATH="$STUB_DIR:$PATH" \
TMPDIR="$tmpdir" \
GITEA_TOKEN="stub-token" \
GITEA_URL="https://git.example.test" \
MOSAIC_STUB_CURL_MODE="cat-fails-after-2xx" \
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
status=$?
set -e
if [[ "$status" -eq 0 ]]; then
echo "Expected unreadable 2xx body path to fail" >&2
printf '%s\n' "$output" >&2
exit 1
fi
if grep -q "unbound variable" "$stderr_file"; then
echo "curl early-exit path emitted unbound-variable cleanup noise" >&2
cat "$stderr_file" >&2
exit 1
fi
if ! grep -q "No such file or directory" "$stderr_file"; then
echo "Expected body-read failure from broken symlink path" >&2
cat "$stderr_file" >&2
exit 1
fi
if grep -q "Gitea API returned non-JSON" "$stderr_file"; then
echo "curl helper masked body-read failure as later JSON parsing failure" >&2
cat "$stderr_file" >&2
exit 1
fi
assert_tmpdir_empty "$tmpdir"
}
run_case() { run_case() {
local fixture="$1" expected_number="$2" expected_head="$3" local fixture="$1" expected_number="$2" expected_head="$3"
local output local output
@@ -77,6 +222,8 @@ PY
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
run_curl_success_case
run_curl_early_exit_cleanup_case
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
echo "Expected API error fixture to fail" >&2 echo "Expected API error fixture to fail" >&2

View File

@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
local full_name="$1" local full_name="$1"
local response http_code body repo_id local response http_code body repo_id
response=$(curl -sS -w "\n%{http_code}" \ response=$(curl -sk -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/lookup/${full_name}") "${WOODPECKER_URL}/api/repos/lookup/${full_name}")

View File

@@ -48,7 +48,7 @@ fi
# Resolve owner/repo to numeric ID (Woodpecker v3 API) # Resolve owner/repo to numeric ID (Woodpecker v3 API)
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1 REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
response=$(curl -sS -w "\n%{http_code}" \ response=$(curl -sk -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}") "${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")

View File

@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
_wp_fetch() { _wp_fetch() {
local ep="$1" local ep="$1"
local resp http_code body local resp http_code body
resp=$(curl -sS -w "\n%{http_code}" \ resp=$(curl -sk -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
"$ep") "$ep")
http_code=$(echo "$resp" | tail -n1) http_code=$(echo "$resp" | tail -n1)

View File

@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
echo "Triggering pipeline for $REPO on branch $BRANCH..." echo "Triggering pipeline for $REPO on branch $BRANCH..."
response=$(curl -sS -w "\n%{http_code}" -X POST \ response=$(curl -sk -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \ -H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \ -d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \