#!/usr/bin/env bash # scripts/select-queue.sh — deterministic queue selector (live Gitea state, never a tracker issue). # # WHY THIS EXISTS. The kickoff dispatches a cheap model to rank the backlog, and the lore then makes # the orchestrator RE-DERIVE the selector's mechanical claims because a small model kept getting them # wrong: it reported blocked issues as `deps:clear` (never called /dependencies), mis-tiered issues # when the server-side `?milestones=` filter silently no-ops on `:`/`+` names, ranked by issue number # and ignored priority-within-tier, and missed live `in-progress` claims. Re-deriving all of that by # hand every session is the tax. The fix is to stop asking a model to do arithmetic: this script does # the MECHANICAL parts deterministically so there is nothing to re-verify, and only FLAGS the two # checks that are genuinely judgment (a claim buried in a comment, umbrella-vs-child framing) for a # human/model to resolve on the shortlist. # # WHAT IS DETERMINISTIC HERE (trust it, don't re-derive): # - dependency exclusion: GET /issues/{n}/dependencies on every surviving candidate; any OPEN # blocker => excluded (Gitea auto-clears the block when the blocker closes). # - tiering: OPEN-milestone membership by LOCAL `.milestone.state=="open"` filter (never the # server `?milestones=` name filter, which no-ops on `:`/`+`); `review` label; `priority:` label. # - ordering: within a tier, priority high>medium>low, then lowest issue number. # - label exclusions: `in-progress` and `parked` are dropped; pull requests are excluded # (type=issues + a .pull_request guard). # - author exclusion: bot-authored issues are dropped (login `renovate` or a `name[bot]` login). # A bot's actionable output is PRs (already excluded); its one lingering ISSUE is Renovate's # Dependency Dashboard (#22), a status board, not a pickup — see ersatztv#553. # # WHAT STAYS JUDGMENT (the script FLAGS, it does not decide): # - CLAIM? — a "claiming" note can precede the `in-progress` label; the script flags any candidate # whose most-recent comments look like a live claim so the orchestrator reads them before claiming. # - UMBRELLA? — an epic/umbrella whose children are the real pickups reads like a normal issue; the # script flags bodies that open with umbrella framing so the orchestrator treats it as a container. # - ARC tier — there is no active arc right now (maintenance/backlog mode; arc complete), so this # script deliberately does not rank an arc tier. If a new arc/milestone is ever established, add # it as a tier here — never fall back to hand-ranking from a tracker's prose. # # Usage: # ETV_GITEA_BASICAUTH=user:pass scripts/select-queue.sh [N] # N size of the ranked shortlist (default 5) # Creds from env: ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH (user:pass). ETV_GITEA_URL overrides the # base (default: the LAN instance); ETV_GITEA_REPO overrides owner/repo (default timothy/ersatztv). # # Output: a ranked table (most-eligible first) with tier, priority, milestone, deps evidence, and the # CLAIM?/UMBRELLA? flags. Fail-OPEN with no creds / Gitea unreachable (prints a notice, exits 0). # Exit 0 when a shortlist is produced or cleanly skipped; exit 2 only on a usage error. set -uo pipefail N="${1:-5}" case "$N" in ''|*[!0-9]*) echo "usage: select-queue.sh [N] (N = shortlist size, integer)" >&2; exit 2;; esac base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1" repo="${ETV_GITEA_REPO:-timothy/ersatztv}" command -v jq >/dev/null || { echo "select-queue: jq not found — install jq." >&2; exit 2; } gq() { local path="$1" if [ -n "${ETV_GITEA_TOKEN:-}" ]; then curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null else return 1 fi } if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then echo "select-queue: no Gitea creds in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH) — skipping (no-op)." exit 0 fi # Reachability probe up front (a mid-pagination `exit` inside the $(...) below would only exit the # subshell, not this script — so fail-open explicitly here where it can actually stop us). gq "repos/$repo/issues?state=open&type=issues&limit=1&page=1" >/dev/null || { echo "select-queue: Gitea unreachable or creds rejected — skipping (no-op)."; exit 0; } # 1) Page through ALL open issues once (type=issues excludes PRs). Filter and tier LOCALLY — never # trust the server-side milestone-name filter. The list payload already carries .body, so the # UMBRELLA? flag is computed here (no per-candidate body fetch). Emit one TSV row per # eligible-by-label candidate, pre-sorted by (tier, priority-rank, number) so the deps walk below # only has to go top-down. rows=$( page=1 while :; do batch=$(gq "repos/$repo/issues?state=open&type=issues&limit=50&page=$page") || break count=$(printf '%s' "$batch" | jq 'length' 2>/dev/null || echo 0) [ "${count:-0}" -eq 0 ] && break printf '%s' "$batch" | jq -r ' .[] # drop PRs and label-excluded issues up front | select(.pull_request == null) # drop bot-authored issues. A bot only emits PRs as work (already excluded above); it never # files a human work-item issue, so the whole class is never a pickup. The concrete case is # Renovate Dependency Dashboard (#22): a bot-rewritten status board whose checkboxes are # commands to Renovate, not work items -- yet as a priority:low issue it sorts to the top of an # all-low backlog and was proposed to every fresh session (ersatztv#553). Matches known bot # logins plus the GitHub name[bot] convention so a future bot dashboard is excluded too. | (.user.login // "") as $author | select(($author == "renovate" or ($author | test("\\[bot\\]$"))) | not) | (.labels | map(.name)) as $L | select(($L | index("in-progress")) | not) | select(($L | index("parked")) | not) # priority rank: high=0, medium=1, low=2, none=3 (none still selectable in a milestone/review tier) | (if ($L | index("priority: high")) then 0 elif ($L | index("priority: medium")) then 1 elif ($L | index("priority: low")) then 2 else 3 end) as $prank | (($L | index("review")) != null) as $isReview | ((.milestone != null) and (.milestone.state == "open")) as $inOpenMs # tier: 2=open-milestone, 3=review, 4/5/6=priority high/medium/low (unmilestoned/unreviewed). # (Tier 1 = arc; there is no active arc right now — see the header comment above.) | (if $inOpenMs then 2 elif $isReview then 3 elif $prank==0 then 4 elif $prank==1 then 5 elif $prank==2 then 6 else empty end) as $tier # no priority label AND not milestone/review => not a candidate # UMBRELLA? — body opens with epic/umbrella framing (computed from the list payload) | (((.body // "")[0:400]) | test("(?i)follow-up arc to|umbrella|this epic|tracked (here|by) as sub|child issues?:|sub-work|sub-issues?:")) as $umbrella | [ $tier, $prank, .number, (.milestone.title // "-"), (.comments // 0), (if $umbrella then "1" else "0" end), (.title | gsub("[\t\n]";" ")) ] | @tsv ' 2>/dev/null page=$((page+1)) done | sort -t$'\t' -k1,1n -k2,2n -k3,3n ) if [ -z "$rows" ]; then echo "select-queue: no label-eligible candidates found (arc/milestone/review/priority all empty)." echo " An empty backlog is unusual — confirm OPEN issues carry priority: labels (see the H12 audit)." exit 0 fi # 2) Walk the pre-sorted candidates top-down; for each, resolve dependencies (deterministic exclusion) # and compute the two judgment FLAGS. Stop once N eligible candidates are collected. tier_name() { case "$1" in 2) echo "milestone";; 3) echo "review";; 4) echo "prio-high";; 5) echo "prio-medium";; 6) echo "prio-low";; *) echo "t$1";; esac; } prio_name() { case "$1" in 0) echo "high";; 1) echo "medium";; 2) echo "low";; *) echo "-";; esac; } printf '%-6s %-11s %-7s %-22s %-16s %-8s %s\n' "ISSUE" "TIER" "PRIO" "MILESTONE" "DEPS" "FLAGS" "TITLE" printf '%s\n' "----------------------------------------------------------------------------------------------------" collected=0 while IFS=$'\t' read -r tier prank number ms ncomments umbrella title; do [ "$collected" -ge "$N" ] && break # --- deterministic: dependency exclusion --- deps=$(gq "repos/$repo/issues/$number/dependencies") open_blockers=$(printf '%s' "$deps" | jq -r '[.[] | select(.state=="open") | (.number|tostring)] | join(",")' 2>/dev/null) if [ -n "$open_blockers" ]; then printf ' (skip #%s: blocked-by open #%s)\n' "$number" "$open_blockers" continue fi closed_blockers=$(printf '%s' "$deps" | jq -r '[.[] | select(.state!="open") | (.number|tostring)] | join(",")' 2>/dev/null) deps_cell="clear"; [ -n "$closed_blockers" ] && deps_cell="clear(was #$closed_blockers)" # --- judgment FLAGS (surface, do not decide) --- flags="" # UMBRELLA? — precomputed from the body in the jq pass above [ "$umbrella" = "1" ] && flags="${flags}UMBRELLA? " # CLAIM? — a claim can precede the in-progress label; scan the last comments for claim language if [ "${ncomments:-0}" -gt 0 ]; then lastc=$(gq "repos/$repo/issues/$number/comments" | jq -r '.[-2:][]?.body // ""' 2>/dev/null) if printf '%s' "$lastc" | grep -qiE 'claim(ing|ed)?\b|picking (this|it) up|taking this'; then flags="${flags}CLAIM? " fi fi [ -z "$flags" ] && flags="-" printf '#%-5s %-11s %-7s %-22.22s %-16s %-8s %.50s\n' \ "$number" "$(tier_name "$tier")" "$(prio_name "$prank")" "$ms" "$deps_cell" "$flags" "$title" collected=$((collected+1)) done <<< "$rows" echo echo "Ranked by (tier, priority, issue#). DEPS/tiering/ordering are deterministic — trust them." echo "Resolve CLAIM?/UMBRELLA? flags before claiming (read the issue's comments/body). No active arc tier right now."