From 9fafc59c95fbc1ed1240a43f70bb182b82617a98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Aug 2026 10:57:16 +0200 Subject: [PATCH] feat(dor): sweep the whole board, and notify only when findings change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the reconcile sweep, both aimed at the same complaint: the health report was simultaneously blind and noisy. ## Membership, not the gate label, decides what is swept The per-issue walk started from `gh issue list --label "$LABEL"`, so an issue could sit on the board in any phase and be invisible to every check in it. Not a corner case: 42 of the Feature board's 74 items carried no `enhancement` label β€” a UI cohort parked in "Awaiting design", most of the "Out of pipeline" column, and an "Awaiting approval" item that made the value-gate count read 2 where the board plainly showed 3. The board snapshot now carries each issue's own dates and labels, and the walk runs over board membership. The label query survives for the one thing the board cannot show: an issue that ought to be on it and is not (the heal path). Board records move from `@tsv` to the same US separator Β§2 already used, for the same reason: an empty field in the middle of a tab-joined record shifts every field after it. ## An item on the wrong board is named as such Membership-driven, a misfiled item would otherwise be reported as un-routed β€” "the agent likely never ran" β€” when the fault is where it is filed. #819 sat on the Feature board at "Blocked (external)" while the Bug board had it at "Awaiting functional acceptance"; each sweep only ever reads its own board, so nothing could see the disagreement. Now 🧭. ## The report notifies on change, not on schedule It commented on EVERY hourly sweep for as long as anything was open β€” 87 comments on #886. Comparing bodies cannot fix that: most findings restate a moving age, so the text is never identical two sweeps running. Each exception now also yields a stable identity (marker + issue, or marker + its numbers for the countable lines), and the sorted set is carried in the body as an HTML comment. The body is refreshed every sweep β€” editing does not notify β€” but a comment is posted only when that set actually differs, or when a closed health issue has to be reopened. A standing backlog costs one notification instead of one per hour. ## Tests 30 assertions, up from 17. The stub now serves the board as JSON too and runs the script's own --jq over it, so the board record contract is under test rather than hand-copied β€” and it strips CR, because jq on Windows writes CRLF while `gh` returns LF, which had been letting a local run disagree with CI about what the script parsed. 4 of the new assertions fail against main: the two board-driven ones, the misfiled-board one, and the notify-on-change one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/dor_reconcile.sh | 153 +++++++++--- changes/reconcile-board-driven-walk.md | 3 + .../ci-scripts/test-dor-reconcile-liveness.sh | 225 ++++++++++++++---- 3 files changed, 299 insertions(+), 82 deletions(-) create mode 100644 changes/reconcile-board-driven-walk.md 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"