diff --git a/.github/scripts/dor_reconcile.sh b/.github/scripts/dor_reconcile.sh index 6c6405187..8f91fe88a 100644 --- a/.github/scripts/dor_reconcile.sh +++ b/.github/scripts/dor_reconcile.sh @@ -29,9 +29,31 @@ ACTIVE_STALL_MIN="${ACTIVE_STALL_MIN:-20}" HEALTH_TITLE="${HEALTH_TITLE:-DoR pipeline health}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" now="$(date -u +%s)" + +# Field separator for every multi-field record this script builds. US, not tab: tab is an *IFS +# whitespace* character, so `read`/`awk` collapse a run of tabs into ONE delimiter and an empty field +# in the middle of a record silently shifts every field after it left. Most records here have two +# optional label fields, so that is the common case, not the corner case. Neither an issue number, +# a board Status nor a GitHub label name can contain a control character. +US=$'\x1f' + exceptions="" # markdown bullet lines +ex_keys="" # one stable identity per exception β€” see the fingerprint in Β§4 stalled="" # issue numbers whose "Building" has no live run behind it -add_ex() { exceptions="${exceptions}- ${1}"$'\n'; } + +# Record an exception, and alongside it a STABLE identity for that exception. The report re-states +# the same findings every sweep and most lines carry a moving age ("untouched for 17h"), so the text +# itself cannot answer "has anything actually changed?" β€” which is what decides whether to notify. +# Identity is the marker plus the issue the line is about: "πŸ•³οΈ#941". A line naming no issue (🚦, ⚠️) +# is identified by its marker plus every number in it, because for those the count IS the finding β€” +# "🚦3" must read as different from "🚦2", while the timestamps around it must not. +add_ex() { + local msg="$1" num + exceptions="${exceptions}- ${msg}"$'\n' + num="$(printf '%s' "$msg" | grep -oE '#[0-9]+' | head -n 1 || true)" + [ -n "$num" ] || num="$(printf '%s' "$msg" | grep -oE '[0-9]+' | tr -d '\n' || true)" + ex_keys="${ex_keys}${msg%% *}${num}"$'\n' +} # state:* label -> Status column name (must match dor_set_status.sh + the board options). label_to_status() { @@ -47,18 +69,30 @@ label_to_status() { esac } -# 1. Board snapshot: one TSV line per item -> "\t\t". -# 65 items today; first:100 covers it. Warn (don't silently truncate) if it ever overflows. +# 1. Board snapshot: one US-separated record per item β€” +# number, OPEN|CLOSED, Status, created epoch, updated epoch, needs-vouch, sk label, state label, is-bug +# The issue's own metadata rides along so Β§2 can walk BOARD MEMBERSHIP instead of a label query. +# 74 items today; first:100 covers it. Warn (don't silently truncate) if it ever overflows. board="$(gh api graphql \ -f query='query($p:ID!){ node(id:$p){ ... on ProjectV2 { items(first:100){ pageInfo{ hasNextPage } - nodes{ content{ ... on Issue { number state } } + nodes{ content{ ... on Issue { number state createdAt updatedAt labels(first:50){ nodes{ name } } } } status: fieldValueByName(name:"Status"){ ... on ProjectV2ItemFieldSingleSelectValue { name } } } } } } }' \ -f p="$PROJECT_ID" \ --jq '.data.node.items as $i - | (if $i.pageInfo.hasNextPage then "::warn-truncated::\t\t\n" else "" end) + | (if $i.pageInfo.hasNextPage then "::warn-truncated::\n" else "" end) + ([$i.nodes[] | select(.content.number != null) - | [(.content.number|tostring), .content.state, (.status.name // "")] | @tsv] | join("\n"))')" + | [.content.labels.nodes[].name] as $l + | [(.content.number|tostring), + .content.state, + (.status.name // ""), + (.content.createdAt | fromdateiso8601 | tostring), + (.content.updatedAt | fromdateiso8601 | tostring), + (($l | index("needs-vouch")) != null | tostring), + ([$l[] | select(startswith("sk:"))][0] // ""), + ([$l[] | select(startswith("state:"))][0] // ""), + (($l | index("bug")) != null | tostring)] + | join("'"$US"'")] | join("\n"))')" if printf '%s\n' "$board" | grep -q '^::warn-truncated::'; then add_ex "⚠️ Board has >100 items β€” reconcile only inspected the first 100. Add pagination." @@ -96,8 +130,15 @@ has_live_run() { # $1 = issue number } # Look up an issue's board Status by number (empty if not on the board). -board_status_of() { printf '%s\n' "$board" | awk -F'\t' -v n="$1" '$1==n {print $3; found=1} END{exit !found}' 2>/dev/null || true; } -on_board() { printf '%s\n' "$board" | awk -F'\t' -v n="$1" '$1==n {f=1} END{exit !f}'; } +board_status_of() { printf '%s\n' "$board" | awk -F"$US" -v n="$1" '$1==n {print $3; found=1} END{exit !found}' 2>/dev/null || true; } +on_board() { printf '%s\n' "$board" | awk -F"$US" -v n="$1" '$1==n {f=1} END{exit !f}'; } + +# Which board owns an issue is decided by its labels, exactly as dor_set_status.sh decides where to +# write: `bug` -> Bug Pipeline, everything else -> Feature Pipeline. Mirrored here rather than +# re-derived, so the two cannot drift. $LABEL says which pipeline this sweep is running for. +belongs_here() { # $1 = "true" when the issue carries the `bug` label + if [ "$LABEL" = bug ]; then [ "$1" = true ]; else [ "$1" != true ]; fi +} # Is this board Status one the BUILD side owns? Those phases are tracked on the board alone β€” the # build drops the issue's `state:*` label (dor_build_flow.sh) and never restores it β€” so a @@ -110,33 +151,56 @@ build_phase() { esac } -# 2. Walk every OPEN enhancement issue. Capture the list first so a transient failure aborts under -# set -e rather than silently reporting "healthy". +# 2. Walk every OPEN issue THIS BOARD carries, plus every open `$LABEL` issue missing from it. +# +# MEMBERSHIP decides what gets swept, not the gate label. Status is the canonical phase (D3), yet +# the walk used to start from `gh issue list --label "$LABEL"`, so an issue could sit on the board +# in any phase and be invisible to every check below. That was not a corner case: 42 of the +# Feature board's 74 items carried no `enhancement` label β€” a whole UI cohort parked in "Awaiting +# design", most of the "Out of pipeline" column, and an "Awaiting approval" item that the 🚦 count +# therefore under-reported (2 where a human counted 3). The label query survives for the one thing +# the board cannot show us: an issue that ought to be on it and is not. # -# Records are joined on US (\x1f), NOT tabs. Tab is an *IFS whitespace* character, so under -# `IFS=$'\t'` bash collapses a run of tabs into ONE delimiter and an empty field in the middle of -# the record silently shifts every field after it left. `sk_label` is empty on almost every issue -# (only a live build holds a sidekick), so the overwhelmingly common record β€” -# `…state:decompose` β€” parsed as sk_label='state:decompose', state_label='' and made -# every correctly-routed issue look un-routed. US is not IFS whitespace, so empty fields survive. -# Neither an issue number nor a GitHub label name can contain a control character. +# Capture both lists first so a transient failure aborts under set -e rather than silently +# reporting "healthy". issues_rows="$(gh issue list --repo "$OWNER/$REPO" --state open --label "$LABEL" --limit 201 \ --json number,labels,createdAt,updatedAt \ - --jq '.[] | [(.number|tostring), - (.createdAt | fromdateiso8601 | tostring), - (.updatedAt | fromdateiso8601 | tostring), - ((([.labels[].name] | index("needs-vouch")) != null) | tostring), - ([.labels[].name | select(startswith("sk:"))][0] // ""), - ([.labels[].name | select(startswith("state:"))][0] // "")] | join("\u001f")')" + --jq '.[] | [.labels[].name] as $l + | [(.number|tostring), + (.createdAt | fromdateiso8601 | tostring), + (.updatedAt | fromdateiso8601 | tostring), + (($l | index("needs-vouch")) != null | tostring), + ([$l[] | select(startswith("sk:"))][0] // ""), + ([$l[] | select(startswith("state:"))][0] // ""), + (($l | index("bug")) != null | tostring)] | join("'"$US"'")')" if [ "$(printf '%s' "$issues_rows" | grep -c .)" -ge 201 ]; then add_ex "⚠️ Over 200 open ${LABEL} issues β€” reconcile inspected only the first 200; add pagination." issues_rows="$(printf '%s\n' "$issues_rows" | head -n 200)" fi + +# Board rows first β€” they already carry the Status β€” then any labelled issue the board is missing. +# First occurrence of an issue number wins, so an issue in both lists is walked exactly once. +walk_rows="$( { printf '%s\n' "$board" | awk -F"$US" 'BEGIN{OFS=FS} $2=="OPEN" {print $1,$4,$5,$6,$7,$8,$9}' + printf '%s\n' "$issues_rows"; } | awk -F"$US" '$1 != "" && !seen[$1]++')" + approval_backlog=0 -while IFS=$'\x1f' read -r num created_epoch updated_epoch needs_vouch sk_label state_label; do +while IFS="$US" read -r num created_epoch updated_epoch needs_vouch sk_label state_label is_bug; do [ -n "$num" ] || continue status="$(board_status_of "$num")" + # SKIP/FLAG: this board does not own the issue. An item filed on the wrong board is a leftover β€” + # #819 sat on the Feature board at "Blocked (external)" while the Bug board had it at "Awaiting + # functional acceptance", and neither sweep could see the disagreement. Caught here because, with + # the walk now driven by membership, it would otherwise fall through and be reported as un-routed + # β€” "the agent likely never ran", when the real fault is where it is filed. If it is not on this + # board either, stay quiet: the other pipeline's sweep owns it. + if ! belongs_here "$is_bug"; then + if on_board "$num"; then + add_ex "🧭 #${num} sits on this board but its labels route it to the other one (\`bug\` β†’ Bug Pipeline, otherwise β†’ Feature Pipeline) β€” remove the stale board item, or fix the labels." + fi + continue + fi + # An external request nobody has accepted yet. Do NOT heal it onto the board: it has no requestor # of record, so parking it at "Awaiting requestor" would read as "waiting on the reporter" when # it is really waiting on us. Flag it every sweep instead β€” an unanswered customer request should @@ -220,7 +284,7 @@ while IFS=$'\x1f' read -r num created_epoch updated_epoch needs_vouch sk_label s [ -z "$open_pr" ] && add_ex "🧟 #${num} still claims \`${sk_label}\` with no open PR β€” that box is probably holding a stale env. Release it, or drop the label if it already was." fi fi -done < <(printf '%s\n' "$issues_rows") +done < <(printf '%s\n' "$walk_rows") [ "$approval_backlog" -gt 0 ] && add_ex "🚦 ${approval_backlog} issue(s) waiting in **Awaiting approval** β€” the Product board's value gate." @@ -228,7 +292,7 @@ done < <(printf '%s\n' "$issues_rows") # "Out of pipeline" is NOT terminal here, deliberately: an issue that left the pipeline and has since # been closed still has to be walked over to Done, and this line is the only reminder that it is # sitting there. Done is the single resting state on the board. -while IFS=$'\t' read -r num istate status; do +while IFS="$US" read -r num istate status _rest; do [ "$istate" = "CLOSED" ] || continue case "$status" in ""|"Done") : ;; *) add_ex "πŸ”š #${num} is CLOSED but still on the board as **${status}** β€” move it to Done or off the board." ;; esac done < <(printf '%s\n' "$board") @@ -262,24 +326,51 @@ done health_num="$(gh issue list --repo "$OWNER/$REPO" --state all --limit 100 \ --json number,title --jq "[.[] | select(.title==\"$HEALTH_TITLE\") | .number][0] // empty")" +# WHICH exceptions are open, independent of how they are worded. Sorted, so a reordering is not +# "news". Carried in the body as an HTML comment (invisible when rendered) so the next sweep can read +# back what the last one found without a second store. This is what lets the report notify on CHANGE +# rather than on schedule: the body is refreshed every sweep β€” editing it does not notify β€” but a +# comment, which does, is only posted when this string differs from last time. A standing backlog +# then costs one notification instead of one per hour, which is the whole complaint. +fingerprint="$(printf '%s' "$ex_keys" | grep . | LC_ALL=C sort | tr '\n' ' ' || true)" +fingerprint="${fingerprint% }" + stamp="$(date -u +'%Y-%m-%d %H:%M UTC')" if [ -z "$exceptions" ]; then body="_Last swept ${stamp}: βœ… pipeline healthy β€” nothing stuck or drifted._" else - body="$(printf '_Last swept %s β€” %s item(s) need attention:_\n\n%s' "$stamp" "$(printf '%s' "$exceptions" | grep -c '^- ')" "$exceptions")" + body="$(printf '_Last swept %s β€” %s item(s) need attention:_\n\n%s\n' \ + "$stamp" "$(printf '%s' "$exceptions" | grep -c '^- ')" "$exceptions" "$fingerprint")" fi if [ -n "$health_num" ]; then + # What did the last sweep leave behind? Its state decides whether we have to reopen; its + # fingerprint decides whether any of this is news. GitHub stores bodies with CRLF, so strip the + # carriage returns before matching the line. + prev="$(gh issue view "$health_num" --repo "$OWNER/$REPO" --json state,body \ + --jq '.state + "'"$US"'" + (.body // "")' 2>/dev/null || true)" + prev_state="${prev%%"$US"*}" + prev_fp="$(printf '%s' "${prev#*"$US"}" | tr -d '\r' \ + | sed -n 's/^$/\1/p' | head -n 1 || true)" + gh issue edit "$health_num" --repo "$OWNER/$REPO" --body "$body" >/dev/null if [ -n "$exceptions" ]; then - # Something to act on β€” surface it (reopen if closed, comment so it hits notifications). - gh issue reopen "$health_num" --repo "$OWNER/$REPO" >/dev/null 2>&1 || true - gh issue comment "$health_num" --repo "$OWNER/$REPO" --body "$body" >/dev/null + # Something to act on β€” surface it. A closed health issue is out of sight, so reopening always + # warrants the comment that goes with it. + if [ "$prev_state" = "CLOSED" ]; then + gh issue reopen "$health_num" --repo "$OWNER/$REPO" >/dev/null 2>&1 || true + fi + if [ "$prev_state" = "CLOSED" ] || [ "$fingerprint" != "$prev_fp" ]; then + gh issue comment "$health_num" --repo "$OWNER/$REPO" --body "$body" >/dev/null + echo "::notice::health issue #${health_num}: exception set changed β€” commented" + else + echo "::notice::health issue #${health_num}: same exceptions as last sweep β€” body refreshed silently" + fi else # Healthy β€” close it so it stays out of the way until the next issue arises. gh issue close "$health_num" --repo "$OWNER/$REPO" >/dev/null 2>&1 || true + echo "::notice::health issue #${health_num}: healthy β€” closed" fi - echo "::notice::updated health issue #${health_num}" elif [ -n "$exceptions" ]; then # No state:* / enhancement label β€” this meta issue must not enter the pipeline or the board. gh issue create --repo "$OWNER/$REPO" --title "$HEALTH_TITLE" --body "$body" >/dev/null diff --git a/changes/reconcile-board-driven-walk.md b/changes/reconcile-board-driven-walk.md new file mode 100644 index 000000000..fc0768977 --- /dev/null +++ b/changes/reconcile-board-driven-walk.md @@ -0,0 +1,3 @@ +- The pipeline health report now inspects every issue its board carries, instead of only those carrying the `enhancement` / `bug` gate label. 42 of the Feature board's 74 items had no gate label and were invisible to every check in the sweep β€” a whole UI cohort parked in "Awaiting design", most of the "Out of pipeline" column, and an issue the "Awaiting approval" count therefore missed, reporting 2 where the board showed 3. +- Added a check for an issue filed on the wrong pipeline board. One issue sat on the Feature board as "Blocked (external)" while the Bug board had it at "Awaiting functional acceptance"; each sweep only ever looked at its own board, so nothing could see the disagreement. +- The health report now notifies only when its findings actually change. It used to post a comment on every hourly sweep for as long as anything was open, so a standing backlog meant a notification every hour β€” and most findings restate a moving age, so the text was never identical. The report itself is still refreshed silently on every sweep. diff --git a/test/ci-scripts/test-dor-reconcile-liveness.sh b/test/ci-scripts/test-dor-reconcile-liveness.sh index 53e6d3c19..584b2fe50 100644 --- a/test/ci-scripts/test-dor-reconcile-liveness.sh +++ b/test/ci-scripts/test-dor-reconcile-liveness.sh @@ -4,19 +4,20 @@ # The sweep is the only thing that notices a build whose sidekick died mid-flight β€” the flow dies # with the box, so it never reaches its own error handling. That detector (πŸ’€, #963) was unreachable # for its entire target population for weeks (#995): the "no `state:*` label" branch `continue`d -# before the liveness check, and the build side deliberately runs without a state label. Nothing -# tested this file, which is why it shipped shadowed and stayed that way. +# before the liveness check, and the build side deliberately runs without a state label. # -# The complementary failure was the record encoding itself. `@tsv` + `IFS=$'\t'` collapses a run of -# tabs (tab is IFS *whitespace*), so an empty `sk_label` in the middle of the record shifted every -# field after it: `state_label` came out empty on every issue that had a state label but no sidekick -# β€” i.e. nearly all of them. Every correctly-routed issue was reported "un-routed", and the drift -# check, which needs the label, never ran at all. The first version of this harness could not see it -# because the fixture hand-encoded the record; the stub now serves JSON and applies the script's OWN -# --jq program to it, so the jq β†’ `read` contract is under test rather than duplicated here. +# Two more failures followed from how the sweep gathered its facts, and both were invisible to a +# fixture that pre-rendered records: # -# Approach: put a stub `gh` on PATH that serves fixtures and records writes, then run the REAL -# script end to end and assert on the health-report body it produces. No network, no tokens. +# * `@tsv` + `IFS=$'\t'` collapses a run of tabs (tab is IFS *whitespace*), so an empty `sk_label` +# in the middle of a record shifted every field after it and `state_label` came out empty on +# nearly every issue. Every routed issue was reported "un-routed"; the drift check never ran. +# * the walk started from `gh issue list --label enhancement`, so 42 of the Feature board's 74 +# items β€” everything without the gate label β€” were invisible to every check in it. +# +# So the stub does NOT hand back pre-rendered records. It serves the JSON `gh` would return and runs +# the script's OWN --jq program over it, which puts the record contract itself under test. No +# network, no tokens. # # Usage: bash test/ci-scripts/test-dor-reconcile-liveness.sh @@ -26,7 +27,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" SCRIPT="$REPO_ROOT/.github/scripts/dor_reconcile.sh" command -v jq >/dev/null 2>&1 || { - echo "jq is required: the stub applies the script's real --jq program to the JSON fixtures." >&2 + echo "jq is required: the stub applies the script's real --jq programs to the JSON fixtures." >&2 exit 1 } @@ -61,7 +62,7 @@ assert_lacks() { # ── The stub ──────────────────────────────────────────────────────────────── # Dispatches on the argument shape of each call the script makes, serving files from $FIX and -# appending every write to $FIX/writes.log so a test can assert on what reached the issue. +# appending every write to $FIX/writes.log so a test can assert on what reached GitHub. make_stub() { local dir="$1" mkdir -p "$dir/bin" @@ -69,31 +70,34 @@ make_stub() { #!/usr/bin/env bash args="$*" log() { printf '%s\n' "$*" >> "$FIX/writes.log"; } +# Apply the CALLER'S OWN --jq program to a JSON fixture, so the record contract is exercised rather +# than hand-copied into the fixture. jq on Windows writes CRLF while real `gh` returns LF, so +# normalise: a stray \r rides along in the last field of every record and makes a local run disagree +# with CI about what the script parsed. +jq_arg() { local prev=""; for a in "$@"; do [ "$prev" = "--jq" ] && { printf '%s' "$a"; return 0; }; prev="$a"; done; } +serve() { local f="$1"; shift; jq -r "$(jq_arg "$@")" "$FIX/$f" | tr -d '\r'; } case "$args" in - # Board snapshot: "\t\t" - "api graphql"*) cat "$FIX/board.tsv" ;; + # Board snapshot (number, state, Status, dates and labels per item). + "api graphql"*) serve board.json "$@" ;; # Live DoR workflow runs β€” display_title lines, one per run. - *"actions/runs?status="*) cat "$FIX/live_runs.txt" ;; - # has_live_run's title lookup β€” `gh issue view --repo … --json title`, so the number is $3. - "issue view"*"--json title"*) sed -n "s/^$3\t//p" "$FIX/titles.tsv" ;; - # The open-issue walk. Serve the JSON real `gh` would return and run the script's OWN --jq program - # over it, so the record encoding is exercised instead of being hand-copied into the fixture. - *"--state open --label dor-stuck"*) cat "$FIX/marked.txt" 2>/dev/null || true ;; - *"--state open --label"*"--limit 201"*) - jq_prog=""; prev="" - for a in "$@"; do - if [ "$prev" = "--jq" ]; then jq_prog="$a"; break; fi - prev="$a" - done - jq -r "$jq_prog" "$FIX/issues.json" - ;; - *"--state closed --label"*) : ;; # no closed issues claiming a sidekick - *"--state all --limit 100"*) : ;; # no health issue yet -> the script creates one - "pr list"*) : ;; # no open PR (zombie check) - "issue create"*) log "CREATE_BODY: $*" ;; - "issue comment"*) log "COMMENT: $*" ;; - "issue edit"*) log "EDIT: $*" ;; - *) : ;; + *"actions/runs?status="*) cat "$FIX/live_runs.txt" ;; + # has_live_run's title lookup β€” `gh issue view … --json title`, so the number is $3. + "issue view"*"--json title"*) sed -n "s/^$3\t//p" "$FIX/titles.tsv" ;; + # The previous sweep's health issue: its state and the fingerprint buried in its body. + "issue view"*"--json state,body"*) serve health.json "$@" ;; + *"--state open --label dor-stuck"*) cat "$FIX/marked.txt" 2>/dev/null || true ;; + # The gate-label query β€” only issues MISSING from the board still matter to the walk. + *"--state open --label"*"--limit 201"*) serve issues.json "$@" ;; + *"--state closed --label"*) : ;; # no closed issues claiming a sidekick + # Does a health issue already exist? + *"--state all --limit 100"*) serve health_list.json "$@" ;; + "pr list"*) : ;; # no open PR (zombie check) + "issue create"*) log "CREATE_BODY: $*" ;; + "issue comment"*) log "COMMENT: $*" ;; + "issue reopen"*) log "REOPEN: $*" ;; + "issue close"*) log "CLOSE: $*" ;; + "issue edit"*) log "EDIT: $*" ;; + *) : ;; esac exit 0 STUB @@ -111,27 +115,70 @@ run_sweep() { sed -n '/^CREATE_BODY: /,$p' "$fix/writes.log" 2>/dev/null || true } -# Build a fixture dir holding ONE open issue (#370). -# $2 board Status $3 minutes since the issue was last updated $4 "live" to give it a live run +# ── Fixture pieces ────────────────────────────────────────────────────────── +# One board item, as the GraphQL node the script reads. +board_node() { # $1 number $2 OPEN|CLOSED $3 Status ('' = no Status set) $4 created $5 updated $6 labels csv + jq -cn --argjson n "$1" --arg s "$2" --arg st "$3" --argjson c "$4" --argjson u "$5" --arg l "$6" \ + '{ content: { number: $n, state: $s, createdAt: ($c|todate), updatedAt: ($u|todate), + labels: { nodes: [ $l | split(",")[] | select(. != "") | {name: .} ] } }, + status: (if $st == "" then null else {name: $st} end) }' +} + +# Wrap board nodes into the response envelope the query returns. +board_doc() { # $@ = node JSON objects + if [ "$#" -eq 0 ]; then echo '{"data":{"node":{"items":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}'; return; fi + printf '%s\n' "$@" | jq -s '{data:{node:{items:{pageInfo:{hasNextPage:false}, nodes: .}}}}' +} + +# One issue as the gate-label query returns it. +issue_node() { # $1 number $2 created $3 updated $4 labels csv + jq -cn --argjson n "$1" --argjson c "$2" --argjson u "$3" --arg l "$4" \ + '{ number: $n, createdAt: ($c|todate), updatedAt: ($u|todate), + labels: [ $l | split(",")[] | select(. != "") | {name: .} ] }' +} + +# Give the fixture a health issue as a previous sweep left it, so Β§4 has something to compare against. +seed_health() { # $1 dir $2 OPEN|CLOSED $3 body + jq -cn '[{number: 886, title: "DoR pipeline health"}]' > "$1/health_list.json" + jq -cn --arg s "$2" --arg b "$3" '{state: $s, body: $b}' > "$1/health.json" +} + +# Build a fixture dir holding ONE issue (#370). +# $2 board Status ('' = on the board with no Status) $3 minutes since its last update +# $4 "live" to make a DoR run look alive for it # $5 its `sk:*` label ('' for none) $6 its `state:*` label ('' for none, as the build side leaves it) +# $7 "nogate" to drop the `enhancement` gate label, so ONLY the board knows about it +# $8 "bug" to add the `bug` label $9 "offboard" to leave it off the board entirely scenario() { local dir="$1" status="$2" upd_min="$3" live="${4:-}" sk="${5-sk:sk3}" state="${6-}" - local now created updated + local gate="${7-}" bug="${8-}" place="${9-}" + local now created updated labels="" now="$(date -u +%s)" created=$(( now - 3600 * 1000 )) # ancient: opened ~42 days ago, like #370 updated=$(( now - 60 * upd_min )) + if [ "$gate" != nogate ]; then labels="enhancement"; fi + if [ "$bug" = bug ]; then labels="${labels:+$labels,}bug"; fi + if [ -n "$sk" ]; then labels="${labels:+$labels,}$sk"; fi + if [ -n "$state" ]; then labels="${labels:+$labels,}$state"; fi + rm -rf "$dir"; mkdir -p "$dir" make_stub "$dir" : > "$dir/writes.log" - printf '370\tOPEN\t%s\n' "$status" > "$dir/board.tsv" - printf '370\tCollapse managed resources\n' > "$dir/titles.tsv" - jq -n --argjson c "$created" --argjson u "$updated" --arg sk "$sk" --arg st "$state" \ - '[{ number: 370, createdAt: ($c|todate), updatedAt: ($u|todate), - labels: ([{name:"enhancement"}] - + (if $sk == "" then [] else [{name:$sk}] end) - + (if $st == "" then [] else [{name:$st}] end)) }]' > "$dir/issues.json" + printf '370\tCollapse managed resources\n' > "$dir/titles.tsv" + printf '[]\n' > "$dir/health_list.json" + + if [ "$place" = offboard ]; then + board_doc > "$dir/board.json" + else + board_doc "$(board_node 370 OPEN "$status" "$created" "$updated" "$labels")" > "$dir/board.json" + fi + if [ "$gate" = nogate ]; then + printf '[]\n' > "$dir/issues.json" + else + issue_node 370 "$created" "$updated" "$labels" | jq -s . > "$dir/issues.json" + fi if [ "$live" = "live" ]; then - printf 'Collapse managed resources\n' > "$dir/live_runs.txt" + printf 'Collapse managed resources\n' > "$dir/live_runs.txt" else : > "$dir/live_runs.txt" fi @@ -145,8 +192,9 @@ closed_scenario() { local dir="$1" status="$2" rm -rf "$dir"; mkdir -p "$dir"; make_stub "$dir" : > "$dir/writes.log"; : > "$dir/live_runs.txt"; : > "$dir/titles.tsv" - printf '370\tCLOSED\t%s\n371\tCLOSED\tBuilding\n' "$status" > "$dir/board.tsv" - printf '[]\n' > "$dir/issues.json" + printf '[]\n' > "$dir/issues.json"; printf '[]\n' > "$dir/health_list.json" + board_doc "$(board_node 370 CLOSED "$status" 1 1 enhancement)" \ + "$(board_node 371 CLOSED "Building" 1 1 enhancement)" > "$dir/board.json" printf '%s' "$dir" } @@ -204,11 +252,86 @@ run_sweep "$(scenario "$TMP/routed" 'Decompose' 600 '' '' 'state:decompose')" >/ assert_lacks "a correctly-routed issue publishes no health report at all" "CREATE_BODY" \ "$(cat "$TMP/routed/writes.log")" +echo +echo "DoR reconcile β€” membership, not the gate label, decides what is swept" +echo + +# ── 7. On the board without the gate label β€” was invisible to everything ──── +# 42 of the Feature board's 74 items had no `enhancement` label, so the walk never saw them: no +# drift, no staleness, no liveness, and an "Awaiting approval" item missing from the 🚦 count. +# Same drift setup as #5, but the issue is absent from the gate-label query entirely. +out="$(run_sweep "$(scenario "$TMP/boardonly" 'Awaiting design' 600 '' '' 'state:decompose' nogate)")" +assert_contains "an issue the board carries is swept without the gate label" "πŸ”€ #370" "$out" + +# ── 8. …and the approval gate counts it ───────────────────────────────────── +out="$(run_sweep "$(scenario "$TMP/approval" 'Awaiting approval' 600 '' '' 'state:awaiting-approval' nogate)")" +assert_contains "…including in the value-gate backlog" "🚦 1 issue(s)" "$out" + +# ── 9. An issue with the gate label but NOT on the board is still healed ──── +# The board cannot show us this one, so the label query has to stay. `dor_set_status.sh` gets no +# usable GraphQL from the stub and fails, which is the ❌ arm β€” reaching it at all is the point. +out="$(run_sweep "$(scenario "$TMP/offboard" '' 600 '' '' 'state:decompose' '' '' offboard)")" +assert_contains "an issue missing from the board still reaches the heal path" \ + "#370 is missing from the board" "$out" + +# ── 10. An item filed on the wrong board ──────────────────────────────────── +# #819 sat on the Feature board at "Blocked (external)" while the Bug board had it at "Awaiting +# functional acceptance". Membership-driven, it must be named as misfiled rather than un-routed. +out="$(run_sweep "$(scenario "$TMP/wrongboard" 'Blocked (external)' 600 '' '' '' nogate bug)")" +assert_contains "a bug on the Feature board is called out as misfiled" "🧭 #370" "$out" +assert_lacks "…and is NOT reported as un-routed" "πŸ•³οΈ #370" "$out" + +echo +echo "DoR reconcile β€” the report notifies on CHANGE, not on schedule" +echo + +# ── 11. Same exceptions as last sweep: refresh the body, post no comment ──── +# The πŸ•³οΈ line embeds a moving age, so the TEXT differs every hour while the finding is identical. +# Comparing bodies would notify forever; comparing the fingerprint is what makes it quiet. +dir="$(scenario "$TMP/nochange" '' 600)" +seed_health "$dir" OPEN "_Last swept 2026-01-01 00:00 UTC β€” 1 item(s) need attention:_ + +- πŸ•³οΈ #370 is on the board (Status: none) with no \`state:*\` label and untouched for 999h β€” the agent likely never ran. +" +run_sweep "$dir" >/dev/null +assert_contains "the body is still refreshed every sweep" "EDIT:" "$(cat "$dir/writes.log")" +assert_lacks "…but an unchanged exception set posts no comment" "COMMENT:" "$(cat "$dir/writes.log")" +assert_lacks "…and a moving age alone is not a change" "999h" "$(cat "$dir/writes.log")" + +# ── 12. A different exception set does notify ─────────────────────────────── +dir="$(scenario "$TMP/changed" '' 600)" +seed_health "$dir" OPEN "_stale_ + +- πŸ”€ #370 drift: something else entirely. +" +run_sweep "$dir" >/dev/null +assert_contains "a changed exception set does comment" "COMMENT:" "$(cat "$dir/writes.log")" + +# ── 13. A closed health issue is reopened, and that always warrants a comment ─ +dir="$(scenario "$TMP/reopen" '' 600)" +seed_health "$dir" CLOSED "_stale_ + +- πŸ•³οΈ #370 is on the board … untouched for 999h β€” the agent likely never ran. +" +run_sweep "$dir" >/dev/null +assert_contains "a closed health issue is reopened" "REOPEN:" "$(cat "$dir/writes.log")" +assert_contains "…and commented on even though the set is unchanged" "COMMENT:" "$(cat "$dir/writes.log")" + +# ── 14. Healthy again: close it ───────────────────────────────────────────── +dir="$(scenario "$TMP/recovered" 'Decompose' 600 '' '' 'state:decompose')" +seed_health "$dir" OPEN "_stale_ + +- πŸ•³οΈ #370 … +" +run_sweep "$dir" >/dev/null +assert_contains "a recovered pipeline closes its health issue" "CLOSE:" "$(cat "$dir/writes.log")" +assert_lacks "…without a parting comment" "COMMENT:" "$(cat "$dir/writes.log")" + echo echo "DoR reconcile β€” closed issues on the board" echo -# ── 7. Done is the ONLY resting state β€” "Out of pipeline" must still nag ──── +# ── 15. Done is the ONLY resting state β€” "Out of pipeline" must still nag ─── # Leaving the pipeline is not the same as being filed away: a closed issue parked in "Out of # pipeline" still has to be walked over to Done, and this report line is the only reminder that it # is sitting there. Pinned because it is tempting to read that column as terminal and silence it. @@ -216,7 +339,7 @@ out="$(run_sweep "$(closed_scenario "$TMP/closed-oop" 'Out of pipeline')")" assert_contains "a closed issue parked mid-pipeline is flagged" "πŸ”š #371" "$out" assert_contains "…and a closed 'Out of pipeline' issue is flagged too" "πŸ”š #370" "$out" -# ── 8. …but a closed issue that reached Done is left alone ────────────────── +# ── 16. …but a closed issue that reached Done is left alone ───────────────── # #371 still flags, so the report exists and the assertion below is not vacuous. out="$(run_sweep "$(closed_scenario "$TMP/closed-done" 'Done')")" assert_contains "the control row still flags" "πŸ”š #371" "$out"