diff --git a/.github/labels.conf b/.github/labels.conf index 2568711..3a779e4 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,4 +1,5 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl +triage-actors=dan-claude-bot scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 2e2363c..0c69c8a 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -95,3 +95,15 @@ jobs: env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} + - name: reconcile issue flow + if: github.repository != 'heavy-duty/ceremony' + uses: ./.ceremony-src/actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + - name: reconcile issue flow (dogfood — the workspace IS ceremony) + if: github.repository == 'heavy-duty/ceremony' + uses: ./actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index b6d17e8..9c94d39 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -7,6 +7,8 @@ name: labels on: schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake workflow_dispatch: # bootstraps missing labels on a fresh repo + issues: + types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened] pull_request_target: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] permissions: diff --git a/LABELS.md b/LABELS.md index a995657..d617129 100644 --- a/LABELS.md +++ b/LABELS.md @@ -7,8 +7,8 @@ the labels workflow's bootstrap dispatch (issue #10). Two state machines share the taxonomy: the **PR machine** (proven in box/rig/cast, reconciled by machinery) and the **issue flow** (the -triage → build queue, doctrine-enforced today, machinery to follow — -issue #18). One rule joins everything: **states are machine-owned, intent +triage → build queue, reconciled by the work-queue sweep). One rule joins +everything: **states are machine-owned, intent labels are hand-set** — a hand-moved state label is a lie waiting to happen, and the reconciler recomputes it from GitHub's own facts. @@ -54,11 +54,11 @@ strips it on sight). | `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | | `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | -The invariant a board scan relies on: every open issue is either +The work-queue sweep enforces the invariant a board scan relies on: every open issue is either `needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / -`blocked`. A `claimed` issue with no open PR and no activity is what the -staleness sweep will reclaim (issue #18); until that machinery exists, -[TRIAGE.md](TRIAGE.md) owns the hygiene by hand. +`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue +with no open PR and no activity for 48 hours is reclaimed by the sweep: it +comments, unassigns the stale owner, and restores `ready`. ## Cross-cutting (PRs and issues) @@ -114,7 +114,7 @@ on a PR would say the same thing twice and drift. The labels workflow (issue #10) recomputes PR state statelessly on PR events plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on -manual dispatch. Issue-flow labels are doctrine-owned until #18 lands -machinery for them. Default GitHub labels (`duplicate`, `invalid`, +manual dispatch. The same workflow reconciles issue-flow labels on issue +events and during the scheduled sweep. Default GitHub labels (`duplicate`, `invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are deleted at bootstrap — a `question` is a discussion, not an issue. diff --git a/TRIAGE.md b/TRIAGE.md index a0a7df0..edbebd0 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -82,13 +82,16 @@ dependency-ordered task list of child issues. Children reference the epic; the epic's checklist is the progress view. Builders never pick the epic itself. Keep the checklist current — a stale epic misleads every scan. -## Backlog hygiene (yours until #18 automates it) +## Backlog hygiene - **Dedup before minting** — search issues *and* closed issues; extend or reopen before duplicating. -- **Flip `blocked` → `ready`** when the named dependency lands. -- **Reclaim abandoned claims**: `claimed` + no open PR + no activity → - comment, unassign, restore `ready`. +- The issue-flow sweep flips `blocked` → `ready` when every named dependency + lands, and flags a blocked issue whose dependency declaration is unreadable. +- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR + + no activity → comment, unassign, restore `ready`. +- Automation never guesses intent. Resolve the conflict comments it leaves on + malformed queue states, and close or extend completed epics when nudged. - **Close obsolete issues** with the reason and a link to what obsoleted them. Every label on every open issue stays true; the board is only worth scanning if it does not lie. diff --git a/actions/issueflow-reconcile/action.yml b/actions/issueflow-reconcile/action.yml new file mode 100644 index 0000000..6a6cf22 --- /dev/null +++ b/actions/issueflow-reconcile/action.yml @@ -0,0 +1,13 @@ +name: Reconcile issue flow +description: Converge the issue work queue and reclaim stale claims +runs: + using: composite + steps: + - name: reconcile issue flow + shell: bash + env: + LABELS_CONF: ${{ github.workspace }}/.github/labels.conf + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_ISSUE: ${{ github.event.issue.number }} + run: bash "$GITHUB_ACTION_PATH/issueflow-reconcile.sh" diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh new file mode 100644 index 0000000..deee082 --- /dev/null +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # backticks in comment bodies are Markdown literals +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -euo pipefail +else + set -u +fi + +# The issue-flow half of the labels state machine. Decisions are pure strings; +# API calls live below the divider so fixture tests can exercise every branch. + +ISSUEFLOW_NOW="${ISSUEFLOW_NOW:-$(date -u +%s)}" +ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}" +[[ "$ISSUEFLOW_NOW" =~ ^[0-9]+$ ]] || { + echo "issueflow: ISSUEFLOW_NOW must be UTC epoch seconds" >&2 + if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi +} +[[ "$ISSUEFLOW_STALE_HOURS" =~ ^[0-9]+$ ]] || { + echo "issueflow: ISSUEFLOW_STALE_HOURS must be a non-negative integer" >&2 + if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi +} +NOW="$ISSUEFLOW_NOW" +STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600)) +QUEUE_LABELS=(ready claimed blocked) +TRIAGE_ACTORS=() + +log() { printf 'issueflow: %s\n' "$*"; } +run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } + +load_issueflow_config() { # $1 = labels.conf + local conf="$1" line seen=false + [ -f "$conf" ] || { echo "issueflow: missing config: $conf" >&2; return 1; } + TRIAGE_ACTORS=() + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + triage-actors=*) + [ "$seen" = false ] || { + echo "issueflow: duplicate triage-actors line in $conf" >&2 + return 1 + } + seen=true + read -r -a TRIAGE_ACTORS <<<"${line#triage-actors=}" + [ "${#TRIAGE_ACTORS[@]}" -gt 0 ] || { + echo "issueflow: triage-actors must name at least one actor in $conf" >&2 + return 1 + } + ;; + esac + done <"$conf" + [ "$seen" = true ] || { + echo "issueflow: missing triage-actors= line in $conf" >&2 + return 1 + } +} + +is_triage_actor() { + local actor + for actor in "${TRIAGE_ACTORS[@]}"; do + [ "$actor" = "$1" ] && return 0 + done + return 1 +} + +has_issue_label() { grep -qxF "$1" <<<"$ISSUE_LABELS"; } + +queue_decision() { # labels on stdin -> KEEP | ADD_NEEDS_TRIAGE | FLAG_CONFLICT + local labels count=0 label categories=0 + labels="$(cat)" + grep -qxF needs-triage <<<"$labels" && categories=$((categories + 1)) + grep -qxF epic <<<"$labels" && categories=$((categories + 1)) + for label in "${QUEUE_LABELS[@]}"; do + if grep -qxF "$label" <<<"$labels"; then count=$((count + 1)); fi + done + [ "$count" -gt 0 ] && categories=$((categories + 1)) + if [ "$categories" -eq 0 ]; then echo ADD_NEEDS_TRIAGE + elif [ "$categories" -gt 1 ] || [ "$count" -gt 1 ]; then echo FLAG_CONFLICT + else echo KEEP + fi +} + +author_decision() { # $1 = true when author is triage; labels on stdin + local triage="$1" labels + labels="$(cat)" + if [ "$triage" = false ] && ! grep -qxF needs-triage <<<"$labels"; then + echo ADD_NEEDS_TRIAGE + else echo KEEP + fi +} + +claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds + local assignees="$1" open_pr="$2" age="$3" + # Staleness wins over missing ownership: a stale unassigned claim is + # derivably reclaimable, while a recent unassigned claim needs triage. + if [ "$open_pr" = false ] && [ "$age" -gt "$STALE_AFTER" ]; then echo RECLAIM + elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED + else echo KEEP + fi +} + +claim_decision_at() { # $1 assignee count, $2 linked open PR, $3 last activity epoch + claim_decision "$1" "$2" "$((NOW - $3))" +} + +claim_reclaim_marker() { # $1 = last activity epoch + printf 'claim-reclaimed-%s\n' "$1" +} + +blocked_references() { # body on stdin -> issue numbers, one per line + # Dependency declarations sometimes soft-wrap after a comma. Continue + # through the first sentence terminator; if prose omits one, conservatively + # retain later references so ambiguity can keep an issue blocked, never + # promote it prematurely. + awk ' + { + line = $0 + lower = tolower(line) + if (!active) { + marker = "blocked by" + start = index(lower, marker) + if (!start) next + line = substr(line, start + length(marker)) + active = 1 + } + if (line ~ /[.;]/) { + sub(/[.;].*/, "", line) + print line + exit + } + print line + } + ' \ + | { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu +} + +blocked_decision() { # $1 refs, $2 OPEN/CLOSED states + local refs="$1" states="$2" + if [ -z "$refs" ]; then echo FLAG_UNPARSEABLE + elif grep -qxF OPEN <<<"$states"; then echo KEEP + elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE + else echo READY + fi +} + +epic_references() { # markdown task-list issue references from body on stdin + awk ' + tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next } + in_list && /^#/ { exit } + in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print } + ' \ + | { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu +} + +epic_decision() { # $1 refs, $2 states + local refs="$1" states="$2" + if [ -n "$refs" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then echo NUDGE + else echo KEEP + fi +} + +# API edge. Marker comments make warnings and nudges idempotent across sweeps. +ensure_comment() { # $1 issue, $2 marker, $3 message + local n="$1" marker="$2" message="$3" + if gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].body' \ + | grep -qF ""; then return; fi + run gh issue comment "$n" -R "$REPO" --body " +$message" >/dev/null +} + +reference_states() { + local ref state + while IFS= read -r ref; do + [ -n "$ref" ] || continue + state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)" + case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac + done +} + +last_issue_activity() { + local n="$1" created="$2" latest + latest="$({ + printf '%s\n' "$created" + gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' + # Assignment is the claim itself. Ignoring it would let an old issue be + # reclaimed in the seconds between assignment and its required draft PR. + gh api --paginate "repos/$REPO/issues/$n/timeline" \ + --jq '.[] | select(.event == "assigned") | .created_at' + } \ + | sort | tail -n1)" + date -d "$latest" +%s +} + +reconcile_issue() { + local n="$1" decision refs states age assignees open_pr=false label owners + decision="$(queue_decision <<<"$ISSUE_LABELS")" + case "$decision" in + ADD_NEEDS_TRIAGE) + run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null + log "#$n: needs-triage (no queue state)" ;; + FLAG_CONFLICT) + ensure_comment "$n" queue-conflict \ + 'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, or `blocked`.' + log "#$n: conflicting queue labels; flagged" + return ;; + esac + + if has_issue_label claimed; then + assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" + grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true + age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + decision="$(claim_decision_at "$assignees" "$open_pr" "$age")" + case "$decision" in + FLAG_UNASSIGNED) + ensure_comment "$n" claimed-unassigned \ + 'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;; + RECLAIM) + # The last-activity epoch identifies a claim episode. A fixed marker + # hid the required comment when the same issue was later claimed and + # reclaimed again. + ensure_comment "$n" "$(claim_reclaim_marker "$age")" \ + 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' + owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" + if [ -n "$owners" ]; then + run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ + --remove-label claimed --add-label ready >/dev/null + else + run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null + fi + log "#$n: stale claim reclaimed -> ready" ;; + esac + elif has_issue_label blocked; then + refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + states="$(reference_states <<<"$refs")" + decision="$(blocked_decision "$refs" "$states")" + case "$decision" in + FLAG_UNPARSEABLE) + ensure_comment "$n" blocked-unparseable \ + 'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;; + READY) + ensure_comment "$n" blockers-cleared \ + 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' + run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null + log "#$n: blockers closed -> ready" ;; + esac + elif has_issue_label epic; then + refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + states="$(reference_states <<<"$refs")" + if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then + ensure_comment "$n" epic-complete \ + "Every issue referenced by this epic's task list is closed. Please close the epic or extend its task list." + log "#$n: completed epic nudged" + fi + fi +} + +reconcile_opened_issue() { + local n="$1" author triage=false labels remove="" label + ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" + jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return + author="$(jq -r '.user.login' <<<"$ISSUE_JSON")" + is_triage_actor "$author" && triage=true + labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" + [ "$(author_decision "$triage" <<<"$labels")" = ADD_NEEDS_TRIAGE ] || return + for label in epic "${QUEUE_LABELS[@]}"; do + grep -qxF "$label" <<<"$labels" && remove="$remove,$label" + done + remove="${remove#,}" + if [ -n "$remove" ]; then + run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null + else + run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null + fi + log "#$n: needs-triage (opened by $author)" +} + +main() { + local owner name + REPO="${REPO:?set REPO to owner/name}" + LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" + load_issueflow_config "$LABELS_CONF" + if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then + reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}" + fi + owner="${REPO%%/*}" + name="${REPO#*/}" + OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' + query($owner: String!, $name: String!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, states: OPEN, after: $endCursor) { + nodes { closingIssuesReferences(first: 100) { nodes { number } } } + pageInfo { hasNextPage endCursor } + } + } + }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ + | sort -nu)" + + local n + for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ + --jq '.[] | select(has("pull_request") | not) | .number'); do + ( + ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" + jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 + ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" + reconcile_issue "$n" + ) || log "#$n: reconcile failed — continuing with the remaining issues" + done + log "reconciled." +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index a6e17cc..53f6d01 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -78,6 +78,7 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional return 1 } ;; + triage-actors=*) ;; *) parse_label_row "$line" >/dev/null || return ;; esac done <"$conf" @@ -102,7 +103,7 @@ configured_label_rows() { # validated scope rows, excluding the panel setting [ -f "$conf" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue - case "$line" in panel=*) continue ;; esac + case "$line" in panel=* | triage-actors=*) continue ;; esac parse_label_row "$line" || return done <"$conf" } diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 17191e7..85de0c8 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -229,6 +229,8 @@ on: workflow_dispatch: # bootstraps missing labels on a fresh repo pull_request_target: types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] + issues: + types: [opened, labeled, unlabeled, assigned, unassigned, closed] permissions: contents: read issues: write @@ -242,21 +244,23 @@ jobs: token to write labels. The reusable workflow executes no PR code. It checks out only the consumer's base branch and the pinned ceremony implementation. -`.github/labels.conf` has one mandatory panel setting followed by zero or -more scope rows: +`.github/labels.conf` has one mandatory panel setting, one mandatory +`triage-actors` setting, and then zero or more scope rows: ```text panel=claude-bot example-codex-bot example-grok-bot +triage-actors=example-triage-bot scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation ``` -The panel is whitespace-separated. Label rows use exactly +Both actor lists are whitespace-separated. `triage-actors` names the identities +allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly `name|color|description`; blank lines are ignored and extra pipes are refused. There are no comment lines: every non-blank line must be the `panel=` -setting or a label row, so `#`-prefixed prose is a parse failure, not a -comment (rig #13's conversion found this the hard way — keep the file data -only). +setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose +is a parse failure, not a comment (rig #13's conversion found this the hard +way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh new file mode 100644 index 0000000..3029388 --- /dev/null +++ b/test/issueflow-reconcile.test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" +# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh +source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +printf '%s\n' \ + 'panel=one two' \ + 'triage-actors=triage-one triage-two' \ + 'scope:one|C5DEF5|First scope' >"$TMP/good.conf" +check "triage actors parse beside panel and labels" 0 "" load_issueflow_config "$TMP/good.conf" +load_issueflow_config "$TMP/good.conf" +check "triage actor is recognized" 0 "" is_triage_actor triage-two +check "non-triage actor is rejected" 1 "" is_triage_actor builder +printf '%s\n' 'panel=one' >"$TMP/missing.conf" +check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config "$TMP/missing.conf" +printf '%s\n' 'triage-actors=one' 'triage-actors=two' >"$TMP/duplicate.conf" +check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config "$TMP/duplicate.conf" + +# The dogfood caller and reusable workflow must expose the same runtime facts +# as the documented consumer stub. Static pins catch YAML blocks drifting to +# the adjacent composite step, which otherwise fails only after merge. +check "dogfood caller wakes on issue events" 0 " issues:" \ + grep -F " issues:" "$ROOT/.github/workflows/self-labels.yml" +dogfood_pr_step="$(sed -n \ + '/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \ + "$ROOT/.github/workflows/labels.yml")" +# shellcheck disable=SC2016 # GitHub expressions are asserted as literals +check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \ + grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step" +# shellcheck disable=SC2016 # GitHub expressions are asserted as literals +check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \ + grep -F ' GH_TOKEN: ${{ github.token }}' <<<"$dogfood_pr_step" + +# Invariant 1: exactly one queue category. +check "one ready queue label is valid" 0 "KEEP" queue_decision <<<"ready" +check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<<"enhancement" +check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked' +check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready' + +# Invariant 2: claims have an owner and either a PR or recent activity. +check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999 +check "unassigned claim is flagged" 0 "FLAG_UNASSIGNED" claim_decision 0 false 60 +check "quiet unassigned claim is also reclaimed" 0 "RECLAIM" claim_decision 0 false $((STALE_AFTER + 1)) +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "injected clock: below stale boundary stays claimed" 0 "KEEP" \ + bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96401' _ \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "injected clock: exact stale boundary stays claimed" 0 "KEEP" \ + bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96400' _ \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \ + bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # expansion belongs to the isolated bash -c process +check "invalid injected clock fails loudly" 1 "ISSUEFLOW_NOW must be UTC epoch seconds" \ + bash -c 'ISSUEFLOW_NOW=garbage source "$1"' _ \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399" \ + claim_reclaim_marker 96399 +check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \ + claim_reclaim_marker 99999 + +# Invariant 3: blocked declarations parse and release only when all close. +refs="$(blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.')" +check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' "$refs" +body=$'Blocked by #12 (first),\n#7 (soft-wrapped second). Blocks #44.' +check "soft-wrapped blocker declaration retains continuation refs" 0 "" test \ + "$(blocked_references <<<"$body")" = $'7\n12' +body=$'Blocked by #12 (known)\nFollow-up context mentions #7 without a sentence boundary' +check "unterminated blocker prose errs toward retaining dependencies" 0 "" test \ + "$(blocked_references <<<"$body")" = $'7\n12' +body="Part of #1. Blocked by #11 (needs a ceremony tag to pin), #12 (must be executed from the guide), #19 (the conversion vendors the doctrine). Blocks #14, #15 (they inherit the pilot's lessons)." +check "real issue 13 inline blockers parse" 0 "" test \ + "$(blocked_references <<<"$body")" = $'11\n12\n19' +body="Part of #1. Blocked by #13 (inherits the pilot's lessons). Can run in parallel with #15." +check "real issue 14 inline blocker parses" 0 "" test \ + "$(blocked_references <<<"$body")" = "13" +body="Part of #1. Blocked by #13 (pilot lessons). Can run in parallel with #14." +check "real issue 15 inline blocker parses" 0 "" test \ + "$(blocked_references <<<"$body")" = "13" +body="Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15." +check "real issue 16 inline blockers parse" 0 "" test \ + "$(blocked_references <<<"$body")" = $'11\n12' +check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision "$refs" $'CLOSED\nOPEN' +check "all closed blockers release issue" 0 "READY" blocked_decision "$refs" $'CLOSED\nCLOSED' +check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" "" +check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN" + +# Invariant 4: only configured triage actors mint directly into the queue. +check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready" +check "outside author receives needs-triage" 0 "ADD_NEEDS_TRIAGE" author_decision false <<<"ready" +check "outside author already marked needs-triage is stable" 0 "KEEP" author_decision false <<<"needs-triage" +check "later sweep accepts a normalized outside-authored issue" 0 "KEEP" queue_decision <<<"ready" + +# Invariant 5: completed epics get one nudge; incomplete/unparseable do not. +epic_refs="$(epic_references <<< $'## Definition of done\n- [ ] outside #8\n\n## Task list\n- [ ] #3 first\n- [x] #2 done\nplain #9')" +check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_refs" +body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge' +check "epic parser stops before later checkbox sections" 0 "" test \ + "$(epic_references <<<"$body")" = $'2\n3' +check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED' +check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN' +check "epic without parseable children is stable" 0 "KEEP" epic_decision "" "" + +summary diff --git a/test/labels.test.sh b/test/labels.test.sh index a042f41..af0d4de 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -12,6 +12,7 @@ trap 'rm -rf "$TMP"' EXIT printf '%s\n' \ 'panel=one two three' \ + 'triage-actors=triage-one' \ '' \ 'scope:one|C5DEF5|First scope' \ 'scope:two|C5DEF5|Second scope' >"$TMP/good.conf" @@ -25,6 +26,10 @@ check "panel is parsed" 0 "one two three" bash -c \ check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \ 'source "$1"; core_label_rows; configured_label_rows "$2"' _ \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +check "triage config is not parsed as a label row" 1 "" bash -c \ + 'source "$1"; configured_label_rows "$2" | grep -F triage-actors' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf" printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf"