feat(framework/tools): orchestration helpers — lane-brief.sh + ci-wait.sh #547
98
packages/mosaic/framework/tools/git/lane-brief.sh
Executable file
98
packages/mosaic/framework/tools/git/lane-brief.sh
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/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.
|
||||||
|
PRS_TSV="$(tea pulls list --repo "$REPO" --login "$LOGIN" --state open \
|
||||||
|
--fields index,title,head --output tsv 2>/dev/null || true)"
|
||||||
|
PR_ISSUE_REFS="$(printf '%s\n' "$PRS_TSV" | grep -oE '#[0-9]+|[/-][0-9]{3,}' | grep -oE '[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
|
||||||
|
|
||||||
|
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//"") | contains($lb))) )
|
||||||
|
| . + { 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)."
|
||||||
@@ -31,6 +31,7 @@ A Woodpecker API token is required. To configure:
|
|||||||
| `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
|
||||||
|
|
||||||
@@ -55,4 +56,7 @@ 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
|
||||||
```
|
```
|
||||||
|
|||||||
86
packages/mosaic/framework/tools/woodpecker/ci-wait.sh
Executable file
86
packages/mosaic/framework/tools/woodpecker/ci-wait.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/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 ;;
|
||||||
|
*) INSTANCE=usc ;;
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user