diff --git a/AGENTS.md b/AGENTS.md index 6f50ec82c20..2bbde3b237e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,6 +373,7 @@ The worker reports the PR when CI first becomes green rather than waiting for me For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done: PR checks green` after CI is green, while `direct-PR` reports `done: PR ` after opening the PR. A brief generated for a GitLab project carries the same signal in merge-request wording - `done: MR checks green` and `done: MR ` - and a brief whose project forge could not be determined carries the combined `PR/MR` noun, so treat the noun as the project's forge vocabulary, not a different contract. Run `bin/fm-pr-check.sh ` - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. +Read a GitLab merge request's state, approvals, and CI through `bin/fm-pr-status.sh` before asserting them, because its pipeline badge is often a merge-result run that never tested the real head. Tell the captain the PR's full URL, always the complete `https://...` link rather than a bare `#number`, a concise outcome summary, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine merge authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. diff --git a/bin/fm-pr-status.sh b/bin/fm-pr-status.sh index fe90056ac1c..c276931e485 100755 --- a/bin/fm-pr-status.sh +++ b/bin/fm-pr-status.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash -# fm-pr-status.sh - one compact line per GitLab merge request: merged, -# approved, conflicted, and whether its reported pipeline is ACTUALLY green. +# fm-pr-status.sh - one compact line per GitLab merge request: its state, +# target branch, approvals, merge status, conflicts, whether its pipeline is +# ACTUALLY green on the real head, and whether its project runs CI at all and +# requires a passing pipeline to merge. Read-only: it never merges, approves, +# or comments. Read merge request state with this tool rather than a +# hand-rolled glab/jq pipeline, which has misread each trap below. # # The trap this exists to close: GitLab's head_pipeline is frequently a # MERGE-RESULT run against a synthetic merge commit, not the branch head, so a @@ -34,9 +38,16 @@ # Three further traps stay encoded because they were each got wrong by hand: # - merge request descriptions routinely carry raw control characters that # break `jq`/`json.load` mid-parse; the API response is scrubbed first. -# - approval is read from detailed_merge_status, not the `approved` flag: -# approved==true with an empty approved_by list means zero approvals were -# REQUIRED, not that anyone signed off. +# - approval is never read from the `approved` flag: approved==true with an +# empty approved_by list means zero approvals were REQUIRED, not that anyone +# signed off. The approvals endpoint's approved_by and approvals_left decide +# it, with detailed_merge_status=not_approved always winning: +# approved() n people approved and none are still required +# not-required nobody approved and none are required +# NOT-APPROVED[(-left)] approval is still required +# none-given nobody approved; whether any is required could +# not be told from the response +# UNVERIFIED the approvals could not be read at all # - a merge request's pipeline list mixes real CI runs with source=external # entries that third-party tools (Atlantis and friends) post through the # commit status API. Only non-external runs decide a verdict; a red @@ -57,17 +68,29 @@ # carries no hardcoded organization. An unresolvable shortname fails with a # clear message naming what it looked for rather than guessing a prefix. # +# A full merge request URL (https://///-/merge_requests/) +# is parsed by bin/fm-pr-lib.sh and read from that URL's own host, so any +# instance works; every other form reads glab's configured default host. +# # Usage: +# fm-pr-status.sh [...] # fm-pr-status.sh [...] # fm-pr-status.sh [...] # fm-pr-status.sh --repo group/subgroup/project ... # fm-pr-status.sh -h | --help # -# Output columns: repo!num state approval pipeline head-sha [- notes] -# A merged merge request prints just "repo!num MERGED on ", and an -# unreadable one just "repo!num UNREACHABLE ()". Conflicts, draft state, -# what the badge actually is, and any failed external status are all disclosed -# as their own notes - never folded into the pipeline verdict. +# Output columns: repo!num state into= ci= approval= merge= conflicts= head= jobs= must-succeed= [- notes] +# jobs= is DISABLED when the project's jobs_enabled or builds_access_level +# says CI can never run there, and must-succeed= is the project's "Pipelines +# must succeed" setting; either prints ? when the project could not be read +# or did not say (as GitLab's reduced view for low-permission tokens does), +# and into= prints ? when the response named no target branch. Every ? fails +# the run just as an unreadable read does. +# A merged merge request prints just "repo!num merged into= on=", +# and an unreadable one just "repo!num UNREACHABLE ()". Draft state, what +# the badge actually is, and any failed external status are all disclosed as +# their own notes - never folded into the pipeline verdict. +# Exit status is non-zero when any row, or any part of one, could not be read. # # Requires: glab (authenticated against the target GitLab host), python3. set -u @@ -77,6 +100,9 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + usage() { awk ' NR == 1 { next } @@ -124,17 +150,95 @@ urlenc() { printf '%s' "$1" | sed 's|/|%2F|g'; } # jq/json.load mid-parse; strip them before anything touches the response. scrub() { tr -d '\000-\010\013\014\016-\037'; } +# One GitLab API read, scrubbed. ROW_HOST is the host a full URL named, or +# empty for glab's configured default. The exit status is glab's own. +fm_prstat_api() { # + local out rc=0 + if [ -n "$ROW_HOST" ]; then + out=$(glab api "$1" --hostname "$ROW_HOST" 2>/dev/null) || rc=$? + else + out=$(glab api "$1" 2>/dev/null) || rc=$? + fi + printf '%s' "$out" | scrub + return "$rc" +} + +# The project's CI capability and "Pipelines must succeed" setting, left in +# PROJ_CACHE_VAL as " ". Called directly, never in a +# subshell, so consecutive rows of one project reuse one read and an +# unreadable project still marks the run failed. +PROJ_CACHE_KEY="" +PROJ_CACHE_VAL="" +fm_prstat_project() { # + local key="$ROW_HOST|$1" raw rc=0 val + [ "$key" != "$PROJ_CACHE_KEY" ] || return 0 + raw=$(fm_prstat_api "projects/$2") || rc=$? + val=$(printf '%s' "$raw" | python3 -c ' +import json,sys +try: d=json.load(sys.stdin) +except Exception: d=None +if not isinstance(d,dict) or "id" not in d: + print("UNREADABLE"); raise SystemExit(0) +je=d.get("jobs_enabled"); bal=d.get("builds_access_level") +if je is False or bal == "disabled": jobs="DISABLED" +elif je is True or bal in ("enabled", "private"): jobs="enabled" +else: jobs="?" +m=d.get("only_allow_merge_if_pipeline_succeeds") +print(jobs, "yes" if m is True else "no" if m is False else "?") +' 2>/dev/null) + if [ "$rc" -ne 0 ] || [ -z "$val" ] || [ "$val" = UNREADABLE ]; then + STATUS=1 + val="? ?" + fi + case "$val" in *'?'*) STATUS=1 ;; esac + PROJ_CACHE_KEY=$key + PROJ_CACHE_VAL=$val +} + +# Who approved and whether any approval is still required, left in +# ROW_APPROVAL; the verdicts are listed in the header and +# detailed_merge_status=not_approved always wins. Called directly, never in a +# subshell, so an unreadable response still marks the run failed. +ROW_APPROVAL="" +fm_prstat_approval() { # + local raw rc=0 val + raw=$(fm_prstat_api "projects/$1/merge_requests/$2/approvals") || rc=$? + [ "$rc" -eq 0 ] || raw="" + val=$(printf '%s' "$raw" | python3 -c ' +import json,sys +dms=sys.argv[1] +try: d=json.load(sys.stdin) +except Exception: d=None +if not isinstance(d,dict) or not ("approved_by" in d or "approvals_left" in d): + print("NOT-APPROVED" if dms == "not_approved" else "UNVERIFIED"); raise SystemExit(0) +by=d.get("approved_by") +n=len(by) if isinstance(by,list) else 0 +left=d.get("approvals_left") +if isinstance(left,bool) or not isinstance(left,int): left=None +if dms == "not_approved" or (left is not None and left > 0): + print("NOT-APPROVED(%d-left)" % left if left else "NOT-APPROVED") +elif n > 0: print("approved(%d)" % n) +elif left == 0 or dms == "mergeable": print("not-required") +else: print("none-given") +' "$3" 2>/dev/null) + if [ -z "$val" ]; then + val="UNVERIFIED" + fi + [ "$val" != UNVERIFIED ] || STATUS=1 + ROW_APPROVAL=$val +} + fm_prstat_unreachable() { # printf '%-34s %s\n' "$(basename "$1")!$2" "UNREACHABLE ($3 - check repo/number/auth)" STATUS=1 } +ROW_HOST="" fm_prstat_one() { # - local repo="$1" iid="$2" enc j rc=0 state sha pipe_sha pipe pipe_source pipe_ref dms conflicts merged draft + local repo="$1" iid="$2" enc j rc=0 state sha pipe_sha pipe pipe_source pipe_ref dms conflicts merged draft target enc=$(urlenc "$repo") - j=$(glab api "projects/$enc/merge_requests/$iid" 2>/dev/null) || rc=$? - j=$(printf '%s' "$j" | scrub) + j=$(fm_prstat_api "projects/$enc/merge_requests/$iid") || rc=$? if [ "$rc" -ne 0 ] || [ -z "$j" ]; then fm_prstat_unreachable "$repo" "$iid" "no data" return @@ -143,7 +247,7 @@ fm_prstat_one() { # # The parser emits a lone UNREADABLE sentinel rather than defaulted fields: # `glab api` prints an error body on stdout for a non-2xx, and defaulting # that to "- / - / none" would compare equal and fabricate a head verdict. - read -r state draft sha pipe_sha pipe pipe_source pipe_ref dms conflicts merged </dev/null) EOF @@ -169,9 +274,10 @@ EOF fm_prstat_unreachable "$repo" "$iid" "unreadable response" return fi + [ "$target" != "?" ] || STATUS=1 if [ "$merged" != "-" ] && [ -n "$merged" ]; then - printf '%-34s %-7s %s\n' "$(basename "$repo")!$iid" "MERGED" "on $merged" + printf '%-34s %-7s into=%s on=%s\n' "$(basename "$repo")!$iid" "merged" "$target" "$merged" return fi @@ -190,8 +296,8 @@ EOF # wrong: it is the only place a red external status is visible, and dropping # it on the rows whose badge happens to match would hide exactly the signal # the notes promise to disclose. - praw=$(glab api "projects/$enc/merge_requests/$iid/pipelines?per_page=30" 2>/dev/null) || prc=$? - plook=$(printf '%s' "$praw" | scrub | python3 -c ' + praw=$(fm_prstat_api "projects/$enc/merge_requests/$iid/pipelines?per_page=30") || prc=$? + plook=$(printf '%s' "$praw" | python3 -c ' import json,sys want,badge_ref,badge_source,badge_sha=sys.argv[1:5] def is_merge_result(ref): @@ -273,23 +379,19 @@ print(kind) fi [ -n "$ext_red" ] && notes="${notes:+$notes; }external status red: $ext_red" - # Approval, read from the merge status rather than a third API call: - # approved==true with an empty approved_by list just means none was required. - local appr - case "$dms" in - not_approved) appr="NOT-APPROVED" ;; - draft_status) appr="draft" ;; - mergeable) appr="ok" ;; - conflict) appr="-" ;; - checking) appr="checking" ;; - *) appr="$dms" ;; - esac + fm_prstat_approval "$enc" "$iid" "$dms" + fm_prstat_project "$repo" "$enc" + local jobs must conflict_col=no + [ "$conflicts" = yes ] && conflict_col=YES + read -r jobs must <///-/merge_requests/)" >&2 + STATUS=1 + fi + ;; *!*) repo=$(fm_prstat_repo_path "${1%%!*}") || { STATUS=1; shift; continue; } fm_prstat_one "$repo" "${1##*!}" diff --git a/docs/scripts.md b/docs/scripts.md index 697f7b01588..ca19bfd5f73 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -111,7 +111,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub or GitLab URL | -| `fm-pr-status.sh` | Print one line per GitLab merge request naming whether its pipeline actually ran against the real head | +| `fm-pr-status.sh` | Print one read-only line per GitLab merge request or URL: state, target, approvals, merge status, conflicts, CI on the real head, and project CI settings | | `fm-task-landed.sh` | Print one read-only line per task separating work at risk from untracked leftovers, plus its PR/MR readiness | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | diff --git a/tests/fm-pr-status.test.sh b/tests/fm-pr-status.test.sh index 1e02c981142..d5abd0c71c5 100755 --- a/tests/fm-pr-status.test.sh +++ b/tests/fm-pr-status.test.sh @@ -17,14 +17,17 @@ SCRIPT="$ROOT/bin/fm-pr-status.sh" TMP_ROOT=$(fm_test_tmproot fm-pr-status) # A fake glab that answers `api projects/.../merge_requests/` from -# FM_TEST_MR_JSON and the `.../pipelines?per_page=30` lookup from -# FM_TEST_PIPELINES_JSON, so each case drives fixed fixture bytes with no -# network and no real GitLab host. Every api path it is asked for is appended to -# FM_TEST_GLAB_LOG, so a case can assert which project path was actually -# requested - the observable effect of shortname resolution - and that an -# unresolved shortname produced no request at all. FM_TEST_GLAB_RC lets a case -# drive a non-zero glab exit, and FM_TEST_GLAB_PIPELINES_RC one for the -# pipelines call alone. +# FM_TEST_MR_JSON, the `.../pipelines?per_page=30` lookup from +# FM_TEST_PIPELINES_JSON, the `.../approvals` read from FM_TEST_APPROVALS_JSON, +# and the bare `projects/` read from FM_TEST_PROJECT_JSON, so each case +# drives fixed fixture bytes with no network and no real GitLab host. The last +# two default to "nobody approved, none required" and "CI enabled, pipelines +# must succeed" so a case about something else need not spell them out. Every +# api call's arguments are appended to FM_TEST_GLAB_LOG, so a case can assert +# which project path and host were actually requested - the observable effect +# of shortname and URL resolution - and that an unresolved shortname produced +# no request at all. FM_TEST_GLAB_RC lets a case drive a non-zero glab exit, +# and FM_TEST_GLAB_PIPELINES_RC one for the pipelines call alone. make_case() { local case_dir="$TMP_ROOT/$1" fakebin mkdir -p "$case_dir" @@ -32,11 +35,18 @@ make_case() { cat > "$fakebin/glab" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = api ]; then - [ -n "${FM_TEST_GLAB_LOG:-}" ] && printf '%s\n' "${2:-}" >> "$FM_TEST_GLAB_LOG" - case "${2:-}" in + shift + [ -n "${FM_TEST_GLAB_LOG:-}" ] && printf '%s\n' "$*" >> "$FM_TEST_GLAB_LOG" + case "${1:-}" in *pipelines*) cat "${FM_TEST_PIPELINES_JSON:-/dev/null}" exit "${FM_TEST_GLAB_PIPELINES_RC:-${FM_TEST_GLAB_RC:-0}}" ;; - *) cat "${FM_TEST_MR_JSON:-/dev/null}" ;; + */approvals) + if [ -n "${FM_TEST_APPROVALS_JSON:-}" ]; then cat "$FM_TEST_APPROVALS_JSON" + else printf '{"approved":true,"approvals_required":0,"approvals_left":0,"approved_by":[]}'; fi ;; + */merge_requests/*) cat "${FM_TEST_MR_JSON:-/dev/null}" ;; + *) + if [ -n "${FM_TEST_PROJECT_JSON:-}" ]; then cat "$FM_TEST_PROJECT_JSON" + else printf '{"id":7,"jobs_enabled":true,"builds_access_level":"enabled","only_allow_merge_if_pipeline_succeeds":true}'; fi ;; esac fi exit "${FM_TEST_GLAB_RC:-0}" @@ -56,6 +66,8 @@ run_case() { FM_TEST_GLAB_LOG="${FM_TEST_GLAB_LOG:-}" \ FM_TEST_GLAB_RC="${FM_TEST_GLAB_RC:-0}" \ FM_TEST_GLAB_PIPELINES_RC="${FM_TEST_GLAB_PIPELINES_RC:-}" \ + FM_TEST_APPROVALS_JSON="${FM_TEST_APPROVALS_JSON:-}" \ + FM_TEST_PROJECT_JSON="${FM_TEST_PROJECT_JSON:-}" \ FM_PROJECTS_OVERRIDE="${FM_PROJECTS_OVERRIDE:-}" \ "$SCRIPT" "$@" } @@ -77,7 +89,7 @@ cat > "$case_a/pipelines.json" <<'JSON' JSON out=$(run_case "$case_a" "$case_a/mr.json" "$case_a/pipelines.json" g/a!1) assert_contains "$out" "green(head)" "green(head): a run on the real head is reported" -assert_contains "$out" " ok " "zero-approvals-required: mergeable status reports ok, not NOT-APPROVED" +assert_contains "$out" "approval=not-required" "zero-approvals-required: nobody approved and none required reads not-required, not NOT-APPROVED" assert_not_contains "$out" "NOT-APPROVED" "zero-approvals-required: the approved:false flag alone must not drive the verdict" pass "case A: green(head) verdict and zero-approvals-required mergeable status" @@ -113,7 +125,7 @@ cat > "$case_c/pipelines.json" <<'JSON' JSON out=$(run_case "$case_c" "$case_c/mr.json" "$case_c/pipelines.json" g/c!3) assert_contains "$out" "manual(head)" "manual(head): a head run blocked on manual jobs is not reported as passed" -assert_contains "$out" "CONFLICTS" "a real conflict is surfaced in the notes" +assert_contains "$out" "conflicts=YES" "a real conflict is surfaced in its own column" pass "case C: manual(head) verdict and a real conflict" # --- fixture D: badge is a merge-result run and it failed; nothing ever ran @@ -161,7 +173,7 @@ cat > "$case_f/mr.json" <<'JSON' "head_pipeline":{"sha":"ffffffff1111","status":"success"}} JSON out=$(run_case "$case_f" "$case_f/mr.json" "" g/f!6) -assert_contains "$out" "MERGED" "an already-merged request reports MERGED" +assert_contains "$out" " merged " "an already-merged request reports merged" pass "case F: a merged merge request short-circuits to MERGED" # --- fixture G: a raw control character in MR text must not break parsing - @@ -580,7 +592,7 @@ advertised=$(printf '%s\n' "$help" \ # Every verdict the tool actually prints, taken from the verdict column of the # rows the verdict fixtures above produce. -verdict_of() { printf '%s\n' "$1" | awk 'NF {print $4}'; } +verdict_of() { printf '%s\n' "$1" | awk 'NF {sub(/^ci=/, "", $4); print $4}'; } printed=$( { verdict_of "$(run_case "$case_a" "$case_a/mr.json" "$case_a/pipelines.json" g/a!1)" verdict_of "$(run_case "$case_b" "$case_b/mr.json" "$case_b/pipelines.json" g/b!2)" @@ -602,3 +614,134 @@ emitted=$(run_case "$case_a" "$case_a/mr.json" "$case_a/pipelines.json" g/a!1 | [ "$documented" = "$emitted" ] || fail \ "--help documents $documented mandatory output columns but a notes-free row emits $emitted fields" pass "case AC: --help describes the verdicts and columns the tool really emits" + +# A green open merge request on the real head, shared by the cases below that +# are about something other than the pipeline verdict. +write_green_mr() { # + cat > "$1" < "$case_ad/pipelines.json" +: > "$case_ad/glab.log" +out=$(FM_TEST_GLAB_LOG="$case_ad/glab.log" run_case "$case_ad" "$case_ad/mr.json" "$case_ad/pipelines.json" \ + https://git.example.org/acme/tools/widget/-/merge_requests/30); rc=$? +expect_code 0 "$rc" "a fully readable URL row exits cleanly" +assert_contains "$out" "widget!30" "the URL's project is reported under its own name" +assert_contains "$out" "into=release/2.x" "the target branch is reported" +assert_contains "$out" "ci=green(head)" "a URL row gets the same head verdict" +assert_grep "projects/acme%2Ftools%2Fwidget/merge_requests/30 --hostname git.example.org" "$case_ad/glab.log" \ + "the merge request is read from the URL's own host" +assert_grep "projects/acme%2Ftools%2Fwidget --hostname git.example.org" "$case_ad/glab.log" \ + "the project settings are read from the URL's own host" +[ "$(grep -c -v -- '--hostname git.example.org' "$case_ad/glab.log")" = 0 ] \ + || fail "every read for a URL row must name the URL's host" +pass "case AD: a full URL is read from its own host and reports the target branch" + +# --- fixture AE: a URL that is not a GitLab merge request is refused before +# any read, rather than guessed at. ----------------------------------------- +case_ae=$(make_case ae) +: > "$case_ae/glab.log" +out=$(FM_TEST_GLAB_LOG="$case_ae/glab.log" run_case "$case_ae" "" "" \ + https://github.com/acme/widget/pull/3 2>&1); rc=$? +expect_code 1 "$rc" "a non-GitLab URL fails the run" +assert_contains "$out" "not a GitLab merge request URL" "the refusal names what was expected" +[ ! -s "$case_ae/glab.log" ] || fail "a refused URL must not reach glab" +pass "case AE: a GitHub pull request URL is refused without a read" + +# --- fixture AF: approvals. "approved" and "no approval required" look the +# same through detailed_merge_status=mergeable, so the approvals read has to +# tell them apart, and each other outcome keeps its own name. -------------- +case_af=$(make_case af) +write_green_mr "$case_af/mr.json" 31 +printf '[{"id":1,"sha":"abcd000031","ref":"feature/x","status":"success","source":"push"}]' \ + > "$case_af/pipelines.json" +approval_of() { # [] + printf '%s' "$1" > "$case_af/approvals.json" + if [ -n "${2:-}" ]; then + sed "s/\"mergeable\"/\"$2\"/" "$case_af/mr.json" > "$case_af/mr-dms.json" + else + cp "$case_af/mr.json" "$case_af/mr-dms.json" + fi + FM_TEST_APPROVALS_JSON="$case_af/approvals.json" \ + run_case "$case_af" "$case_af/mr-dms.json" "$case_af/pipelines.json" g/af!31 +} +out=$(approval_of '{"approved":true,"approvals_left":0,"approved_by":[{"user":{"username":"a"}},{"user":{"username":"b"}}]}') +assert_contains "$out" "approval=approved(2)" "two sign-offs with none left read approved(2)" +out=$(approval_of '{"approved":true,"approvals_required":0,"approvals_left":0,"approved_by":[]}') +assert_contains "$out" "approval=not-required" "no sign-off and none required reads not-required, never approved" +assert_not_contains "$out" "approved(" "an approved:true flag with an empty approved_by is not an approval" +out=$(approval_of '{"approved":false,"approvals_required":2,"approvals_left":1,"approved_by":[{"user":{"username":"a"}}]}' not_approved) +assert_contains "$out" "approval=NOT-APPROVED(1-left)" "a still-required approval is reported with how many are left" +out=$(approval_of '{"approved":true,"approved_by":[{"user":{"username":"a"}}]}' not_approved) +assert_contains "$out" "approval=NOT-APPROVED" "detailed_merge_status=not_approved wins over a partial sign-off" +out=$(approval_of '{"approved":false,"approved_by":[]}' ci_must_pass) +assert_contains "$out" "approval=none-given" "no sign-off with an unknown requirement is not claimed as not-required" +out=$(approval_of 'not json'); rc=$? +assert_contains "$out" "approval=UNVERIFIED" "an unreadable approvals read makes no approval claim" +expect_code 1 "$rc" "an unreadable approvals read fails the run" +out=$(approval_of 'not json' not_approved) +assert_contains "$out" "approval=NOT-APPROVED" "not_approved is still reported when the approvals read fails" +assert_contains "$out" "merge=not_approved" "detailed_merge_status is reported verbatim" +pass "case AF: approvals distinguish approved, not required, still required, and unreadable" + +# --- fixture AG: the project's own CI settings. A project whose builds are +# disabled can never produce a pipeline, "Pipelines must succeed" is shown as +# set, and a project that cannot be read makes no claim either way. The +# project is read once for consecutive rows of the same project. ----------- +case_ag=$(make_case ag) +write_green_mr "$case_ag/mr.json" 32 +printf '[{"id":1,"sha":"abcd000032","ref":"feature/x","status":"success","source":"push"}]' \ + > "$case_ag/pipelines.json" +printf '{"id":9,"jobs_enabled":true,"builds_access_level":"disabled","only_allow_merge_if_pipeline_succeeds":false}' \ + > "$case_ag/project.json" +: > "$case_ag/glab.log" +out=$(FM_TEST_GLAB_LOG="$case_ag/glab.log" FM_TEST_PROJECT_JSON="$case_ag/project.json" \ + run_case "$case_ag" "$case_ag/mr.json" "$case_ag/pipelines.json" --repo g/ag 32 32) +assert_contains "$out" "jobs=DISABLED" "builds_access_level=disabled reports CI as disabled even with jobs_enabled=true" +assert_contains "$out" "must-succeed=no" "an unset Pipelines must succeed is reported as no" +[ "$(grep -c '^projects/g%2Fag$' "$case_ag/glab.log")" = 1 ] \ + || fail "consecutive rows of one project must read its settings once" +out=$(run_case "$case_ag" "$case_ag/mr.json" "$case_ag/pipelines.json" g/ag!32) +assert_contains "$out" "jobs=enabled must-succeed=yes" "an enabled project with the setting on reports both" +printf '{"message":"404 Project Not Found"}' > "$case_ag/project-err.json" +out=$(FM_TEST_PROJECT_JSON="$case_ag/project-err.json" \ + run_case "$case_ag" "$case_ag/mr.json" "$case_ag/pipelines.json" g/ag!32); rc=$? +assert_contains "$out" "jobs=? must-succeed=?" "an unreadable project makes no CI-settings claim" +expect_code 1 "$rc" "an unreadable project fails the run" +printf '{"id":9,"name":"ag","visibility":"public"}' > "$case_ag/project-reduced.json" +out=$(FM_TEST_PROJECT_JSON="$case_ag/project-reduced.json" \ + run_case "$case_ag" "$case_ag/mr.json" "$case_ag/pipelines.json" g/ag!32); rc=$? +assert_contains "$out" "jobs=? must-succeed=?" "a reduced project view that omits the settings makes no claim" +expect_code 1 "$rc" "a project view that omits the CI settings fails the run" +printf '{"id":9,"builds_access_level":"private","only_allow_merge_if_pipeline_succeeds":true}' \ + > "$case_ag/project-bal.json" +out=$(FM_TEST_PROJECT_JSON="$case_ag/project-bal.json" \ + run_case "$case_ag" "$case_ag/mr.json" "$case_ag/pipelines.json" g/ag!32); rc=$? +assert_contains "$out" "jobs=enabled must-succeed=yes" "builds_access_level=private alone reports CI as enabled" +expect_code 0 "$rc" "a project settled by builds_access_level exits cleanly" +pass "case AG: project CI capability and Pipelines must succeed are reported, or ? when unread" + +# --- fixture AH: a merged merge request still names its target branch. ----- +case_ah=$(make_case ah) +cat > "$case_ah/mr.json" <<'JSON' +{"iid":33,"state":"merged","sha":"abcd000033","target_branch":"main", + "merged_at":"2026-09-01T10:00:00Z","head_pipeline":null} +JSON +out=$(run_case "$case_ah" "$case_ah/mr.json" "" g/ah!33) +assert_contains "$out" "merged into=main on=2026-09-01" "a merged row names its target branch and date" +sed 's/"target_branch":"main",//' "$case_ah/mr.json" > "$case_ah/mr-notarget.json" +out=$(run_case "$case_ah" "$case_ah/mr-notarget.json" "" g/ah!33); rc=$? +assert_contains "$out" "into=?" "a response naming no target branch reports it as unknown" +expect_code 1 "$rc" "an unknown target branch fails the run" +pass "case AH: a merged merge request reports its target branch"