From 4c56a0c7580f0adbf3b506b58c10495c517623e8 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:15:38 +0200 Subject: [PATCH 1/3] feat(harness): add roadmap generator + post-merge regen hook (issue #175) Add roadmap.sh, a gh-2.4.0-safe REST-only generator that renders docs/ROADMAP.md from live GitHub metadata: open milestones in natural version order, each with its issues (priority chip, derived open/in_flight/PR#N-open/closed state mirroring loop-census.sh's own ADVANCE logic, and a Mermaid "Blocked by" graph reusing cockpit.sh's --parse-blocking parser), plus a Feedback inbox of unmilestoned open feedback-labeled issues and a generated timestamp/commit-sha footer. Wire merge-ready.sh to regenerate docs/ROADMAP.md best-effort after every successful merge, strictly non-fatal on generator failure, with commit+push only when the local checkout is cleanly on the base branch. Extend merge-ready.test.sh to prove regen invocation, its non-fatal failure path, and that it never fires on a skip-only (no merge) run. Document docs/ROADMAP.md as generated/read-only in docs/USAGE.md. --- .claude/scripts/merge-ready.sh | 50 ++++ .claude/scripts/merge-ready.test.sh | 157 +++++++++++ .claude/scripts/roadmap.sh | 420 ++++++++++++++++++++++++++++ .claude/scripts/roadmap.test.sh | 195 +++++++++++++ docs/USAGE.md | 32 +++ 5 files changed, 854 insertions(+) create mode 100755 .claude/scripts/roadmap.sh create mode 100755 .claude/scripts/roadmap.test.sh diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index 73a1af7..c867268 100644 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -234,5 +234,55 @@ if [ "$merged" -gt 0 ]; then else echo "{\"local_sync\":\"skip\",\"reason\":\"fetch or fast-forward failed (diverged/offline?)\"}" fi + + # Post-merge roadmap regen (issue #175): docs/ROADMAP.md is a GENERATED + # snapshot of open milestones/issues/PRs (see roadmap.sh's own header + + # docs/USAGE.md's "Roadmap" section) — regenerating it right after a merge + # keeps it fresh without the owner remembering to re-run it by hand. + # + # STRICTLY best-effort and NON-FATAL: this whole block is wrapped in its own + # subshell + `|| true` so ANY failure inside it (missing roadmap.sh, a + # generator crash, no local git checkout, an unclean tree, an offline + # push) degrades to a `"roadmap_regen":"skip"` line on STDERR — it must + # NEVER cause merge-ready.sh to report a non-zero exit or unwind a merge + # that already succeeded. `[ -f "$script_dir/roadmap.sh" ]` also makes this + # a no-op when roadmap.sh isn't present at all (nothing to regenerate). + # + # Regeneration itself is invoked unconditionally (independent of the + # local_sync outcome above) so a stubbed/failing roadmap.sh is always + # exercised — see merge-ready.test.sh. The COMMIT+PUSH step, however, only + # runs when $wt is a real checkout sitting on $base with a clean tree (the + # same safety rails local_sync already applies above): committing to a + # detached/foreign branch, or on top of uncommitted work, would be unsafe. + # The commit/push themselves are plain `git` (the repo OWNER's auth, same + # as every other git operation in this script) — only roadmap.sh's OWN gh + # calls (issue/PR/milestone reads) go through bot-gh.sh. + if [ -f "$script_dir/roadmap.sh" ]; then + ( + set +e + regen_err="$(mktemp "${TMPDIR:-/tmp}/roadmap-regen.XXXXXX.err")" + if ! GATES_FILE="$gates_rel" bash "$script_dir/roadmap.sh" --write >/dev/null 2>"$regen_err"; then + echo "{\"roadmap_regen\":\"skip\",\"reason\":\"generator failed: $(tr '\n' ' ' <"$regen_err" | head -c 200)\"}" >&2 + rm -f "$regen_err" + exit 0 + fi + rm -f "$regen_err" + if [ -z "$wt" ] || [ "$cur" != "$base" ]; then + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no eligible local checkout on $base\"}" + exit 0 + fi + if git -C "$wt" diff --quiet -- docs/ROADMAP.md 2>/dev/null && git -C "$wt" diff --cached --quiet -- docs/ROADMAP.md 2>/dev/null; then + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no changes\"}" + exit 0 + fi + if git -C "$wt" add docs/ROADMAP.md \ + && git -C "$wt" commit -q -m "chore: regenerate docs/ROADMAP.md [skip ci]" \ + && git -C "$wt" push -q origin "HEAD:$base"; then + echo "{\"roadmap_regen\":\"generated\",\"committed\":true,\"branch\":\"$base\"}" + else + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"commit or push failed\"}" >&2 + fi + ) || echo "{\"roadmap_regen\":\"skip\",\"reason\":\"unexpected error\"}" >&2 + fi fi echo "=== merge-ready: merged=$merged skipped=$skipped ===" diff --git a/.claude/scripts/merge-ready.test.sh b/.claude/scripts/merge-ready.test.sh index 59507e4..540a396 100755 --- a/.claude/scripts/merge-ready.test.sh +++ b/.claude/scripts/merge-ready.test.sh @@ -478,6 +478,163 @@ check "G3: no merge attempted for PR 16 (absolute-path adapter selection)" \ check "G3: needs-human label add attempted (REST POST) via absolute-path adapter" \ grep -qF -- "-X POST repos/acme/repo/issues/16/labels --input -" "$gh_logG3" +# --------------------------------------------------------------------------- +# H. Post-merge roadmap regen (issue #175) is INVOKED on a successful merge. +# The fixture's roadmap.sh is a stub that touches a marker file (proving +# invocation) whenever it's called with --write, then exits 0. Reuses the +# same scenario-C-shaped MERGE fixture (owner-approved, CI-green PR). +# --------------------------------------------------------------------------- +dirH="$(new_fixture scenarioH)" +gh_logH="$work/scenarioH-gh.log" +roadmap_markerH="$work/scenarioH-roadmap-invoked.marker" +cat > "$dirH/.claude/scripts/roadmap.sh" < "$dirH/.claude/scripts/bot-gh.sh" <> "$gh_logH" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "17" + fi + ;; + view) + cat <<'JSON' +{"number":17,"title":"Ship roadmap-adjacent feature","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-17-thing","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirH/.claude/scripts/bot-gh.sh" +outH="$(env -u GATES_FILE bash "$dirH/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "H: PR merged" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outH" +check "H: roadmap.sh --write was actually invoked (marker file created)" test -f "$roadmap_markerH" +check "H: output reports roadmap_regen generated" bash -c 'printf "%s\n" "$1" | grep -q "\"roadmap_regen\":\"generated\""' _ "$outH" + +# --------------------------------------------------------------------------- +# I. Post-merge roadmap regen FAILURE is NON-FATAL: the fixture's roadmap.sh +# always exits 1 (simulating a generator crash). The merge itself must +# still be reported as merged, and the overall merge-ready.sh invocation +# must still exit 0 -- a broken roadmap generator must never break, abort, +# or roll back a merge that already succeeded. +# --------------------------------------------------------------------------- +dirI="$(new_fixture scenarioI)" +gh_logI="$work/scenarioI-gh.log" +cat > "$dirI/.claude/scripts/roadmap.sh" <<'EOF' +#!/usr/bin/env bash +echo "boom: simulated roadmap generator crash" >&2 +exit 1 +EOF +chmod +x "$dirI/.claude/scripts/roadmap.sh" +cat > "$dirI/.claude/scripts/bot-gh.sh" <> "$gh_logI" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "18" + fi + ;; + view) + cat <<'JSON' +{"number":18,"title":"Ship another feature","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-18-thing","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirI/.claude/scripts/bot-gh.sh" +set +e +outI="$(env -u GATES_FILE bash "$dirI/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" +rcI=$? +set -e + +check "I: merge-ready.sh exits 0 despite the roadmap generator crashing" bash -c '[ "$1" -eq 0 ]' _ "$rcI" +check "I: PR still reported merged (roadmap failure did not roll back the merge)" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outI" +check "I: roadmap_regen skip reason surfaced (generator failed)" bash -c 'printf "%s\n" "$1" | grep -q "roadmap_regen.*generator failed"' _ "$outI" + +# --------------------------------------------------------------------------- +# J. Roadmap regen is NEVER invoked on a SKIP-only run (no merge happened at +# all) -- it lives inside the `if [ "$merged" -gt 0 ]` post-merge block, +# same as the existing local_sync leg. Reuses scenario A's SKIP:no-owner- +# review shape with a marker-touching roadmap.sh stub, proving the marker +# stays absent. +# --------------------------------------------------------------------------- +dirJ="$(new_fixture scenarioJ)" +gh_logJ="$work/scenarioJ-gh.log" +roadmap_markerJ="$work/scenarioJ-roadmap-invoked.marker" +labeled_markerJ="$work/scenarioJ-labeled.marker" +cat > "$dirJ/.claude/scripts/roadmap.sh" < "$dirJ/.claude/scripts/bot-gh.sh" <> "$gh_logJ" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "19" + fi + ;; + view) + cat <<'JSON' +{"number":19,"title":"Add widget","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-19-widget","mergeable":"MERGEABLE","reviews":[],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + comment) : ;; + merge) exit 1 ;; + *) : ;; + esac + ;; + api) + case "\$*" in + *"-X POST"*"/issues/19/labels --input -") + touch "$labeled_markerJ" + ;; + *"-q .labels[].name"*) + [ -f "$labeled_markerJ" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirJ/.claude/scripts/bot-gh.sh" +outJ="$(env -u GATES_FILE bash "$dirJ/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" + +check "J: verdict is skip:no-owner-review (no merge happened)" bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"no-owner-review\""' _ "$outJ" +check "J: roadmap.sh was NEVER invoked on a skip-only run (no marker file)" bash -c '[ ! -f "$1" ]' _ "$roadmap_markerJ" + echo "" if [ "$fail" -eq 0 ]; then echo "merge-ready.test.sh: PASS ($ok checks)" diff --git a/.claude/scripts/roadmap.sh b/.claude/scripts/roadmap.sh new file mode 100755 index 0000000..3354484 --- /dev/null +++ b/.claude/scripts/roadmap.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# roadmap.sh — generates docs/ROADMAP.md, a read-only snapshot of the +# project's roadmap sourced ENTIRELY from GitHub metadata (open milestones, +# issues, PRs, and the blocking-graph edges parsed out of issue bodies) — +# issue #175. +# +# docs/ROADMAP.md is GENERATED, never hand-authored: the single source of +# truth is GitHub itself (milestones/labels/issue bodies/PR state). Re-run +# this script to refresh it; do not edit the file directly (see the marker +# this script prints at the very top of its own output, and docs/USAGE.md). +# +# Renders, per OPEN milestone (in version order — natural/`sort -V` semantics, +# same as loop-census.sh's own milestone ordering): +# - every issue attached to it (open AND closed, so progress is visible), +# each with a priority chip (same `priority:critical|high|medium|low` +# label set the census/cockpit already use), a derived STATE, and its +# "Blocked by" edges, +# - a Mermaid graph of the "Blocked by" edges among that milestone's issues. +# Plus a trailing "Feedback inbox" section: open `feedback`-labeled issues +# that carry NO milestone yet (see docs/USAGE.md's "rollout & feedback +# companion" — that's exactly the census-invisible inbox state before owner +# triage assigns a milestone). +# +# STATE DERIVATION mirrors loop-census.sh's own ADVANCE/in_flight logic (never +# reimplemented, just re-applied here) so an issue's roadmap state never +# disagrees with what the loop itself would report: +# closed — issue.state == CLOSED +# PR#N open — an OPEN PR's headRefName (or title "#N" mention) +# matches this issue number +# in_flight — a feat/fix/work/issue--* branch (local or +# remote-tracking) exists, but no open PR yet +# open — none of the above +# +# REUSE, not reimplementation: the "Blocked by #N"/"Blocks #N" parser is +# cockpit.sh's own `--parse-blocking` subcommand (shelled out to below, same +# as cockpit.sh's own render step does internally for its issue list) — there +# is exactly ONE implementation of that parser in this repo. +# +# gh 2.4.0 SAFE: every gh call here is a plain REST-backed `gh issue list` / +# `gh pr list` / `gh api` invocation — no `gh milestone` subcommand, no +# `gh api graphql`, no bracket-array `-f` syntax (see loop-census.sh's own +# header for the identical constraint/rationale). Always routed through +# bot-gh.sh (or $ROADMAP_GH_BIN in tests), never bare `gh`. +# +# Usage: +# roadmap.sh [--fixtures ] [--write] [output-path] +# (default, no --write) prints the rendered Markdown to stdout — a dry +# preview that touches no file on disk. +# --write persists the rendered Markdown to output-path +# (default /docs/ROADMAP.md) instead of printing it. +# --fixtures reads /milestones.json (raw REST milestones +# array), /issues.json (gh issue list --json shape, plus a `state` +# field), /prs.json (gh pr list --json shape), and +# /branches.json (a flat array of branch short names) instead of +# calling gh/git at all — missing files default to `[]`. This is the +# offline seam roadmap.test.sh uses; no live gh/network/git in tests. +# +# Degrades gracefully: a failed/unavailable gh call for any of +# milestones/issues/PRs renders that section as +# "_unavailable (gh/network)_" instead of data, and the script still exits 0 +# — it must never hard-crash a caller (e.g. merge-ready.sh's best-effort +# post-merge regen hook) on a missing network/auth. +set -uo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" +cockpit="$script_dir/cockpit.sh" + +# --------------------------------------------------------------------------- +# Args +# --------------------------------------------------------------------------- +fixtures="" +write=0 +out_arg="" +while [ $# -gt 0 ]; do + case "$1" in + --fixtures) fixtures="$2"; shift 2 ;; + --fixtures=*) fixtures="${1#--fixtures=}"; shift ;; + --write) write=1; shift ;; + *) out_arg="$1"; shift ;; + esac +done +out="${out_arg:-$root/docs/ROADMAP.md}" +case "$out" in + /*) : ;; + *) out="$root/$out" ;; +esac + +# gh entry point — overridable so tests can stub gh without touching real +# auth/network (same seam as cockpit.sh's COCKPIT_GH_BIN). +gh_bin="${ROADMAP_GH_BIN:-$script_dir/bot-gh.sh}" +gh() { "$gh_bin" "$@"; } + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/roadmap.XXXXXX")" +trap 'rm -rf "$tmpdir"' EXIT + +valid_json() { node -e 'JSON.parse(require("fs").readFileSync(0,"utf8"))' <"$1" >/dev/null 2>&1; } + +repo="" +if [ -z "$fixtures" ]; then + repo="${ROADMAP_REPO:-$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null || true)}" +fi + +# ---- milestones (open, raw REST shape) -------------------------------------- +milestones_unavailable=0 +if [ -n "$fixtures" ]; then + if [ -f "$fixtures/milestones.json" ]; then cp "$fixtures/milestones.json" "$tmpdir/milestones.json"; else echo "[]" >"$tmpdir/milestones.json"; fi +else + if [ -n "$repo" ] && gh api --paginate "repos/$repo/milestones?state=open" >"$tmpdir/milestones.json" 2>"$tmpdir/milestones.err"; then + : + else + milestones_unavailable=1 + fi + if [ "$milestones_unavailable" -eq 0 ] && ! valid_json "$tmpdir/milestones.json"; then milestones_unavailable=1; fi + [ "$milestones_unavailable" -eq 1 ] && echo "[]" >"$tmpdir/milestones.json" +fi + +# ---- issues (ALL states — closed ones show milestone progress too) --------- +issues_unavailable=0 +if [ -n "$fixtures" ]; then + if [ -f "$fixtures/issues.json" ]; then cp "$fixtures/issues.json" "$tmpdir/issues.json"; else echo "[]" >"$tmpdir/issues.json"; fi +else + if [ -n "$repo" ] && gh issue list -R "$repo" --state all --limit 500 \ + --json number,title,url,labels,body,milestone,state >"$tmpdir/issues.json" 2>"$tmpdir/issues.err"; then + : + else + issues_unavailable=1 + fi + if [ "$issues_unavailable" -eq 0 ] && ! valid_json "$tmpdir/issues.json"; then issues_unavailable=1; fi + [ "$issues_unavailable" -eq 1 ] && echo "[]" >"$tmpdir/issues.json" +fi + +# ---- PRs (open only — enough to derive "PR#N open") ------------------------- +prs_unavailable=0 +if [ -n "$fixtures" ]; then + if [ -f "$fixtures/prs.json" ]; then cp "$fixtures/prs.json" "$tmpdir/prs.json"; else echo "[]" >"$tmpdir/prs.json"; fi +else + if [ -n "$repo" ] && gh pr list -R "$repo" --state open --limit 200 \ + --json number,title,url,headRefName >"$tmpdir/prs.json" 2>"$tmpdir/prs.err"; then + : + else + prs_unavailable=1 + fi + if [ "$prs_unavailable" -eq 0 ] && ! valid_json "$tmpdir/prs.json"; then prs_unavailable=1; fi + [ "$prs_unavailable" -eq 1 ] && echo "[]" >"$tmpdir/prs.json" +fi + +# ---- branches (in_flight detection, issue #175 mirrors loop-census.sh) ----- +if [ -n "$fixtures" ]; then + if [ -f "$fixtures/branches.json" ]; then cp "$fixtures/branches.json" "$tmpdir/branches.json"; else echo "[]" >"$tmpdir/branches.json"; fi +else + git -C "$root" branch -a --list 2>/dev/null | sed 's/^[+* ]*//' >"$tmpdir/branches.txt" || : >"$tmpdir/branches.txt" + node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").map((s) => s.trim()).filter(Boolean); + fs.writeFileSync(process.argv[2], JSON.stringify(lines)); + ' "$tmpdir/branches.txt" "$tmpdir/branches.json" +fi + +# ---- footer facts: timestamp + generating commit SHA ------------------------ +if [ -n "$fixtures" ]; then + commit_sha="${ROADMAP_FIXTURE_SHA:-fixture}" +else + commit_sha="$(git -C "$root" rev-parse --short HEAD 2>/dev/null || echo unknown)" +fi + +# --------------------------------------------------------------------------- +# Render — one node process reads the gathered JSON and prints Markdown. +# --------------------------------------------------------------------------- +rendered="$( +ROADMAP_TMPDIR="$tmpdir" \ +ROADMAP_COCKPIT="$cockpit" \ +ROADMAP_SHA="$commit_sha" \ +ROADMAP_NOW="${ROADMAP_NOW:-}" \ +ROADMAP_MILESTONES_UNAVAILABLE="$milestones_unavailable" \ +ROADMAP_ISSUES_UNAVAILABLE="$issues_unavailable" \ +ROADMAP_PRS_UNAVAILABLE="$prs_unavailable" \ +node - <<'NODE_RENDER' +const fs = require("fs"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const tmpdir = process.env.ROADMAP_TMPDIR; +const readJson = (name, fallback) => { + try { return JSON.parse(fs.readFileSync(path.join(tmpdir, name), "utf8")); } catch (e) { return fallback; } +}; + +const rawMilestones = readJson("milestones.json", []); +const issues = readJson("issues.json", []); +const prs = readJson("prs.json", []); +const branches = readJson("branches.json", []); +const milestonesUnavailable = process.env.ROADMAP_MILESTONES_UNAVAILABLE === "1"; +const issuesUnavailable = process.env.ROADMAP_ISSUES_UNAVAILABLE === "1"; +const prsUnavailable = process.env.ROADMAP_PRS_UNAVAILABLE === "1"; + +function esc(s) { + return String(s == null ? "" : s).replace(/\|/g, "\\|").replace(/\r?\n/g, " "); +} +// Mermaid node labels: strip characters that break the `["..."]` shape +// (quotes/brackets), truncate so a long title doesn't blow up the graph. +function mermaidEsc(s) { + const t = String(s == null ? "" : s).replace(/["\[\]]/g, "'").replace(/\r?\n/g, " "); + return t.length > 48 ? t.slice(0, 45) + "..." : t; +} + +// Natural/version compare (same intent as GNU `sort -V`, used by +// loop-census.sh's own milestone ordering): split into digit/non-digit runs, +// compare numeric runs numerically, everything else lexically. +function naturalCompare(a, b) { + const re = /(\d+)|(\D+)/g; + const as = String(a).match(re) || []; + const bs = String(b).match(re) || []; + const len = Math.max(as.length, bs.length); + for (let i = 0; i < len; i++) { + const x = as[i], y = bs[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y); + if (xn && yn) { + const diff = parseInt(x, 10) - parseInt(y, 10); + if (diff !== 0) return diff; + } else { + const cmp = x.localeCompare(y); + if (cmp !== 0) return cmp; + } + } + return 0; +} + +// ---- Priority chip (same label set/precedence as cockpit.sh's priorityOf) -- +function hasLabel(obj, name) { + return (obj.labels || []).some((l) => l && l.name === name); +} +const PRIORITY_LEVELS = [ + { name: "critical", chip: "🔴 critical" }, + { name: "high", chip: "🟠 high" }, + { name: "medium", chip: "🟡 medium" }, + { name: "low", chip: "⚪ low" }, +]; +function priorityOf(issue) { + for (const level of PRIORITY_LEVELS) { + if (hasLabel(issue, "priority:" + level.name)) return level; + } + return null; +} +// Rank for sort ordering within a milestone (issue #173's ordering, reused): +// critical=0 < high=1 < medium=2 < low=3 < unlabeled=4. +function priorityRank(issue) { + const p = priorityOf(issue); + if (!p) return 4; + return PRIORITY_LEVELS.findIndex((l) => l.name === p.name); +} + +// ---- Blocked-by/Blocks parsing: reuse cockpit.sh --parse-blocking ---------- +function parseBlocking(body) { + try { + const stdout = execFileSync("bash", [process.env.ROADMAP_COCKPIT, "--parse-blocking"], { + input: body || "", + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + return JSON.parse(stdout); + } catch (e) { + return { blockedBy: [], blocks: [], taskRefs: [] }; + } +} + +// ---- State derivation (mirrors loop-census.sh's own ADVANCE/in_flight logic, +// re-applied here rather than reimplemented from scratch) ------------------- +function findPRForIssue(n) { + for (const pr of prs) { + const branch = String(pr.headRefName || ""); + const m = branch.match(/issue-(\d+)(?:[-_]|$)/i); + if (m && parseInt(m[1], 10) === n) return pr; + } + const titleRe = new RegExp("#" + n + "\\b"); + for (const pr of prs) { + if (titleRe.test(String(pr.title || ""))) return pr; + } + return null; +} +function branchExistsForIssue(n) { + const re = /(?:^|\/)(?:remotes\/[^/]+\/)?(?:feat|fix|work)\/issue-(\d+)(?:[-_]|$)/i; + return branches.some((b) => { + const m = String(b).match(re); + return m && parseInt(m[1], 10) === n; + }); +} +function stateOf(issue) { + if (String(issue.state || "").toUpperCase() === "CLOSED") return { label: "closed", pr: null }; + const pr = findPRForIssue(issue.number); + if (pr) return { label: `PR#${pr.number} open`, pr }; + if (branchExistsForIssue(issue.number)) return { label: "in_flight", pr: null }; + return { label: "open", pr: null }; +} + +// ---- Group issues by milestone title --------------------------------------- +const byMilestone = new Map(); // title -> issue[] +const unmilestoned = []; +for (const issue of issues) { + const title = issue.milestone && issue.milestone.title; + if (title) { + if (!byMilestone.has(title)) byMilestone.set(title, []); + byMilestone.get(title).push(issue); + } else { + unmilestoned.push(issue); + } +} + +// ---- Milestones, open, in version order ------------------------------------ +const milestones = rawMilestones + .filter((m) => m && m.state !== "closed") + .slice() + .sort((a, b) => naturalCompare(a.title, b.title)); + +const lines = []; +lines.push(""); +lines.push(""); +lines.push(""); +lines.push("# Roadmap"); +lines.push(""); +lines.push("> **Generated — do not hand-edit.** This file is produced by `.claude/scripts/roadmap.sh` from live"); +lines.push("> GitHub metadata (open milestones, issues, PRs, and \"Blocked by\" edges parsed from issue bodies)."); +lines.push("> The single source of truth is GitHub itself — change labels/milestones/issue bodies there, then"); +lines.push("> re-run `bash .claude/scripts/roadmap.sh --write`. Manual edits here will be overwritten."); +lines.push(""); + +if (milestonesUnavailable || issuesUnavailable) { + lines.push("_unavailable (gh/network)_"); +} else if (milestones.length === 0) { + lines.push("_No open milestones._"); +} else { + for (const ms of milestones) { + const msIssues = (byMilestone.get(ms.title) || []).slice().sort((a, b) => { + const r = priorityRank(a) - priorityRank(b); + return r !== 0 ? r : a.number - b.number; + }); + lines.push(`## ${esc(ms.title)} (#${ms.number} · ${ms.open_issues || 0} open / ${ms.closed_issues || 0} closed)`); + lines.push(""); + if (msIssues.length === 0) { + lines.push("_No issues assigned to this milestone yet._"); + lines.push(""); + continue; + } + lines.push("| Issue | Priority | State | Blocked by |"); + lines.push("|---|---|---|---|"); + const edges = []; // {from, to} = from blocks to (from must land before to) + for (const issue of msIssues) { + const priority = priorityOf(issue); + const st = stateOf(issue); + const parsed = parseBlocking(issue.body || ""); + const blockedByText = parsed.blockedBy.length ? parsed.blockedBy.map((n) => `#${n}`).join(", ") : "—"; + lines.push(`| [#${issue.number}](${esc(issue.url || "#")}) ${esc(issue.title)} | ${priority ? esc(priority.chip) : "—"} | ${esc(st.label)} | ${blockedByText} |`); + for (const b of parsed.blockedBy) edges.push({ from: b, to: issue.number }); + } + lines.push(""); + if (edges.length > 0) { + // Node labels use whichever title we know (from this milestone's own + // issue set — the blocker may be outside it, in which case we fall + // back to a bare "#N" label). + const titleOf = (n) => { + const found = issues.find((i) => i.number === n); + return found ? mermaidEsc(found.title) : ""; + }; + lines.push("```mermaid"); + lines.push("graph LR"); + const seen = new Set(); + for (const e of edges) { + const key = `${e.from}->${e.to}`; + if (seen.has(key)) continue; + seen.add(key); + const fromLabel = titleOf(e.from) ? `#${e.from} ${titleOf(e.from)}` : `#${e.from}`; + const toLabel = titleOf(e.to) ? `#${e.to} ${titleOf(e.to)}` : `#${e.to}`; + lines.push(` I${e.from}["${fromLabel}"] --> I${e.to}["${toLabel}"]`); + } + lines.push("```"); + lines.push(""); + } + } +} + +// ---- Feedback inbox: open `feedback`-labeled issues with NO milestone ------ +lines.push("## Feedback inbox"); +lines.push(""); +lines.push("Open `feedback`-labeled issues not yet assigned a milestone (owner triage — see docs/USAGE.md's"); +lines.push("\"rollout & feedback companion\" — turns into loop-eligible work once `planned` + a milestone land):"); +lines.push(""); +if (prsUnavailable) { /* PRs aren't needed for this section; no-op, kept for clarity */ } +if (issuesUnavailable) { + lines.push("_unavailable (gh/network)_"); +} else { + const inbox = unmilestoned + .filter((i) => String(i.state || "").toUpperCase() !== "CLOSED" && hasLabel(i, "feedback")) + .sort((a, b) => a.number - b.number); + if (inbox.length === 0) { + lines.push("_No unmilestoned feedback issues._"); + } else { + for (const issue of inbox) { + lines.push(`- [#${issue.number}](${esc(issue.url || "#")}) ${esc(issue.title)}`); + } + } +} +lines.push(""); + +lines.push("---"); +const now = process.env.ROADMAP_NOW || new Date().toISOString(); +lines.push(`_Generated ${esc(now)} · commit \`${esc(process.env.ROADMAP_SHA || "unknown")}\`_`); +lines.push(""); + +process.stdout.write(lines.join("\n")); +NODE_RENDER +)" + +if [ "$write" -eq 1 ]; then + mkdir -p "$(dirname "$out")" + printf '%s\n' "$rendered" >"$out" +else + printf '%s\n' "$rendered" +fi diff --git a/.claude/scripts/roadmap.test.sh b/.claude/scripts/roadmap.test.sh new file mode 100755 index 0000000..5b3173f --- /dev/null +++ b/.claude/scripts/roadmap.test.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# roadmap.test.sh — offline smoke test for roadmap.sh (issue #175). +# +# Runs the generator against controlled FIXTURE milestones/issues/PRs/branches +# JSON (never live gh/network — see roadmap.sh's --fixtures mode), then +# asserts: +# - open milestones render in NATURAL version order (v1.0.0 < v1.2.0 < +# v1.10.0 — a plain string sort would get this wrong), +# - state derivation per issue: closed / in_flight / "PR#N open" / open, +# - the "Blocked by" edges render as a Mermaid graph, +# - the "Feedback inbox" section lists only unmilestoned, open, +# `feedback`-labeled issues, +# - the top-of-file GENERATED/do-not-edit marker is present, +# - --write persists to disk while the default mode only prints to stdout, +# - the gh/network-unavailable degrade path never crashes the script. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/roadmap.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +roadmap="$script_dir/roadmap.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/roadmap-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +# --------------------------------------------------------------------------- +# 1. Full fixture run: milestone ordering, state derivation, mermaid edges, +# feedback inbox, generated marker. +# --------------------------------------------------------------------------- +mkdir -p "$work/fixtures" +cat_json() { printf '%s' "$1"; } + +cat > "$work/fixtures/milestones.json" <<'EOF' +[ + {"number":1,"title":"v1.0.0","open_issues":1,"closed_issues":1,"state":"open"}, + {"number":2,"title":"v1.10.0","open_issues":1,"closed_issues":0,"state":"open"}, + {"number":3,"title":"v1.2.0","open_issues":0,"closed_issues":0,"state":"open"}, + {"number":4,"title":"v0.9.0 (done)","open_issues":0,"closed_issues":5,"state":"closed"} +] +EOF +cat > "$work/fixtures/issues.json" <<'EOF' +[ + {"number":10,"title":"Feature A ","url":"https://example.com/10","labels":[{"name":"module:harness"},{"name":"priority:critical"}],"body":"Blocked by #11","milestone":{"title":"v1.0.0"},"state":"OPEN"}, + {"number":11,"title":"Foundation piece","url":"https://example.com/11","labels":[{"name":"module:harness"}],"body":"","milestone":{"title":"v1.0.0"},"state":"CLOSED"}, + {"number":20,"title":"Feature B (has a PR)","url":"https://example.com/20","labels":[{"name":"module:docs"},{"name":"priority:low"}],"body":"","milestone":{"title":"v1.10.0"},"state":"OPEN"}, + {"number":21,"title":"Plain open issue, no branch no PR","url":"https://example.com/21","labels":[{"name":"module:docs"}],"body":"","milestone":{"title":"v1.10.0"},"state":"OPEN"}, + {"number":30,"title":"Unmilestoned feedback item","url":"https://example.com/30","labels":[{"name":"feedback"}],"body":"","milestone":null,"state":"OPEN"}, + {"number":31,"title":"Closed feedback item (must not appear)","url":"https://example.com/31","labels":[{"name":"feedback"}],"body":"","milestone":null,"state":"CLOSED"}, + {"number":32,"title":"Unmilestoned but no feedback label (must not appear)","url":"https://example.com/32","labels":[],"body":"","milestone":null,"state":"OPEN"}, + {"number":40,"title":"Milestoned feedback item (must not appear in inbox)","url":"https://example.com/40","labels":[{"name":"feedback"}],"body":"","milestone":{"title":"v1.10.0"},"state":"OPEN"} +] +EOF +cat > "$work/fixtures/prs.json" <<'EOF' +[ + {"number":99,"title":"Ship feature B","url":"https://example.com/pr/99","headRefName":"feat/issue-20-featureb"} +] +EOF +cat > "$work/fixtures/branches.json" <<'EOF' +["main","feat/issue-10-feature-a","remotes/origin/fix/issue-999-stale"] +EOF + +out="$(ROADMAP_NOW="2026-01-01T00:00:00Z" bash "$roadmap" --fixtures "$work/fixtures")" + +check "generated/do-not-edit marker at top of output" bash -c 'printf "%s\n" "$1" | head -1 | grep -q "GENERATED FILE"' _ "$out" +check "closed milestone (v0.9.0) is NOT rendered" bash -c '! printf "%s\n" "$1" | grep -q "v0.9.0"' _ "$out" + +# Milestone ordering: v1.0.0 must appear before v1.2.0, which must appear +# before v1.10.0 (natural/version order, NOT plain string sort which would +# put "v1.10.0" before "v1.2.0"). +check "milestones render in natural version order (v1.0.0 < v1.2.0 < v1.10.0)" node -e ' + const out = process.argv[1]; + const i0 = out.indexOf("## v1.0.0"); + const i2 = out.indexOf("## v1.2.0"); + const i10 = out.indexOf("## v1.10.0"); + if (i0 < 0 || i2 < 0 || i10 < 0) process.exit(1); + if (!(i0 < i2 && i2 < i10)) process.exit(1); +' "$out" + +# State derivation. +check "issue #10 (branch exists, no PR) derives in_flight" bash -c 'printf "%s\n" "$1" | grep -q "#10.*in_flight"' _ "$out" +check "issue #11 (state CLOSED) derives closed" bash -c 'printf "%s\n" "$1" | grep -q "#11.*closed"' _ "$out" +check "issue #20 (open PR #99 matches branch) derives PR#99 open" bash -c 'printf "%s\n" "$1" | grep -q "#20.*PR#99 open"' _ "$out" +check "issue #21 (no branch, no PR) derives plain open" bash -c 'printf "%s\n" "$1" | grep -qE "#21\b.*\| open \|"' _ "$out" + +# Priority chip. +check "issue #10 shows a critical priority chip" bash -c 'printf "%s\n" "$1" | grep -q "critical"' _ "$out" +check "issue #21 (unprioritized) shows no chip (em-dash placeholder)" bash -c 'printf "%s\n" "$1" | grep -E "#21\b" | grep -q "| — |"' _ "$out" + +# Mermaid blocking-graph edge for #11 -> #10. +check "mermaid code fence present" bash -c 'printf "%s\n" "$1" | grep -q "\`\`\`mermaid"' _ "$out" +check "mermaid edge I11 --> I10 (11 blocks 10) rendered" bash -c 'printf "%s\n" "$1" | grep -q "I11.*-->.*I10"' _ "$out" +check "milestone with zero issues (v1.2.0) has no mermaid block of its own (no edges)" node -e ' + const out = process.argv[1]; + const start = out.indexOf("## v1.2.0"); + const end = out.indexOf("## v1.10.0"); + const section = out.slice(start, end); + if (section.includes("```mermaid")) process.exit(1); +' "$out" + +# Feedback inbox: only open + feedback-labeled + unmilestoned. +check "feedback inbox section header present" bash -c 'printf "%s\n" "$1" | grep -q "## Feedback inbox"' _ "$out" +check "feedback inbox lists #30 (open, feedback, unmilestoned)" bash -c ' + section=$(printf "%s\n" "$1" | sed -n "/## Feedback inbox/,\$p") + printf "%s\n" "$section" | grep -q "#30" +' _ "$out" +check "feedback inbox EXCLUDES #31 (closed)" bash -c ' + section=$(printf "%s\n" "$1" | sed -n "/## Feedback inbox/,\$p") + ! printf "%s\n" "$section" | grep -q "#31" +' _ "$out" +check "feedback inbox EXCLUDES #32 (no feedback label)" bash -c ' + section=$(printf "%s\n" "$1" | sed -n "/## Feedback inbox/,\$p") + ! printf "%s\n" "$section" | grep -q "#32" +' _ "$out" +check "feedback inbox EXCLUDES #40 (has a milestone)" bash -c ' + section=$(printf "%s\n" "$1" | sed -n "/## Feedback inbox/,\$p") + ! printf "%s\n" "$section" | grep -q "#40" +' _ "$out" + +# Footer. +check "footer carries the timestamp and commit sha" bash -c 'printf "%s\n" "$1" | grep -q "Generated 2026-01-01T00:00:00Z .* commit"' _ "$out" + +# XSS/escaping smoke: table pipes in a title must not break the table shape +# (a literal "|" would corrupt the row) -- titles here don't contain "|", but +# newline-stripping is asserted instead (Markdown, not HTML, so no "' _ "$out" + +# --------------------------------------------------------------------------- +# 2. --write persists to disk; default (no --write) mode does not. +# --------------------------------------------------------------------------- +write_out="$work/written/ROADMAP.md" +bash "$roadmap" --fixtures "$work/fixtures" --write "$write_out" >/dev/null +check "--write creates the output file" test -f "$write_out" +check "--write output also carries the generated marker" bash -c 'head -1 "$1" | grep -q "GENERATED FILE"' _ "$write_out" + +no_write_out="$work/should-not-exist/ROADMAP.md" +bash "$roadmap" --fixtures "$work/fixtures" "$no_write_out" >/dev/null +check "default (no --write) mode does NOT create a file even when given a path arg" bash -c '[ ! -f "$1" ]' _ "$no_write_out" + +# --------------------------------------------------------------------------- +# 3. Empty fixtures dir (no milestones/issues at all): degrades to a clean +# "no open milestones" / "no unmilestoned feedback issues" render, never a +# crash, and exits 0. +# --------------------------------------------------------------------------- +mkdir -p "$work/empty" +if out_empty="$(bash "$roadmap" --fixtures "$work/empty" 2>&1)"; then + rc_empty=0 +else + rc_empty=$? +fi +check "empty fixtures dir exits 0" bash -c '[ "$1" -eq 0 ]' _ "$rc_empty" +check "empty fixtures dir renders 'No open milestones'" bash -c 'printf "%s\n" "$1" | grep -q "No open milestones"' _ "$out_empty" +check "empty fixtures dir renders 'No unmilestoned feedback issues'" bash -c 'printf "%s\n" "$1" | grep -q "No unmilestoned feedback issues"' _ "$out_empty" + +# --------------------------------------------------------------------------- +# 4. gh/network-unavailable degrade path: ROADMAP_GH_BIN stubbed to fail every +# call. Must still exit 0 and render "unavailable" placeholders instead of +# crashing (mirrors cockpit.sh's own degrade contract). +# --------------------------------------------------------------------------- +fail_gh="$work/fail-gh.sh" +cat > "$fail_gh" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF +chmod +x "$fail_gh" +if out_unavail="$(ROADMAP_GH_BIN="$fail_gh" ROADMAP_REPO="acme/repo" bash "$roadmap" 2>&1)"; then + rc_unavail=0 +else + rc_unavail=$? +fi +check "gh-unavailable path exits 0 (never crashes)" bash -c '[ "$1" -eq 0 ]' _ "$rc_unavail" +check "gh-unavailable path renders an 'unavailable' placeholder" bash -c 'printf "%s\n" "$1" | grep -q "unavailable"' _ "$out_unavail" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "roadmap.test.sh: PASS ($ok checks)" + exit 0 +else + echo "roadmap.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/docs/USAGE.md b/docs/USAGE.md index af5bdf0..4ca4294 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -537,6 +537,38 @@ micro-milestone containing just that one bug issue, label it `planned`, let the cut the patch release through the exact same `Release vX.Y.Z+1` → `release.sh` machinery above (its "Blocked by" list will just be the one bug issue). +## Roadmap (`docs/ROADMAP.md`, issue #175) + +**`docs/ROADMAP.md` is GENERATED — never hand-edit it.** The single source of truth is GitHub itself (open +milestones, issue labels/bodies, PR state); the file is a read-only rendering of that data, produced by +`bash .claude/scripts/roadmap.sh --write`. A manual edit to `docs/ROADMAP.md` survives only until the next +regeneration, at which point it's silently overwritten — the file itself carries a +`GENERATED FILE — DO NOT EDIT BY HAND` marker at the very top as a reminder. To change what the roadmap +shows, change the underlying GitHub state instead: relabel/reassign a milestone, adjust a +`priority:critical|high|medium|low` label, or edit an issue's "Blocked by #N" line — then re-run the +generator (or just wait for the next post-merge regen, see below). + +**What it renders**, per open milestone (in natural version order — v1.0.0 < v1.2.0 < v1.10.0, not a plain +string sort): +- every issue attached to that milestone, each with its priority chip (same label set as the census/cockpit + above), a derived **state** — `open` / `in_flight` (a `feat`/`fix`/`work`/issue-``-\* branch exists, no + PR yet) / `PR#N open` / `closed` — mirroring `loop-census.sh`'s own ADVANCE/in_flight logic so the roadmap + never disagrees with what the loop itself would report, +- a Mermaid graph of the "Blocked by" edges among that milestone's issues (the SAME parser `cockpit.sh + --parse-blocking` uses — one implementation, reused, not re-derived). + +A trailing **Feedback inbox** section lists open `feedback`-labeled issues that carry **no milestone yet** — +exactly the census-invisible state described in "The rollout & feedback companion" above, before owner +triage assigns a milestone and turns it into loop-eligible work. + +**Regeneration.** `bash .claude/scripts/roadmap.sh --write` regenerates it on demand (default, no `--write`, +prints the same Markdown to stdout instead — a preview that touches no file). `merge-ready.sh` also calls it +automatically after every successful merge, best-effort: a roadmap generator failure (missing gh +auth/network, a crash) is logged to stderr and never blocks, fails, or rolls back the merge that triggered +it. Every `gh` call the generator makes is REST-only and gh-2.4.0-safe, routed through `bot-gh.sh` like every +other script in this repo; the regenerated file's own commit/push (when the local checkout is cleanly on the +base branch) runs as plain `git`, the same as every other git operation in this repo. + ## Merge discipline - **`pr-per-agent`** (default): each worker → branch → PR. You (or a merge step) integrate; conflicts surface at PR time. Cleanest/auditable. From b287465569ff80ed164c5e506060bbeb90f36245 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:36:56 +0200 Subject: [PATCH 2/3] fix(merge-ready): gate roadmap commit on local_sync precondition, roll back on push failure (issue #175) Both review lenses rejected the tip for a real correctness bug: the post-merge docs/ROADMAP.md commit/push could wedge the owner's local main forever -- it committed without verifying the whole tree was clean and $base was in sync with origin, and left a dangling local commit on any push failure (offline/branch-protection), which local_sync's ff-only merge can then never reconcile. - merge-ready.sh: gate the commit on local_sync's own success flag (checkout on $base, whole tree clean, origin/$base fast-forwarded) instead of a docs/ROADMAP.md-only diff; on push failure, `git reset --hard origin/$base` so main is never left diverged. Change-detection now ignores roadmap.sh's volatile footer line (timestamp/commit) so a semantically identical regen is never committed as no-op spam. Added a comment clarifying the plain-git (owner-auth) commit choice is deliberate, not an oversight. - merge-ready.test.sh: new scenarios K/L/M drive the real commit/push path against a hermetic local bare-origin remote (same pattern as worktree.test.sh/release.test.sh/worktree-cleanup.test.sh/ loop-census.test.sh), asserting the real push lands, the footer-only diff is skipped, and a rejected push rolls local main back in sync with origin. - roadmap.sh: soften the header's census-parity claim -- its in_flight branch detection lacks census's stale-merged-remote refinement; documented as a known, low-risk simplification rather than an unstated mismatch. - roadmap.test.sh: note the gh-unavailable scenario's one real (read-only) git branch listing call instead of leaving it unexplained. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/merge-ready.sh | 52 +++++- .claude/scripts/merge-ready.test.sh | 267 ++++++++++++++++++++++++++++ .claude/scripts/roadmap.sh | 24 ++- .claude/scripts/roadmap.test.sh | 8 + 4 files changed, 337 insertions(+), 14 deletions(-) diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index c867268..6679a2a 100644 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -222,6 +222,13 @@ done if [ "$merged" -gt 0 ]; then wt="$(git rev-parse --show-toplevel 2>/dev/null || true)" cur="$(git -C "${wt:-.}" symbolic-ref --quiet --short HEAD 2>/dev/null || echo DETACHED)" + # local_sync_ok (issue #175 review finding #1a): 1 iff the fast-forward + # below actually landed, i.e. the checkout was on $base, the WHOLE working + # tree was clean, and origin/$base fast-forwarded cleanly (so local $base + # is now verified in sync with origin/$base). The post-merge roadmap + # commit/push further below reuses this exact flag as its own precondition + # instead of re-deriving a weaker, file-scoped check -- see that block. + local_sync_ok=0 if [ -z "$wt" ]; then : elif [ "$cur" != "$base" ]; then @@ -231,6 +238,7 @@ if [ "$merged" -gt 0 ]; then elif git -C "$wt" fetch --quiet origin "$base" 2>/dev/null \ && git -C "$wt" merge --ff-only -q "origin/$base" 2>/dev/null; then echo "{\"local_sync\":\"ok\",\"branch\":\"$base\",\"head\":\"$(git -C "$wt" rev-parse --short HEAD)\"}" + local_sync_ok=1 else echo "{\"local_sync\":\"skip\",\"reason\":\"fetch or fast-forward failed (diverged/offline?)\"}" fi @@ -250,13 +258,36 @@ if [ "$merged" -gt 0 ]; then # # Regeneration itself is invoked unconditionally (independent of the # local_sync outcome above) so a stubbed/failing roadmap.sh is always - # exercised — see merge-ready.test.sh. The COMMIT+PUSH step, however, only - # runs when $wt is a real checkout sitting on $base with a clean tree (the - # same safety rails local_sync already applies above): committing to a - # detached/foreign branch, or on top of uncommitted work, would be unsafe. + # exercised — see merge-ready.test.sh. The COMMIT+PUSH step, however, gates + # on `local_sync_ok` -- the SAME precondition local_sync itself required + # (checkout on $base, WHOLE working tree clean, origin/$base fast-forwarded + # in sync) -- not just a docs/ROADMAP.md-only diff: committing on top of + # unrelated WIP, or while $base is diverged/offline, would be unsafe. + # + # Change detection ignores the volatile footer line (issue #175 review + # finding #3): roadmap.sh's own footer always changes (timestamp + the + # generating commit SHA), so a raw file diff would treat every regen as a + # change and commit no-op spam straight onto $base. Comparing the committed + # vs regenerated content with that line stripped from both means a + # semantically-identical roadmap is correctly treated as "no changes" and + # never committed -- the footer itself is still written to disk unstripped. + # # The commit/push themselves are plain `git` (the repo OWNER's auth, same # as every other git operation in this script) — only roadmap.sh's OWN gh - # calls (issue/PR/milestone reads) go through bot-gh.sh. + # calls (issue/PR/milestone reads) go through bot-gh.sh. This is deliberate, + # not an oversight: bot-gh.sh exists so PRs are bot-authored (the owner is + # then free to approve them); a direct-to-$base commit has no PR and + # nothing for the owner to approve, so that approvability concern doesn't + # apply here -- using the owner's own git auth (already required for the + # ff-only local_sync above) is the correct choice, not a shortcut. + # + # On push failure (issue #175 review finding #1b): roll back with + # `git reset --hard origin/$base` so a rejected/offline push NEVER leaves a + # dangling local commit diverging $base from origin (which would otherwise + # wedge every future local_sync ff-only forever). Safe specifically because + # local_sync_ok guarantees the tree was clean and $base was on origin/$base + # immediately before this block ran, so resetting to origin/$base discards + # at most the regen commit just made here -- never real owner work. if [ -f "$script_dir/roadmap.sh" ]; then ( set +e @@ -267,11 +298,13 @@ if [ "$merged" -gt 0 ]; then exit 0 fi rm -f "$regen_err" - if [ -z "$wt" ] || [ "$cur" != "$base" ]; then - echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no eligible local checkout on $base\"}" + if [ "$local_sync_ok" -ne 1 ]; then + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"local $base not verified in sync with origin (see local_sync)\"}" exit 0 fi - if git -C "$wt" diff --quiet -- docs/ROADMAP.md 2>/dev/null && git -C "$wt" diff --cached --quiet -- docs/ROADMAP.md 2>/dev/null; then + old_content="$(git -C "$wt" show "HEAD:docs/ROADMAP.md" 2>/dev/null | grep -v '^_Generated ' || true)" + new_content="$(grep -v '^_Generated ' "$wt/docs/ROADMAP.md" 2>/dev/null || true)" + if [ "$old_content" = "$new_content" ]; then echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no changes\"}" exit 0 fi @@ -280,7 +313,8 @@ if [ "$merged" -gt 0 ]; then && git -C "$wt" push -q origin "HEAD:$base"; then echo "{\"roadmap_regen\":\"generated\",\"committed\":true,\"branch\":\"$base\"}" else - echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"commit or push failed\"}" >&2 + git -C "$wt" reset --hard "origin/$base" >/dev/null 2>&1 || true + echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"commit or push failed (rolled back)\"}" >&2 fi ) || echo "{\"roadmap_regen\":\"skip\",\"reason\":\"unexpected error\"}" >&2 fi diff --git a/.claude/scripts/merge-ready.test.sh b/.claude/scripts/merge-ready.test.sh index 540a396..50d6c5a 100755 --- a/.claude/scripts/merge-ready.test.sh +++ b/.claude/scripts/merge-ready.test.sh @@ -8,6 +8,18 @@ # comment, `pr merge`) that flows through the real needs-human.sh seam this # script sources. # +# Scenarios K/L/M (issue #175 review finding #2) additionally drive the +# post-merge roadmap commit/push leg with a REAL local git repo + a local +# bare "origin" remote (the same hermetic pattern worktree.test.sh / +# release.test.sh / worktree-cleanup.test.sh / loop-census.test.sh already +# use for exercising a real git push) instead of the "no eligible local +# checkout" stub path scenarios H/I take: K asserts a real diff is committed +# AND pushed to the bare origin; L asserts a footer-only (timestamp) diff is +# correctly treated as "no changes" and never committed (issue #175 finding +# #3); M asserts a REJECTED push (simulated via a bare-repo pre-receive hook) +# rolls local $base back so it is never left diverged from origin (issue +# #175 finding #1b), while the merge itself still succeeds. +# # Issue #169: needs-human.sh's label reads/writes now go through `gh api` # (REST) instead of `gh pr edit --*-label`/`gh pr view --json labels`. The # fake bot-gh.sh below simulates GitHub's own label state via a marker FILE @@ -635,6 +647,261 @@ outJ="$(env -u GATES_FILE bash "$dirJ/.claude/scripts/merge-ready.sh" "acme/repo check "J: verdict is skip:no-owner-review (no merge happened)" bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"no-owner-review\""' _ "$outJ" check "J: roadmap.sh was NEVER invoked on a skip-only run (no marker file)" bash -c '[ ! -f "$1" ]' _ "$roadmap_markerJ" +# --------------------------------------------------------------------------- +# init_git_repo_fixture : turns a fixture dir +# (already created via new_fixture) into a REAL git repo on branch "main" +# (repo-LOCAL user.email/user.name only -- never touches global git config) +# with a local bare "origin" remote at . Does not commit or +# push anything itself -- each K/L/M scenario below seeds + pushes its own +# initial commit so it controls exactly what's "already committed" before +# merge-ready.sh runs. +# --------------------------------------------------------------------------- +init_git_repo_fixture() { + local dir="$1" origin_bare="$2" + git init -q -b main "$dir" + git -C "$dir" config user.email "test@example.com" + git -C "$dir" config user.name "Test" + git init -q --bare -b main "$origin_bare" + git -C "$dir" remote add origin "$origin_bare" +} + +# --------------------------------------------------------------------------- +# K. Real post-merge roadmap COMMIT + PUSH (issue #175 review finding #2): a +# real git repo, on main, clean, in sync with a local bare "origin" -- the +# fixture's roadmap.sh stub writes a docs/ROADMAP.md that doesn't exist in +# HEAD yet, so the change-detection must see a real diff. Asserts the +# commit lands locally AND is actually pushed to the bare origin's main +# ref (not just a stubbed/skipped path like scenarios H/I). +# --------------------------------------------------------------------------- +dirK="$(new_fixture scenarioK)" +originK="$work/scenarioK-origin.git" +init_git_repo_fixture "$dirK" "$originK" +printf 'seed\n' > "$dirK/seed.txt" +git -C "$dirK" add seed.txt +git -C "$dirK" commit -q -m "seed" +git -C "$dirK" push -q origin main + +cat > "$dirK/.claude/scripts/roadmap.sh" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "--write" ]; then + mkdir -p docs + cat > docs/ROADMAP.md <<'ROADMAP' +# Roadmap + +- issue #1 open + +--- +_Generated 2026-01-01T00:00:00Z · commit `abc1234`_ +ROADMAP +fi +exit 0 +EOF +chmod +x "$dirK/.claude/scripts/roadmap.sh" +gh_logK="$work/scenarioK-gh.log" +cat > "$dirK/.claude/scripts/bot-gh.sh" <> "$gh_logK" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "20" + fi + ;; + view) + cat <<'JSON' +{"number":20,"title":"Ship widget","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-20-widget","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirK/.claude/scripts/bot-gh.sh" +outK="$( (cd "$dirK" && env -u GATES_FILE bash .claude/scripts/merge-ready.sh "acme/repo") 2>&1 )" + +check "K: PR merged" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outK" +check "K: roadmap_regen reports committed:true" bash -c 'printf "%s\n" "$1" | grep -q "roadmap_regen.*\"committed\":true"' _ "$outK" +check "K: the regen commit actually landed locally on main" bash -c ' + [ "$(git -C "$1" log -1 --pretty=%s)" = "chore: regenerate docs/ROADMAP.md [skip ci]" ] +' _ "$dirK" +check "K: the push actually reached the bare origin (main ref advanced)" bash -c ' + [ "$(git -C "$1" log -1 --pretty=%s refs/heads/main)" = "chore: regenerate docs/ROADMAP.md [skip ci]" ] +' _ "$originK" +check "K: the bare origin content matches the regenerated roadmap" bash -c ' + git -C "$1" show refs/heads/main:docs/ROADMAP.md | grep -q "issue #1 open" +' _ "$originK" +check "K: local main and origin main are in sync after the push" bash -c ' + [ "$(git -C "$1" rev-parse main)" = "$(git -C "$2" rev-parse refs/heads/main)" ] +' _ "$dirK" "$originK" + +# --------------------------------------------------------------------------- +# L. "No changes" path is taken when the regenerated roadmap is semantically +# UNCHANGED (issue #175 review finding #3): docs/ROADMAP.md is already +# committed (and pushed) with some body + a footer timestamp; the +# fixture's roadmap.sh stub regenerates the SAME body but a DIFFERENT +# footer timestamp/commit line, mimicking roadmap.sh's real footer churn. +# Asserts NO commit is made and the bare origin's main ref never moves. +# --------------------------------------------------------------------------- +dirL="$(new_fixture scenarioL)" +originL="$work/scenarioL-origin.git" +init_git_repo_fixture "$dirL" "$originL" +mkdir -p "$dirL/docs" +cat > "$dirL/docs/ROADMAP.md" <<'EOF' +# Roadmap + +- issue #1 open + +--- +_Generated 2026-01-01T00:00:00Z · commit `seed0001`_ +EOF +git -C "$dirL" add docs/ROADMAP.md +git -C "$dirL" commit -q -m "chore: regenerate docs/ROADMAP.md [skip ci]" +git -C "$dirL" push -q origin main + +cat > "$dirL/.claude/scripts/roadmap.sh" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "--write" ]; then + mkdir -p docs + cat > docs/ROADMAP.md <<'ROADMAP' +# Roadmap + +- issue #1 open + +--- +_Generated 2026-06-06T12:00:00Z · commit `deadbee1`_ +ROADMAP +fi +exit 0 +EOF +chmod +x "$dirL/.claude/scripts/roadmap.sh" +gh_logL="$work/scenarioL-gh.log" +cat > "$dirL/.claude/scripts/bot-gh.sh" <> "$gh_logL" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "21" + fi + ;; + view) + cat <<'JSON' +{"number":21,"title":"Ship gizmo","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-21-gizmo","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirL/.claude/scripts/bot-gh.sh" +preShaL="$(git -C "$originL" rev-parse refs/heads/main)" +outL="$( (cd "$dirL" && env -u GATES_FILE bash .claude/scripts/merge-ready.sh "acme/repo") 2>&1 )" +postShaL="$(git -C "$originL" rev-parse refs/heads/main)" + +check "L: PR merged" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outL" +check "L: roadmap_regen reports committed:false, reason no changes (footer-only diff ignored)" bash -c ' + printf "%s\n" "$1" | grep -q "roadmap_regen.*\"committed\":false" && printf "%s\n" "$1" | grep -q "no changes" +' _ "$outL" +check "L: no new commit was made locally (still just the single seeded roadmap commit)" bash -c ' + [ "$(git -C "$1" log --oneline | wc -l)" -eq 1 ] +' _ "$dirL" +check "L: the bare origin main ref never moved" bash -c '[ "$1" = "$2" ]' _ "$preShaL" "$postShaL" + +# --------------------------------------------------------------------------- +# M. Push FAILURE rolls back local $base so it is never left diverged from +# origin (issue #175 review finding #1b): the bare origin's pre-receive +# hook rejects every push (simulating branch protection / offline), so +# the regen commit is created locally but the push fails. Asserts (a) the +# merge itself still succeeds and merge-ready.sh still exits 0, and (b) +# local main ends up IDENTICAL to origin main (rolled back), never a +# dangling local commit diverging main from origin. +# --------------------------------------------------------------------------- +dirM="$(new_fixture scenarioM)" +originM="$work/scenarioM-origin.git" +init_git_repo_fixture "$dirM" "$originM" +printf 'seed\n' > "$dirM/seed.txt" +git -C "$dirM" add seed.txt +git -C "$dirM" commit -q -m "seed" +git -C "$dirM" push -q origin main +mkdir -p "$originM/hooks" +cat > "$originM/hooks/pre-receive" <<'EOF' +#!/usr/bin/env bash +echo "remote: rejected (branch protection)" >&2 +exit 1 +EOF +chmod +x "$originM/hooks/pre-receive" + +cat > "$dirM/.claude/scripts/roadmap.sh" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "--write" ]; then + mkdir -p docs + cat > docs/ROADMAP.md <<'ROADMAP' +# Roadmap + +- issue #1 open + +--- +_Generated 2026-01-01T00:00:00Z · commit `abc1234`_ +ROADMAP +fi +exit 0 +EOF +chmod +x "$dirM/.claude/scripts/roadmap.sh" +gh_logM="$work/scenarioM-gh.log" +cat > "$dirM/.claude/scripts/bot-gh.sh" <> "$gh_logM" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "22" + fi + ;; + view) + cat <<'JSON' +{"number":22,"title":"Ship sprocket","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-22-sprocket","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirM/.claude/scripts/bot-gh.sh" +preShaM="$(git -C "$originM" rev-parse refs/heads/main)" +set +e +outM="$( (cd "$dirM" && env -u GATES_FILE bash .claude/scripts/merge-ready.sh "acme/repo") 2>&1 )" +rcM=$? +set -e +postShaM="$(git -C "$originM" rev-parse refs/heads/main)" +localShaM="$(git -C "$dirM" rev-parse main)" + +check "M: merge-ready.sh exits 0 despite the roadmap push being rejected" bash -c '[ "$1" -eq 0 ]' _ "$rcM" +check "M: PR still reported merged (roadmap push failure did not roll back the merge)" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outM" +check "M: roadmap_regen surfaces the rolled-back commit/push failure" bash -c 'printf "%s\n" "$1" | grep -q "commit or push failed (rolled back)"' _ "$outM" +check "M: the bare origin main ref never moved (push was rejected)" bash -c '[ "$1" = "$2" ]' _ "$preShaM" "$postShaM" +check "M: local main was rolled back and is NOT diverged from origin main" bash -c '[ "$1" = "$2" ]' _ "$localShaM" "$postShaM" + echo "" if [ "$fail" -eq 0 ]; then echo "merge-ready.test.sh: PASS ($ok checks)" diff --git a/.claude/scripts/roadmap.sh b/.claude/scripts/roadmap.sh index 3354484..cf4a3b3 100755 --- a/.claude/scripts/roadmap.sh +++ b/.claude/scripts/roadmap.sh @@ -21,9 +21,18 @@ # companion" — that's exactly the census-invisible inbox state before owner # triage assigns a milestone). # -# STATE DERIVATION mirrors loop-census.sh's own ADVANCE/in_flight logic (never -# reimplemented, just re-applied here) so an issue's roadmap state never -# disagrees with what the loop itself would report: +# STATE DERIVATION follows the SAME shape as loop-census.sh's own +# ADVANCE/in_flight logic (closed/PR-open/in_flight/open), re-applied here so +# an issue's roadmap state usually agrees with what the loop would report. +# NOTE (issue #175 review finding #4): this is a simplified re-application, +# not a byte-for-byte port -- it does NOT carry census's stale-merged-remote +# refinement (loop-census.sh ignores a remote-only branch whose PR already +# merged; see loop-census.sh's own header). A branch here is "existing" if +# ANY matching local/remote-tracking ref is present, full stop. In practice +# this can only over-report `in_flight` for an issue whose old PR merged but +# whose remote-tracking ref hasn't been pruned yet -- never under-report, and +# never affects census/loop-tick's own ADVANCE decisions (this script only +# renders a read-only snapshot, it never feeds back into the loop): # closed — issue.state == CLOSED # PR#N open — an OPEN PR's headRefName (or title "#N" mention) # matches this issue number @@ -266,8 +275,9 @@ function parseBlocking(body) { } } -// ---- State derivation (mirrors loop-census.sh's own ADVANCE/in_flight logic, -// re-applied here rather than reimplemented from scratch) ------------------- +// ---- State derivation (same SHAPE as loop-census.sh's own ADVANCE/in_flight +// logic, re-applied here -- NOT the stale-merged-remote refinement, see this +// script's own header, issue #175 review finding #4) ------------------------ function findPRForIssue(n) { for (const pr of prs) { const branch = String(pr.headRefName || ""); @@ -280,6 +290,10 @@ function findPRForIssue(n) { } return null; } +// Any matching local OR remote-tracking ref counts as "existing" -- does NOT +// apply census's stale-merged-remote refinement (a remote-only ref whose PR +// already merged is still "existing" here), so this can over-report +// in_flight for a stale ref; see this script's header, issue #175 finding #4. function branchExistsForIssue(n) { const re = /(?:^|\/)(?:remotes\/[^/]+\/)?(?:feat|fix|work)\/issue-(\d+)(?:[-_]|$)/i; return branches.some((b) => { diff --git a/.claude/scripts/roadmap.test.sh b/.claude/scripts/roadmap.test.sh index 5b3173f..a152fd2 100755 --- a/.claude/scripts/roadmap.test.sh +++ b/.claude/scripts/roadmap.test.sh @@ -170,6 +170,14 @@ check "empty fixtures dir renders 'No unmilestoned feedback issues'" bash -c 'pr # 4. gh/network-unavailable degrade path: ROADMAP_GH_BIN stubbed to fail every # call. Must still exit 0 and render "unavailable" placeholders instead of # crashing (mirrors cockpit.sh's own degrade contract). +# NOTE (issue #175 review finding #5): unlike every other case in this +# suite, this one does NOT pass --fixtures, so roadmap.sh's branch-listing +# step falls through to a REAL, un-stubbed `git -C "$root" branch -a +# --list` against this actual checkout (there is no ROADMAP_BRANCHES_BIN +# seam to stub it, only ROADMAP_GH_BIN for milestones/issues/PRs). This is +# harmless -- read-only, no network, no mutation -- but it does mean this +# one assertion isn't fully hermetic; called out here rather than adding a +# new stub seam just for this. # --------------------------------------------------------------------------- fail_gh="$work/fail-gh.sh" cat > "$fail_gh" <<'EOF' From 4b0f06e6803ae9efe740c6cd2ca5bab103eaf708 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:46:57 +0200 Subject: [PATCH 3/3] fix(merge-ready): restore docs/ROADMAP.md on non-committing regen paths (issue #175) roadmap.sh --write unconditionally rewrites docs/ROADMAP.md's footer (timestamp + commit SHA) every run, even on paths that don't commit it: "no changes" (semantically identical content) and "not in sync" (local_sync_ok=0). Both previously exited without restoring the file, leaving the owner's real checkout dirty and, worse, wedging local_sync's own clean-tree precondition on the NEXT run. Restore docs/ROADMAP.md via `git checkout --` on both non-committing paths so the tree always ends up clean unless the commit+push path actually lands. The push-failure rollback path was already safe via its existing `git reset --hard origin/$base`. Adds a clean-tree assertion to scenario L (no-changes path) and a new scenario N exercising the not-in-sync path with a real diverged git repo, both asserting docs/ROADMAP.md is restored to clean afterward. Co-Authored-By: Claude Sonnet 5 --- .claude/scripts/merge-ready.sh | 9 +++ .claude/scripts/merge-ready.test.sh | 115 ++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/.claude/scripts/merge-ready.sh b/.claude/scripts/merge-ready.sh index 6679a2a..b297a4b 100644 --- a/.claude/scripts/merge-ready.sh +++ b/.claude/scripts/merge-ready.sh @@ -299,12 +299,21 @@ if [ "$merged" -gt 0 ]; then fi rm -f "$regen_err" if [ "$local_sync_ok" -ne 1 ]; then + git -C "$wt" checkout -- docs/ROADMAP.md 2>/dev/null || true echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"local $base not verified in sync with origin (see local_sync)\"}" exit 0 fi old_content="$(git -C "$wt" show "HEAD:docs/ROADMAP.md" 2>/dev/null | grep -v '^_Generated ' || true)" new_content="$(grep -v '^_Generated ' "$wt/docs/ROADMAP.md" 2>/dev/null || true)" if [ "$old_content" = "$new_content" ]; then + # roadmap.sh --write still rewrote the file on disk (its footer + # timestamp + commit SHA always change), so even though there's + # nothing worth committing, the working tree must be restored to + # clean here -- otherwise local_sync's own clean-tree precondition + # (above) would trip on THIS file on the very next run and wedge + # roadmap regeneration off forever (issue #175 review finding #1, + # round 2). + git -C "$wt" checkout -- docs/ROADMAP.md 2>/dev/null || true echo "{\"roadmap_regen\":\"generated\",\"committed\":false,\"reason\":\"no changes\"}" exit 0 fi diff --git a/.claude/scripts/merge-ready.test.sh b/.claude/scripts/merge-ready.test.sh index 50d6c5a..97fc12f 100755 --- a/.claude/scripts/merge-ready.test.sh +++ b/.claude/scripts/merge-ready.test.sh @@ -820,6 +820,14 @@ check "L: no new commit was made locally (still just the single seeded roadmap c [ "$(git -C "$1" log --oneline | wc -l)" -eq 1 ] ' _ "$dirL" check "L: the bare origin main ref never moved" bash -c '[ "$1" = "$2" ]' _ "$preShaL" "$postShaL" +# Round-2 regression guard (issue #175 review finding #1, round 2): the +# stub's --write still rewrote docs/ROADMAP.md on disk with a new footer +# even though nothing was committed -- the working tree MUST be restored to +# clean, or local_sync's own clean-tree precondition would trip on this file +# next run and wedge roadmap regen off forever. +check "L: working tree is clean afterward (ROADMAP.md was restored, not left dirty)" bash -c ' + git -C "$1" diff --quiet docs/ROADMAP.md +' _ "$dirL" # --------------------------------------------------------------------------- # M. Push FAILURE rolls back local $base so it is never left diverged from @@ -902,6 +910,113 @@ check "M: roadmap_regen surfaces the rolled-back commit/push failure" bash -c 'p check "M: the bare origin main ref never moved (push was rejected)" bash -c '[ "$1" = "$2" ]' _ "$preShaM" "$postShaM" check "M: local main was rolled back and is NOT diverged from origin main" bash -c '[ "$1" = "$2" ]' _ "$localShaM" "$postShaM" +# --------------------------------------------------------------------------- +# N. "Not in sync" path leaves the working tree CLEAN afterward (issue #175 +# review finding #1, round 2, regression guard): docs/ROADMAP.md is +# already committed+pushed, then local main and origin main are made to +# DIVERGE (an unrelated commit lands on the bare origin via a second +# clone, and a separate unrelated local-only commit lands on dirN that is +# never pushed) so local_sync's own `git merge --ff-only` fails and +# local_sync_ok stays 0. roadmap.sh --write still rewrites docs/ROADMAP.md +# on disk with a real content diff; the commit/push step must be skipped +# (not in sync), AND the rewritten file must be restored so the tree ends +# up clean -- otherwise the NEXT run's local_sync clean-tree precondition +# would trip on this file and wedge roadmap regen off forever. +# --------------------------------------------------------------------------- +dirN="$(new_fixture scenarioN)" +originN="$work/scenarioN-origin.git" +init_git_repo_fixture "$dirN" "$originN" +mkdir -p "$dirN/docs" +cat > "$dirN/docs/ROADMAP.md" <<'EOF' +# Roadmap + +- issue #1 open + +--- +_Generated 2026-01-01T00:00:00Z · commit `seed0001`_ +EOF +git -C "$dirN" add docs/ROADMAP.md +git -C "$dirN" commit -q -m "chore: regenerate docs/ROADMAP.md [skip ci]" +git -C "$dirN" push -q origin main + +# Diverge origin: a second clone pushes an unrelated commit directly. +cloneN="$work/scenarioN-clone" +git clone -q "$originN" "$cloneN" +git -C "$cloneN" config user.email "test@example.com" +git -C "$cloneN" config user.name "Test" +printf 'remote-only\n' > "$cloneN/remote-only.txt" +git -C "$cloneN" add remote-only.txt +git -C "$cloneN" commit -q -m "unrelated remote commit" +git -C "$cloneN" push -q origin main + +# Diverge dirN: an unrelated LOCAL-only commit that is never pushed, so +# dirN's main and origin/main now share only the earlier seed commit as +# common ancestor -- `git merge --ff-only origin/main` must fail. +printf 'local-only\n' > "$dirN/local-only.txt" +git -C "$dirN" add local-only.txt +git -C "$dirN" commit -q -m "unrelated local-only commit" + +cat > "$dirN/.claude/scripts/roadmap.sh" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "--write" ]; then + mkdir -p docs + cat > docs/ROADMAP.md <<'ROADMAP' +# Roadmap + +- issue #1 open +- issue #2 open + +--- +_Generated 2026-06-06T12:00:00Z · commit `deadbee2`_ +ROADMAP +fi +exit 0 +EOF +chmod +x "$dirN/.claude/scripts/roadmap.sh" +gh_logN="$work/scenarioN-gh.log" +cat > "$dirN/.claude/scripts/bot-gh.sh" <> "$gh_logN" +case "\$1" in + pr) + case "\$2" in + list) + if printf '%s\n' "\$*" | grep -q -- '--json number'; then + echo "23" + fi + ;; + view) + cat <<'JSON' +{"number":23,"title":"Ship widget-two","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-23-widget-two","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} +JSON + ;; + merge) exit 0 ;; + comment) : ;; + *) : ;; + esac + ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op + *) echo "unhandled: \$*" >&2; exit 1 ;; +esac +EOF +chmod +x "$dirN/.claude/scripts/bot-gh.sh" +preShaN="$(git -C "$originN" rev-parse refs/heads/main)" +localHeadBeforeN="$(git -C "$dirN" rev-parse main)" +outN="$( (cd "$dirN" && env -u GATES_FILE bash .claude/scripts/merge-ready.sh "acme/repo") 2>&1 )" +postShaN="$(git -C "$originN" rev-parse refs/heads/main)" +localHeadAfterN="$(git -C "$dirN" rev-parse main)" + +check "N: PR merged" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outN" +check "N: local_sync reports skip (diverged)" bash -c 'printf "%s\n" "$1" | grep -q "\"local_sync\":\"skip\""' _ "$outN" +check "N: roadmap_regen reports committed:false, reason not-in-sync" bash -c ' + printf "%s\n" "$1" | grep -q "roadmap_regen.*\"committed\":false" && printf "%s\n" "$1" | grep -q "not verified in sync" +' _ "$outN" +check "N: no new commit was made locally beyond the unrelated local-only commit" bash -c '[ "$1" = "$2" ]' _ "$localHeadBeforeN" "$localHeadAfterN" +check "N: the bare origin main ref never moved" bash -c '[ "$1" = "$2" ]' _ "$preShaN" "$postShaN" +check "N: working tree is clean afterward (ROADMAP.md was restored, not left dirty)" bash -c ' + git -C "$1" diff --quiet docs/ROADMAP.md +' _ "$dirN" + echo "" if [ "$fail" -eq 0 ]; then echo "merge-ready.test.sh: PASS ($ok checks)"