Compare commits
2 Commits
main
...
fix/wrappe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8a9cfa8d | ||
|
|
b90aec2024 |
@@ -51,48 +51,3 @@ 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`).
|
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||||
|
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
|
||||||
@@ -34,6 +39,19 @@ _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() {
|
||||||
@@ -261,7 +279,8 @@ mosaic_http() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X "$method" \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
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}")
|
||||||
@@ -279,7 +298,8 @@ mosaic_http_post() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
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" \
|
||||||
@@ -297,7 +317,8 @@ mosaic_http_patch() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X PATCH \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
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" \
|
||||||
|
|||||||
@@ -98,32 +98,27 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea issue edit syntax
|
# tea issue edit syntax
|
||||||
REPO_SLUG=$(get_repo_slug) || {
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
REPO_LOGIN=$(get_gitea_login) || {
|
CMD="tea issue edit $ISSUE $REPO_ARGS"
|
||||||
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+=(--assignees "$ASSIGNEE")
|
CMD="$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+=(--labels "$LABELS")
|
CMD="$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+=(--milestone "$MILESTONE_ID")
|
CMD="$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
|
||||||
@@ -131,7 +126,7 @@ case "$PLATFORM" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||||
"${CMD[@]}"
|
eval "$CMD"
|
||||||
echo "Issue #$ISSUE updated successfully"
|
echo "Issue #$ISSUE updated successfully"
|
||||||
else
|
else
|
||||||
echo "No changes specified"
|
echo "No changes specified"
|
||||||
|
|||||||
@@ -63,28 +63,24 @@ 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+=(--title "$TITLE")
|
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
||||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
||||||
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
[[ -n "$LABELS" ]] && CMD="$CMD --add-label \"$LABELS\""
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
||||||
"${CMD[@]}"
|
eval $CMD
|
||||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
REPO_SLUG=$(get_repo_slug) || {
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
REPO_LOGIN=$(get_gitea_login) || {
|
CMD="tea issue edit $ISSUE_NUMBER $REPO_ARGS"
|
||||||
echo "Error: Could not resolve Gitea login for remote host" >&2
|
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
||||||
exit 1
|
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
||||||
}
|
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\""
|
||||||
CMD=(tea issue edit "$ISSUE_NUMBER" --repo "$REPO_SLUG" --login "$REPO_LOGIN")
|
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
||||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
eval $CMD
|
||||||
[[ -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"
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# lane-brief.sh — live dispatch brief for a repo "lane" (milestone/label), straight
|
|
||||||
# from current Gitea state. Defeats stale worker self-report: workers brief from
|
|
||||||
# static notes and routinely report issues "todo" that are already CLOSED, forcing
|
|
||||||
# the orchestrator to re-verify each one before dispatch. This returns the CURRENT
|
|
||||||
# open set, classified for dispatch, in one call.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# lane-brief.sh -r <owner/repo> [-m <milestone>] [-l <label>] [-L <login>] [-n <limit>]
|
|
||||||
# lane-brief.sh -r usc/uconnect -m "M2M Part Search (0.0.45)"
|
|
||||||
# lane-brief.sh -r usc/uconnect -l domain/6-security
|
|
||||||
#
|
|
||||||
# Reliable signals (closed issues are excluded by definition — that's the point):
|
|
||||||
# - open-vs-closed : authoritative; this is the stale-intake failure mode.
|
|
||||||
# - PR-linkage : an open PR referencing the issue = work underway.
|
|
||||||
# Assignees/dependencies are intentionally NOT trusted as "available" signals —
|
|
||||||
# fleets that track work-state out-of-band (tmux board, issue text) leave them
|
|
||||||
# empty in Gitea. Output therefore partitions by PR presence and the OPEN-NO-PR set
|
|
||||||
# is "dispatch candidates to cross-check against the live fleet", not a blind list.
|
|
||||||
#
|
|
||||||
# Login resolution order: -L flag > $GITEA_LOGIN > owner inference (usc->usc,
|
|
||||||
# mosaicstack/mosaic->mosaicstack) > detect-platform.sh default-login fallback.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
|
||||||
|
|
||||||
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
|
||||||
while getopts "r:m:l:L:n:h" opt; do
|
|
||||||
case "$opt" in
|
|
||||||
r) REPO="$OPTARG" ;;
|
|
||||||
m) MILESTONE="$OPTARG" ;;
|
|
||||||
l) LABEL="$OPTARG" ;;
|
|
||||||
L) LOGIN="$OPTARG" ;;
|
|
||||||
n) LIMIT="$OPTARG" ;;
|
|
||||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
|
||||||
*) echo "see -h" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
|
||||||
|
|
||||||
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
|
||||||
# shared default-login resolver. Owner inference comes before the shared fallback
|
|
||||||
# because the latter is not owner-aware (picks the default tea login), which is
|
|
||||||
# wrong for cross-instance lanes.
|
|
||||||
if [[ -z "$LOGIN" ]]; then
|
|
||||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
|
||||||
LOGIN="$GITEA_LOGIN"
|
|
||||||
else
|
|
||||||
case "${REPO%%/*}" in
|
|
||||||
usc|USC) LOGIN=usc ;;
|
|
||||||
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
|
||||||
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
|
||||||
|
|
||||||
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
|
||||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
|
|
||||||
|
|
||||||
ISSUES_JSON="$(tea issues list --repo "$REPO" --login "$LOGIN" --state open --limit "$LIMIT" \
|
|
||||||
--fields index,title,assignees,milestone,labels --output json 2>/dev/null)" || {
|
|
||||||
echo "FATAL: tea issues list failed for $REPO (login=$LOGIN)" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Open PRs, to cross-ref which issues already have work in flight. An issue is
|
|
||||||
# "work underway" if an open PR links to it. Two link signals are honored:
|
|
||||||
# (a) a closing keyword in the PR BODY — Gitea's auto-close set (close/closes/
|
|
||||||
# closed, fix/fixes/fixed, resolve/resolves/resolved), case-insensitive,
|
|
||||||
# directly preceding `#N`. This is the AUTHORITATIVE link Gitea itself uses
|
|
||||||
# to associate a PR with the issue it resolves; a body-only "Closes #546"
|
|
||||||
# is the common case and MUST count. The earlier version inspected only the
|
|
||||||
# PR index/title/head TSV (never the body or Gitea linkage), so a body-only
|
|
||||||
# reference was invisible and the linked OPEN issue was misclassified as a
|
|
||||||
# dispatch candidate — re-dispatchable in-flight work (the #546/#547 defect).
|
|
||||||
# (b) a bare #N in the PR title, or an issue number embedded in the head branch
|
|
||||||
# (feat/546-x, fix-546) — the weaker heuristic preserved from prior behavior.
|
|
||||||
# Bare #N mentions in the BODY are deliberately NOT treated as links: PR bodies
|
|
||||||
# routinely name unrelated issues in prose ("relevant to the #538 line of work"),
|
|
||||||
# and counting those would wrongly mark live, dispatchable issues as in-flight.
|
|
||||||
# Only the closing-keyword form is a commitment to resolve that issue. Requiring
|
|
||||||
# `#` to directly follow the keyword also keeps cross-repo `owner/repo#N` forms
|
|
||||||
# from leaking a foreign issue number into this per-repo lane (cross-repo lanes
|
|
||||||
# are run per-repo). JSON (not TSV) is used so multi-line bodies parse cleanly.
|
|
||||||
PRS_JSON="$(tea pulls list --repo "$REPO" --login "$LOGIN" --state open \
|
|
||||||
--fields index,title,head,body --output json 2>/dev/null || echo '[]')"
|
|
||||||
[[ -n "$PRS_JSON" ]] || PRS_JSON='[]'
|
|
||||||
|
|
||||||
# \b anchors the keyword to a word start so embedded substrings do not match
|
|
||||||
# (e.g. "prefix #5", "disclosed #7" must NOT be read as "fix #5" / "closed #7").
|
|
||||||
GITEA_CLOSE_KW='close[sd]?|fix(e[sd])?|resolve[sd]?'
|
|
||||||
PR_BODY_REFS="$(printf '%s' "$PRS_JSON" | jq -r '.[] | .body // ""' 2>/dev/null \
|
|
||||||
| grep -oiE "\\b(${GITEA_CLOSE_KW})[[:space:]:]+#[0-9]+" | grep -oE '[0-9]+' || true)"
|
|
||||||
PR_TITLE_HEAD_REFS="$(printf '%s' "$PRS_JSON" \
|
|
||||||
| jq -r '.[] | [ (.title // ""), (.head // "" | if type=="object" then (.ref // "") else . end) ] | join(" ")' 2>/dev/null \
|
|
||||||
| grep -oE '#[0-9]+|[/-][0-9]{3,}' | grep -oE '[0-9]+' || true)"
|
|
||||||
PR_ISSUE_REFS="$(printf '%s\n%s\n' "$PR_BODY_REFS" "$PR_TITLE_HEAD_REFS" | grep -E '^[0-9]+$' | sort -u || true)"
|
|
||||||
|
|
||||||
ts="$(date -u '+%Y-%m-%d %H:%MZ' 2>/dev/null || echo '?')"
|
|
||||||
filt="$REPO"; [[ -n "$MILESTONE" ]] && filt="$filt · milestone:'$MILESTONE'"; [[ -n "$LABEL" ]] && filt="$filt · label:'$LABEL'"
|
|
||||||
echo "LANE BRIEF — $filt · $ts (login=$LOGIN)"
|
|
||||||
echo "(open issues only; closed are excluded by definition — that's the point)"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# Label match is exact-token against tea's space-separated labels string (so -l
|
|
||||||
# "security" does NOT match label "domain/6-security"). Caveat: label names that
|
|
||||||
# themselves contain spaces aren't distinguishable in tea's string form.
|
|
||||||
printf '%s' "$ISSUES_JSON" | jq -r --arg ms "$MILESTONE" --arg lb "$LABEL" --arg prs "$PR_ISSUE_REFS" '
|
|
||||||
($prs | split("\n") | map(select(length>0))) as $prrefs
|
|
||||||
| map(
|
|
||||||
select( ($ms=="" or .milestone==$ms)
|
|
||||||
and ($lb=="" or ((.labels//"") | split(" ") | index($lb) != null)) )
|
|
||||||
| . + { assigned: ((.assignees//"")|length>0),
|
|
||||||
haspr: (.index as $ix | ($prrefs | index($ix)) != null) }
|
|
||||||
)
|
|
||||||
| (map(select(.haspr|not))) as $candidates
|
|
||||||
| (map(select(.haspr))) as $inflight
|
|
||||||
| "DISPATCH CANDIDATES (open · no open PR) — \($candidates|length) [cross-check vs live fleet]:",
|
|
||||||
( $candidates[] | " #\(.index) \(.title[0:90])\(if .assigned then " (gitea-assignee set)" else "" end)" ),
|
|
||||||
"",
|
|
||||||
"WORK UNDERWAY (open · PR in flight) — \($inflight|length):",
|
|
||||||
( $inflight[] | " #\(.index) \(.title[0:80]) [PR open]" )
|
|
||||||
'
|
|
||||||
echo
|
|
||||||
echo "Closed issues are excluded — do NOT take a worker's self-reported 'todo' on faith."
|
|
||||||
echo "Candidates = open + no PR; confirm against the live fleet before dispatch"
|
|
||||||
echo "(fleets that don't self-assign in Gitea leave 'unassigned' meaningless)."
|
|
||||||
@@ -99,15 +99,10 @@ fi
|
|||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
# GitHub uses the API for milestone creation
|
# GitHub uses the API for milestone creation
|
||||||
# Use jq to safely construct JSON so titles/descriptions containing
|
JSON_PAYLOAD="{\"title\":\"$TITLE\""
|
||||||
# quotes or special characters do not corrupt the payload (F-07).
|
[[ -n "$DESCRIPTION" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"description\":\"$DESCRIPTION\""
|
||||||
JSON_PAYLOAD=$(jq -n \
|
[[ -n "$DUE_DATE" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"due_on\":\"${DUE_DATE}T00:00:00Z\""
|
||||||
--arg t "$TITLE" \
|
JSON_PAYLOAD="$JSON_PAYLOAD}"
|
||||||
--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"
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ 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
|
||||||
@@ -142,6 +147,21 @@ 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)
|
||||||
@@ -245,6 +265,51 @@ 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
|
||||||
@@ -272,11 +337,35 @@ 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
|
||||||
|
|||||||
@@ -57,20 +57,12 @@ 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" || return $?
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
http_code="$raw_code"
|
http_code="$raw_code"
|
||||||
@@ -80,7 +72,8 @@ 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" || return $?
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
http_code="$raw_code"
|
http_code="$raw_code"
|
||||||
@@ -103,6 +96,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for lane-brief.sh PR->issue linkage classification.
|
|
||||||
#
|
|
||||||
# Covers the #546/#547 defect: lane-brief.sh inspected only the PR index/title/head
|
|
||||||
# fields and never the PR BODY, so an open PR whose body says "Closes #546" did not
|
|
||||||
# mark issue #546 as work-underway — #546 was listed as a DISPATCH CANDIDATE and was
|
|
||||||
# re-dispatchable in-flight work.
|
|
||||||
#
|
|
||||||
# Asserts:
|
|
||||||
# 1. an open issue closed-keyword-linked from a PR BODY ("Closes #546") is
|
|
||||||
# classified WORK UNDERWAY, not a dispatch candidate.
|
|
||||||
# 2. a BARE "#777" prose mention in a PR body does NOT classify #777 as
|
|
||||||
# work-underway (only Gitea closing keywords are a real link) — #777 stays a
|
|
||||||
# dispatch candidate.
|
|
||||||
# 3. NON-VACUITY / RED-ON-REVERT: a copy of the script with the body-scan removed
|
|
||||||
# misclassifies #546 as a dispatch candidate — proving the body-scan is exactly
|
|
||||||
# what fixes the defect and that assertion 1 fails if the fix is reverted.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
LANE_BRIEF="$SCRIPT_DIR/lane-brief.sh"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-pr-linkage}"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$BIN_DIR"
|
|
||||||
|
|
||||||
# --- fake `tea`: serves a fixed open-issue set and one open PR. ----------------
|
|
||||||
# PR #547 body uses a closing keyword for #546 ("Closes #546") and a BARE mention
|
|
||||||
# of #777 ("the #777 line of work"). #777 must NOT be treated as linked.
|
|
||||||
cat > "$BIN_DIR/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
case "${1:-} ${2:-}" in
|
|
||||||
"issues list")
|
|
||||||
cat <<'JSON'
|
|
||||||
[
|
|
||||||
{"index":"546","title":"lane-brief + ci-wait orchestration tooling","assignees":[],"milestone":null,"labels":""},
|
|
||||||
{"index":"777","title":"unrelated downstream item","assignees":[],"milestone":null,"labels":""},
|
|
||||||
{"index":"999","title":"item only named inside the word hotfix","assignees":[],"milestone":null,"labels":""}
|
|
||||||
]
|
|
||||||
JSON
|
|
||||||
;;
|
|
||||||
"pulls list")
|
|
||||||
cat <<'JSON'
|
|
||||||
[
|
|
||||||
{"index":"547","title":"feat(framework/tools): orchestration helpers","head":"feat/orchestration-tools-lane-brief-ci-wait","body":"Two additive orchestration tools.\n\nCloses #546.\n\nLogin resolution is relevant to the #777 line of work but does not touch it.\nThis shipped as a hotfix #999 earlier — that bare reference must not link it.\n\nFixes #546\n"}
|
|
||||||
]
|
|
||||||
JSON
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "fake-tea: unhandled: $*" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tea"
|
|
||||||
|
|
||||||
run_brief() { # $1 = script path
|
|
||||||
PATH="$BIN_DIR:$PATH" "$1" -r mosaic/stack -L test-login 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract the issue numbers under a named section header until the next blank line.
|
|
||||||
section_nums() { # $1 = output $2 = header-prefix
|
|
||||||
printf '%s\n' "$1" | awk -v h="$2" '
|
|
||||||
index($0,h)==1 {grab=1; next}
|
|
||||||
grab && /^[[:space:]]*$/ {grab=0}
|
|
||||||
grab && match($0, /#[0-9]+/) { print substr($0, RSTART+1, RLENGTH-1) }
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
|
||||||
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixed (current) script behavior
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
OUT="$(run_brief "$LANE_BRIEF")"
|
|
||||||
CAND="$(section_nums "$OUT" 'DISPATCH CANDIDATES')"
|
|
||||||
UNDER="$(section_nums "$OUT" 'WORK UNDERWAY')"
|
|
||||||
|
|
||||||
echo "--- lane-brief output (fixed) ---"; printf '%s\n' "$OUT"
|
|
||||||
echo "--- candidates: [$(printf '%s' "$CAND" | tr '\n' ' ')] underway: [$(printf '%s' "$UNDER" | tr '\n' ' ')] ---"
|
|
||||||
|
|
||||||
contains "$UNDER" 546 || fail "#546 (PR body 'Closes #546') should be WORK UNDERWAY"
|
|
||||||
contains "$CAND" 546 && fail "#546 must NOT be a dispatch candidate (it has an open PR)"
|
|
||||||
contains "$CAND" 777 || fail "#777 (only a bare prose mention) should remain a dispatch candidate"
|
|
||||||
contains "$UNDER" 777 && fail "#777 must NOT be work-underway — bare body mentions are not links"
|
|
||||||
contains "$CAND" 999 || fail "#999 ('hotfix #999' — keyword is a substring) should remain a candidate"
|
|
||||||
contains "$UNDER" 999 && fail "#999 must NOT be work-underway — word-boundary must reject 'hotfix'"
|
|
||||||
echo "PASS: body closing-keyword link classifies #546 underway; bare #777 / substring #999 stay candidates"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# NON-VACUITY: revert the body-scan and prove #546 regresses to a candidate.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REVERTED="$SCRIPT_DIR/.lane-brief.reverted.$$.sh"
|
|
||||||
trap 'rm -f "$REVERTED"' EXIT
|
|
||||||
# Drop the PR_BODY_REFS contribution from the union (simulates the pre-fix script
|
|
||||||
# that only looked at index/title/head). Sibling `source detect-platform.sh` still
|
|
||||||
# resolves because the copy lives in the same dir.
|
|
||||||
# shellcheck disable=SC2016 # single-quoted on purpose: sed needs the literal $PR_BODY_REFS
|
|
||||||
sed 's/"\$PR_BODY_REFS"/""/' "$LANE_BRIEF" > "$REVERTED"
|
|
||||||
chmod +x "$REVERTED"
|
|
||||||
grep -q 'PR_BODY_REFS' "$REVERTED" || fail "revert sed anchor not found — test is stale"
|
|
||||||
|
|
||||||
ROUT="$(run_brief "$REVERTED")"
|
|
||||||
RCAND="$(section_nums "$ROUT" 'DISPATCH CANDIDATES')"
|
|
||||||
RUNDER="$(section_nums "$ROUT" 'WORK UNDERWAY')"
|
|
||||||
echo "--- candidates(reverted): [$(printf '%s' "$RCAND" | tr '\n' ' ')] underway: [$(printf '%s' "$RUNDER" | tr '\n' ' ')] ---"
|
|
||||||
|
|
||||||
contains "$RCAND" 546 || fail "non-vacuity broken: reverted script should misclassify #546 as a candidate"
|
|
||||||
contains "$RUNDER" 546 && fail "non-vacuity broken: reverted script should NOT mark #546 underway"
|
|
||||||
echo "PASS (RED-on-revert): without the body-scan, #546 regresses to a dispatch candidate"
|
|
||||||
|
|
||||||
echo "ALL PASS: test-lane-brief-pr-linkage.sh"
|
|
||||||
@@ -7,10 +7,9 @@ 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" "$STUB_DIR"
|
mkdir -p "$REPO_DIR" "$FIXTURE_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
|
||||||
@@ -57,150 +56,6 @@ 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
|
||||||
@@ -222,8 +77,6 @@ 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
|
||||||
|
|||||||
@@ -27,11 +27,10 @@ A Woodpecker API token is required. To configure:
|
|||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| --------------------- | ------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
|
||||||
|
|
||||||
## Common Options
|
## Common Options
|
||||||
|
|
||||||
@@ -56,7 +55,4 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
# Trigger a build on a specific branch
|
# Trigger a build on a specific branch
|
||||||
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch
|
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch
|
||||||
|
|
||||||
# Block until one or more pipelines finish (event-driven CI wait)
|
|
||||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 -sk -w "\n%{http_code}" \
|
response=$(curl -sS -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci-wait.sh — block until one or more Woodpecker pipelines reach terminal state.
|
|
||||||
#
|
|
||||||
# Problem it solves: orchestrators hand-author a `while true; curl .../repos/1/pipelines/$n
|
|
||||||
# ...; sleep` loop for every CI wait. Those loops HARDCODE Woodpecker repo id 1 (only
|
|
||||||
# correct for whichever repo happens to be id 1), re-implement URL building with raw
|
|
||||||
# curl, and tend to get armed as tight <300s ScheduleWakeup polls (each poll = a full
|
|
||||||
# wake+reload+recheck cycle). This encapsulates the loop once, on top of the existing
|
|
||||||
# `pipeline-status.sh` wrapper (which resolves repo->id correctly and is instance-aware),
|
|
||||||
# so a CI wait becomes a one-liner.
|
|
||||||
#
|
|
||||||
# Intended use: as the COMMAND of a Monitor / event-driven re-invoke (primary), paired
|
|
||||||
# with a single long (>=1500s) timed fallback — NOT as a tight standalone poll.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ci-wait.sh -r <owner/repo> -n <num> [-n <num> ...] [-a <instance>] [-i <interval>] [-t <timeout>]
|
|
||||||
# ci-wait.sh -r usc/uconnect -n 3917 -n 3918 # wait for both, infer instance
|
|
||||||
# ci-wait.sh -r usc/uconnect -n 3922 -a usc -i 30 -t 2400
|
|
||||||
#
|
|
||||||
# Instance is inferred from the owner (usc->usc, mosaicstack/mosaic->mosaic) unless -a given.
|
|
||||||
# Exit: 0 = all pipelines terminal AND all 'success'; 1 = >=1 terminal non-success;
|
|
||||||
# 2 = usage/precondition error; 3 = timeout before all terminal.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Resolve pipeline-status.sh as a sibling, matching how the woodpecker tools source
|
|
||||||
# _lib.sh — works under the installed runtime AND an in-repo checkout, no MOSAIC_HOME dep.
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PS="$SCRIPT_DIR/pipeline-status.sh"
|
|
||||||
|
|
||||||
REPO="" INSTANCE="" INTERVAL=30 TIMEOUT=3600
|
|
||||||
NUMS=()
|
|
||||||
while getopts "r:n:a:i:t:h" opt; do
|
|
||||||
case "$opt" in
|
|
||||||
r) REPO="$OPTARG" ;;
|
|
||||||
n) NUMS+=("$OPTARG") ;;
|
|
||||||
a) INSTANCE="$OPTARG" ;;
|
|
||||||
i) INTERVAL="$OPTARG" ;;
|
|
||||||
t) TIMEOUT="$OPTARG" ;;
|
|
||||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
|
||||||
*) echo "see -h" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
|
||||||
[[ ${#NUMS[@]} -gt 0 ]] || { echo "FATAL: at least one -n <pipeline-number> required" >&2; exit 2; }
|
|
||||||
[[ -x "$PS" ]] || { echo "FATAL: pipeline-status.sh not found/executable at $PS" >&2; exit 2; }
|
|
||||||
|
|
||||||
# Infer Woodpecker instance from owner unless overridden (matches the git-wrapper convention).
|
|
||||||
if [[ -z "$INSTANCE" ]]; then
|
|
||||||
case "${REPO%%/*}" in
|
|
||||||
usc|USC) INSTANCE=usc ;;
|
|
||||||
mosaicstack|mosaic) INSTANCE=mosaic ;;
|
|
||||||
*) echo "FATAL: cannot infer Woodpecker instance for owner '${REPO%%/*}' — pass -a <instance>" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 2; }
|
|
||||||
|
|
||||||
TERMINAL_RE='^(success|failure|error|killed|declined|blocked)$'
|
|
||||||
declare -A STATE=() # num -> terminal status, once reached
|
|
||||||
start=$(date +%s 2>/dev/null || echo 0)
|
|
||||||
|
|
||||||
echo "ci-wait: $REPO pipelines [${NUMS[*]}] (instance=$INSTANCE, every ${INTERVAL}s, timeout ${TIMEOUT}s)"
|
|
||||||
while true; do
|
|
||||||
for n in "${NUMS[@]}"; do
|
|
||||||
[[ -n "${STATE[$n]:-}" ]] && continue
|
|
||||||
s=$("$PS" -r "$REPO" -n "$n" -a "$INSTANCE" -f json 2>/dev/null | jq -r '.status // empty' 2>/dev/null || true)
|
|
||||||
if [[ "$s" =~ $TERMINAL_RE ]]; then
|
|
||||||
STATE[$n]="$s"
|
|
||||||
echo " pipeline $n TERMINAL: $s"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# all terminal?
|
|
||||||
if [[ ${#STATE[@]} -eq ${#NUMS[@]} ]]; then
|
|
||||||
bad=0
|
|
||||||
for n in "${NUMS[@]}"; do [[ "${STATE[$n]}" == "success" ]] || bad=1; done
|
|
||||||
if [[ $bad -eq 0 ]]; then echo "ci-wait: ALL SUCCESS"; exit 0; fi
|
|
||||||
echo "ci-wait: all terminal, NOT all success — $(for n in "${NUMS[@]}"; do printf '%s=%s ' "$n" "${STATE[$n]}"; done)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
now=$(date +%s 2>/dev/null || echo 0)
|
|
||||||
if [[ "$start" != 0 && $((now - start)) -ge $TIMEOUT ]]; then
|
|
||||||
echo "ci-wait: TIMEOUT after ${TIMEOUT}s — pending: $(for n in "${NUMS[@]}"; do [[ -z "${STATE[$n]:-}" ]] && printf '%s ' "$n"; done)"
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
sleep "$INTERVAL"
|
|
||||||
done
|
|
||||||
@@ -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 -sk -w "\n%{http_code}" \
|
response=$(curl -sS -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 -sk -w "\n%{http_code}" \
|
resp=$(curl -sS -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)
|
||||||
|
|||||||
@@ -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 -sk -w "\n%{http_code}" -X POST \
|
response=$(curl -sS -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}')" \
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for ci-wait.sh terminal-state aggregation and exit codes.
|
|
||||||
#
|
|
||||||
# ci-wait.sh wraps pipeline-status.sh and blocks until every requested pipeline
|
|
||||||
# reaches a terminal Woodpecker state, then maps the aggregate to an exit code.
|
|
||||||
# That contract is what callers arm a Monitor/timed-fallback around, so it must be
|
|
||||||
# exact. This harness drives ci-wait.sh against a stub pipeline-status.sh whose
|
|
||||||
# per-pipeline status is fixture-controlled, and asserts the full exit matrix:
|
|
||||||
#
|
|
||||||
# 0 = every pipeline terminal AND all 'success'
|
|
||||||
# 1 = every pipeline terminal, at least one non-success
|
|
||||||
# 2 = usage/precondition error (missing -n)
|
|
||||||
# 3 = timeout before all pipelines terminal
|
|
||||||
#
|
|
||||||
# Non-vacuity: each case pins a DISTINCT exit code to a distinct fixture, so a
|
|
||||||
# regression in success-aggregation (case 0 vs 1), terminal detection (case 3),
|
|
||||||
# or arg validation (case 2) flips exactly one assertion RED.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CIW_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ci-wait.sh"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-wait-exit-matrix}"
|
|
||||||
TOOL_DIR="$WORK_DIR/tool"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$TOOL_DIR"
|
|
||||||
|
|
||||||
# ci-wait.sh resolves pipeline-status.sh as a sibling ($SCRIPT_DIR/pipeline-status.sh),
|
|
||||||
# so we run a COPY of ci-wait.sh next to a stub sibling we control.
|
|
||||||
cp "$CIW_SRC" "$TOOL_DIR/ci-wait.sh"
|
|
||||||
chmod +x "$TOOL_DIR/ci-wait.sh"
|
|
||||||
|
|
||||||
# Stub pipeline-status.sh: emits {"status":"<s>"} where <s> comes from env
|
|
||||||
# CIW_STATUS_<num> (default "running" = non-terminal, drives the timeout path).
|
|
||||||
cat > "$TOOL_DIR/pipeline-status.sh" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
num=""
|
|
||||||
while getopts "r:n:a:f:" opt; do case "$opt" in n) num="$OPTARG" ;; *) : ;; esac; done
|
|
||||||
var="CIW_STATUS_${num}"
|
|
||||||
printf '{"status":"%s"}\n' "${!var:-running}"
|
|
||||||
SH
|
|
||||||
chmod +x "$TOOL_DIR/pipeline-status.sh"
|
|
||||||
|
|
||||||
CIW="$TOOL_DIR/ci-wait.sh"
|
|
||||||
|
|
||||||
run_expect() { # $1 = expected exit $2 = label ; rest = args
|
|
||||||
local want="$1" label="$2"; shift 2
|
|
||||||
local rc=0
|
|
||||||
"$CIW" "$@" >/dev/null 2>&1 || rc=$?
|
|
||||||
if [[ "$rc" -ne "$want" ]]; then
|
|
||||||
echo "FAIL [$label]: expected exit $want, got $rc" >&2; exit 1
|
|
||||||
fi
|
|
||||||
echo "PASS [$label]: exit $rc"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 0 — both pipelines terminal + success
|
|
||||||
CIW_STATUS_100=success CIW_STATUS_101=success \
|
|
||||||
run_expect 0 "all-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
|
||||||
|
|
||||||
# 1 — both terminal, one failure
|
|
||||||
CIW_STATUS_100=success CIW_STATUS_101=failure \
|
|
||||||
run_expect 1 "terminal-not-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
|
||||||
|
|
||||||
# 1 — other terminal non-success states still map to 1 (error/killed)
|
|
||||||
CIW_STATUS_100=error CIW_STATUS_101=killed \
|
|
||||||
run_expect 1 "terminal-error-killed" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
|
||||||
|
|
||||||
# 3 — a pipeline never reaches terminal state before timeout
|
|
||||||
CIW_STATUS_100=success CIW_STATUS_101=running \
|
|
||||||
run_expect 3 "timeout-pending" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 0
|
|
||||||
|
|
||||||
# 2 — usage error: no -n
|
|
||||||
run_expect 2 "usage-missing-n" -r mosaic/stack -a mosaic
|
|
||||||
|
|
||||||
echo "ALL PASS: test-ci-wait-exit-matrix.sh"
|
|
||||||
Reference in New Issue
Block a user