Skip to content
29 changes: 29 additions & 0 deletions plugins/issue-driven-dev/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Version number left to the merge: PR #426 and #424 both claim 3.6.0 and PR #418 claims 3.5.1.

### Fixed

- **`idd-issue` no longer tags `idd--baseline` when the issue was not created (#412).** Step 3.A captures the URL
from `gh-egress.sh`, checks that it ends in an issue number and stops otherwise (every failure — gh's error, a
missing label, a gate refusal — leaves stdout empty); it also sets `NEW_ISSUE_NUMBER`, which Step 4.6 reads.
Step 3.6 tags only a numeric number and prints a graceful-skip reason otherwise. When gh-egress prints an issue
URL but still exits non-zero, Step 3.A says the issue may exist instead of "not created", so it is not retried
into a duplicate. Group mode (Step 3.G keeps its number in `PRIMARY_NUM`) therefore skips the baseline tag
until #438; on 3.5.0 it tagged `idd--baseline`.
- **`idd-issue`'s `label_exists()` finds labels that exist (#411).** It GETs `repos/$GITHUB_REPO/labels/<name>`
instead of `gh label list --search … | grep -qx`, which missed labels outside the 30 best matches, treated the
name as a regex and is GraphQL (blocked in cloud sessions). A failed lookup is reported as such (exit 2), not as
a missing label, and Step 3.5 (b) uses `$GITHUB_REPO` instead of the bare repo name. Names now match ignoring
case, as GitHub and `gh issue edit --add-label` do; a name that is or contains a `.`/`..` or empty path part is reported
missing without a request.
- **`idd-update` no longer writes a setext heading above Current Status (#413).** Step 5 joins the new block with
exactly one blank line after stripping the body's trailing newlines, in both branches; Branch A therefore also
repairs bodies an older Branch B wrote as `last line\n---` (23 in this repo). That blank line is the one stated
exception to "never change content outside the managed zone". The splice reads the body itself (REST) in the
same call and stops when the read, `NUMBER`, `GITHUB_REPO` or the new block is missing, so a body lost between
Bash calls can no longer overwrite the issue with the status alone. The `## Current Status` anchor follows
CommonMark fences (``` and ~~~, fence length): a heading quoted inside a fence is not the anchor, and with a fence
left open at the end the status an earlier run appended is replaced instead of appended again (#290 grew one
block per run).

## [3.5.0] - 2026-10-20

### Added
Expand Down
114 changes: 114 additions & 0 deletions plugins/issue-driven-dev/scripts/tests/baseline-tag-guard/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Test: idd-issue never tags `idd--baseline` when the issue was not created (#412).
#
# Step 3.A showed a bare `gh-egress.sh create …` call: no captured URL, no issue
# number, no stop on failure. gh-egress leaves stdout empty on every failure (gh's
# own error, a missing label, a gate refusal at exit 10-15), so an agent that
# derived NUMBER from the output got "" and carried on. Step 3.6 then built the tag
# from that empty number, tagged and pushed `idd--baseline`, and every later
# failure printed "already exists — skip (idempotent)", which looks normal.
#
# The checks RUN both snippets out of SKILL.md (a stub gh-egress / gh, a temp repo
# with a bare remote), because the defect was behaviour, not a missing word. The
# Step 3.6 guard has a positive control: with the guard neutralised, the same
# empty number must produce `idd--baseline` again.
#
# Usage: bash test.sh (exit 0 = pass, 1 = fail)

set -u

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN="$(cd "$HERE/../../.." && pwd)"
SKILL="$PLUGIN/skills/idd-issue/SKILL.md"
. "$PLUGIN/scripts/lib/assert-helpers.sh"

TMP=$(mktemp -d "${TMPDIR:-/tmp}/baseline-tag-guard-XXXXXX") || exit 1
trap 'rm -rf "$TMP"' EXIT

fenced_block_with() { # $1 = needle → the first fenced bash block (in $SKILL) that contains it
awk -v needle="$1" '/^```bash/{buf=""; f=1; next} f&&/^```/{if (index(buf, needle)) {printf "%s", buf; exit} f=0; next} f{buf=buf $0 "\n"}' "$SKILL"
}
S36="$(fenced_block_with 'TAG="${BASELINE_FMT')"
S3A="$(fenced_block_with 'gh-egress.sh" create')"
assert_true "Step 3.6 tag block is extractable" '[ -n "$S36" ]'
assert_true "Step 3.A create block is extractable" '[ -n "$S3A" ]'

# ── Step 3.6: tag only a real issue number ──
GIT=(-c user.name=t -c user.email=t@t -c init.defaultBranch=main)
mkdir -p "$TMP/bin"
printf '#!/usr/bin/env bash\ncase "$*" in *defaultBranchRef*) echo main ;; *) echo "unexpected gh $*" >&2; exit 1 ;; esac\n' > "$TMP/bin/gh"
chmod +x "$TMP/bin/gh"
newrepo() { # $1 → $TMP/r-$1 (main, one commit) with a bare origin
local r="$TMP/r-$1"
git "${GIT[@]}" init -q --bare "$r.git" && git "${GIT[@]}" clone -q "$r.git" "$r" 2>/dev/null
( cd "$r" && git "${GIT[@]}" checkout -q -b main && git "${GIT[@]}" commit -q --allow-empty -m init && git "${GIT[@]}" push -q origin main 2>/dev/null )
}
run36() { # $1 repo, $2 NUMBER, $3 block → combined output; returns the block's exit code
( cd "$1" && PATH="$TMP/bin:$PATH" NUMBER="$2" GITHUB_REPO=o/r CONFIG_PATH="$TMP/no-config.json" bash -c "$3" 2>&1 )
}
remote_tags() { git -C "$1.git" tag --list; }

i=0
for bad in '' 'abc' 'https://github.com/o/r/issues/42' '042' '4 2'; do
i=$((i + 1)); newrepo "bad$i"
OUT="$(run36 "$TMP/r-bad$i" "$bad" "$S36")"; RC=$?
assert_exit "Step 3.6, NUMBER='$bad': never aborts (graceful-skip)" 0 "$RC"
assert_eq "Step 3.6, NUMBER='$bad': no tag is pushed" "" "$(remote_tags "$TMP/r-bad$i")"
assert_grep "Step 3.6, NUMBER='$bad': says why it skipped" "graceful-skip" "$OUT"
done
newrepo good
OUT="$(run36 "$TMP/r-good" 42 "$S36")"; RC=$?
assert_exit "Step 3.6, NUMBER=42: succeeds" 0 "$RC"
assert_eq "Step 3.6, NUMBER=42: tags idd-42-baseline on the remote" "idd-42-baseline" "$(remote_tags "$TMP/r-good")"

# under `set -u` an unset NUMBER still skips gracefully (TAG is built before the guard)
newrepo unset
OUT="$( cd "$TMP/r-unset" && PATH="$TMP/bin:$PATH" GITHUB_REPO=o/r CONFIG_PATH="$TMP/no-config.json" bash -u -c "$S36" 2>&1 )"; RC=$?
assert_exit "Step 3.6 under set -u, NUMBER unset: never aborts" 0 "$RC"
assert_grep "…and skips with a reason" "graceful-skip" "$OUT"

# positive control: neutralise the guard → the empty number tags idd--baseline again
MUTANT="$(printf '%s' "$S36" | sed 's/BASELINE_SKIP="no valid issue number[^;]*;/BASELINE_SKIP="";/')"
assert_true "control: the guard line is present to neutralise" '[ "$MUTANT" != "$S36" ]'
newrepo control
run36 "$TMP/r-control" '' "$MUTANT" >/dev/null
assert_eq "control: without the guard an empty number tags idd--baseline" "idd--baseline" "$(remote_tags "$TMP/r-control")"

# ── Step 3.A: capture the URL, check the number, stop when the issue was not created ──
FR="$TMP/root"; mkdir -p "$FR/scripts"
cat > "$FR/scripts/gh-egress.sh" <<'STUB'
#!/usr/bin/env bash
case "${STUB_MODE:-ok}" in
ok) echo "https://github.com/o/r/issues/77" ;;
fail) echo "could not add label: 'P0' not found" >&2; exit 1 ;;
refused) echo "✗ gh-egress: REFUSED — privacy net" >&2; exit 10 ;;
garbage) echo "oops" ;;
crlf) printf 'https://github.com/o/r/issues/79\r\n' ;;
url_then_fail) echo "https://github.com/o/r/issues/78"; echo "gh: later step failed" >&2; exit 1 ;;
esac
STUB
chmod +x "$FR/scripts/gh-egress.sh"
run3a() { # $1 STUB_MODE → output + the numbers left behind (also on exit); returns the block's exit code
STUB_MODE="$1" CLAUDE_PLUGIN_ROOT="$FR" GITHUB_REPO=o/r TITLE=T TYPE=bug SCRUB_LEVEL=warn NUMBER=999 NEW_ISSUE_NUMBER=999 \
bash -c 'trap '"'"'echo "EXIT-NUMBER=[${NUMBER:-}] EXIT-NEW=[${NEW_ISSUE_NUMBER:-}]"'"'"' EXIT'$'\n'"$S3A"$'\n''echo "NUMBER=[$NUMBER] NEW=[${NEW_ISSUE_NUMBER:-}]"' 2>&1
}
OUT="$(run3a ok)"; RC=$?
assert_exit "Step 3.A, created: continues" 0 "$RC"
assert_grep "Step 3.A, created: NUMBER is the new issue's number" "NUMBER=[77]" "$OUT"
assert_grep "Step 3.A, created: Step 4.6's NEW_ISSUE_NUMBER is set" "NEW=[77]" "$OUT"
for mode in fail refused garbage; do
OUT="$(run3a "$mode")"; RC=$?
assert_exit "Step 3.A, $mode: stops" 1 "$RC"
assert_grep "Step 3.A, $mode: says the issue was not created" "issue was not created" "$OUT"
assert_grep "Step 3.A, $mode: a stale number from before is not kept" "EXIT-NUMBER=[] EXIT-NEW=[]" "$OUT"
done

OUT="$(run3a crlf)"; RC=$?
assert_exit "Step 3.A, URL printed with a CR: continues" 0 "$RC"
assert_grep "…with the number" "NUMBER=[79]" "$OUT"
OUT="$(run3a url_then_fail)"; RC=$?
assert_exit "Step 3.A, URL printed but non-zero exit: stops" 1 "$RC"
assert_grep "…saying the issue may exist (no blind retry)" "the issue may exist" "$OUT"
refute_grep "…and not claiming it was not created" "issue was not created" "$OUT"

print_summary "baseline-tag-guard (#412)"
120 changes: 120 additions & 0 deletions plugins/issue-driven-dev/scripts/tests/idd-update-splice/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Test: idd-update never turns the body's last line into a setext heading (#413).
#
# Step 5 Branch B appended `\n---\n\n## Current Status…`. When the body ended without
# a newline — common for bodies edited on GitHub, and every body read through `$(...)`
# — the body's last line sat directly on the `---`, and CommonMark reads
# "a paragraph line\n---" as a setext h2. That heading then makes `/idd-edit --correct`
# refuse the section above Current Status. 23 bodies in this repo had that shape.
#
# The splice is RUN out of SKILL.md and its output parsed with the same pinned
# CommonMark parser the correction helper uses (markdown-it-py, table rule on). Branch
# A (an existing Current Status) is checked too: per the 2026-10-26 maintainer decision
# it re-joins with a blank line, which repairs bodies an older Branch B wrote as setext.
#
# Usage: bash test.sh (exit 0 = pass, 1 = fail)

set -u

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN="$(cd "$HERE/../../.." && pwd)"
SKILL="$PLUGIN/skills/idd-update/SKILL.md"
. "$PLUGIN/scripts/lib/assert-helpers.sh"

python3 -c 'import markdown_it' 2>/dev/null || { echo "✗ markdown-it-py missing (scripts/requirements-egress.txt)" >&2; exit 1; }

SPLICE="$(awk '/^```bash/{buf=""; f=1; next} f&&/^```/{if (index(buf, "HEAD_PART=")) {printf "%s", buf; exit} f=0; next} f{buf=buf $0 "\n"}' "$SKILL")"
assert_true "Step 5 splice block is extractable" '[ -n "$SPLICE" ]'

NEW_STATUS=$'---\n\n## Current Status\n\n**Phase**: diagnosed\n\n### Blocking\n- (none)'
# The block reads the body itself (gh api repos/R/issues/N --jq '.body // ""'); the stub serves STUB_BODY.
TMP=$(mktemp -d "${TMPDIR:-/tmp}/idd-update-splice-XXXXXX") || exit 1
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$TMP/bin"
cat > "$TMP/bin/gh" <<'STUB'
#!/usr/bin/env bash
[ -n "${STUB_FAIL:-}" ] && { echo "gh: HTTP 502" >&2; exit 1; }
[ "$1 $2" = "api repos/o/r/issues/7" ] || { echo "unexpected gh $*" >&2; exit 1; }
printf '%s\n' "$STUB_BODY"
STUB
chmod +x "$TMP/bin/gh"
# the block also sends the result (gh-egress edit) in the same call; the stub records what would be sent
mkdir -p "$TMP/root/scripts"
cat > "$TMP/root/scripts/gh-egress.sh" <<'STUB'
#!/usr/bin/env bash
while [ $# -gt 0 ]; do case "$1" in --body) printf '%s' "$2" > "$SENT"; shift 2 ;; *) shift ;; esac; done
STUB
export SENT="$TMP/sent.txt"
splice() { # $1 = body → UPDATED_BODY on stdout (exact bytes, via a trailing sentinel)
PATH="$TMP/bin:$PATH" STUB_BODY="$1" NUMBER=7 GITHUB_REPO=o/r NEW_STATUS="$NEW_STATUS" CLAUDE_PLUGIN_ROOT="$TMP/root" SCRUB_LEVEL=warn \
bash -c "$SPLICE"$'\n''printf "%s<END>" "$UPDATED_BODY"'
}
check() { # $1 = label, $2 = body, $3 = expected prefix that must survive verbatim
local out; out="$(splice "$2")"; out="${out%<END>}"
local verdict
verdict="$(BODY_OUT="$out" PREFIX="$3" python3 - <<'PY'
import os
from markdown_it import MarkdownIt
out, prefix = os.environ["BODY_OUT"], os.environ["PREFIX"]
toks = MarkdownIt("commonmark").enable("table").parse(out)
setext = sum(1 for t in toks if t.type == "heading_open" and t.markup in ("-", "="))
cs = sum(1 for i, t in enumerate(toks) if t.type == "inline" and t.content == "Current Status" and toks[i-1].type == "heading_open")
hr_before_cs = any(t.type == "hr" for t in toks[:next((i for i, t in enumerate(toks) if t.type == "inline" and t.content == "Current Status"), 0)])
print(f"setext={setext} cs={cs} hr={'yes' if hr_before_cs else 'no'} prefix={'yes' if out.startswith(prefix) else 'no'} old={'yes' if 'old status' in out else 'no'}")
PY
)"
assert_grep "$1: no setext heading" "setext=0" "$verdict"
assert_grep "$1: exactly one Current Status heading" "cs=1" "$verdict"
assert_grep "$1: the --- above it is a thematic break" "hr=yes" "$verdict"
assert_grep "$1: earlier content survives verbatim" "prefix=yes" "$verdict"
assert_grep "$1: the old Current Status is gone" "old=no" "$verdict"
}

# Branch B — no Current Status yet
check "B, paragraph, no final newline" 'Some text' 'Some text'
check "B, paragraph, one newline" $'Some text\n' 'Some text'
check "B, paragraph, blank line at end" $'Some text\n\n' 'Some text'
check "B, CRLF" $'Some text\r\n' 'Some text'
check "B, IC_R011 footer, no newline" $'Body.\n\n---\n**Source**: surfaced during x' $'Body.\n\n---\n**Source**: surfaced during x'
check "B, list" $'- a\n- b' $'- a\n- b'
check "B, table" $'| a | b |\n|---|---|\n| 1 | 2 |' $'| a | b |\n|---|---|\n| 1 | 2 |'
check "B, fenced block naming the heading" $'```\n## Current Status\n```' $'```\n## Current Status\n```'
# Branch A — an existing Current Status is replaced from the --- above it
check "A, normal shape" $'Problem.\n\n---\n\n## Current Status\n\nold status' $'Problem.'
check "A, setext shape an old Branch B wrote" $'Problem line\n---\n\n## Current Status\n\nold status' $'Problem line'
check "A, no --- above the heading" $'Problem.\n\n## Current Status\n\nold status' $'Problem.'
check "A, audit block above is kept" $'Problem.\n\n---\n\n### Clarity Surface\n\n| t |\n|---|\n| x |\n\n---\n\n## Current Status\nold status' $'Problem.\n\n---\n\n### Clarity Surface\n\n| t |\n|---|\n| x |'

# fences, the CommonMark way (verify r1): a quoted heading inside ~~~ or a 4-backtick fence is not the anchor
check "B, ~~~ fence quoting the heading" $'Text.\n\n~~~\n## Current Status\nquoted\n~~~' $'Text.\n\n~~~\n## Current Status\nquoted\n~~~'
check "B, 4-backtick fence holding a 3-backtick line" $'````\n```\n## Current Status\n````' $'````\n```\n## Current Status\n````'
# a backtick in the info string means "not a fence": read as a fence, the line would hide the heading and the
# later fences would leave it closed, so the old status would be kept and a second one appended
check "A, a line starting with inline triple backticks is not a fence" $'```x``` is inline\n\n## Current Status\nold status\n\n```\ncode\n```' $'```x``` is inline'
# an unclosed fence (#290): repeated runs must not keep appending Current Status blocks
U=$'Problem.\n\n```\nunclosed fence'
for run in 1 2 3; do U="$(splice "$U")"; U="${U%<END>}"; done
assert_eq "unclosed fence: three runs leave one Current Status line" "1" "$(printf '%s\n' "$U" | grep -c '^## Current Status$')"
assert_grep "unclosed fence: the original text is still there" "unclosed fence" "$U"

# the first Current Status outside fences is the anchor: duplicates an older run left are merged into one
check "A, two Current Status blocks are merged" $'Problem.\n\n---\n\n## Current Status\nold status\n\n---\n\n## Current Status\nold status again' $'Problem.'
# what is sent is exactly the spliced body
: > "$SENT"; OUT="$(splice $'Text\n')"; OUT="${OUT%<END>}"
assert_eq "the body sent through gh-egress is the spliced body" "$OUT" "$(cat "$SENT")"

# the block refuses to write when its inputs are missing or the body cannot be read (shell state lost, #414)
OUT="$(PATH="$TMP/bin:$PATH" STUB_BODY=x NUMBER=7 GITHUB_REPO=o/r NEW_STATUS="" bash -c "$SPLICE" 2>&1)"; RC=$?
assert_exit "an empty NEW_STATUS stops the splice" 1 "$RC"
OUT="$(PATH="$TMP/bin:$PATH" STUB_BODY=x NUMBER= GITHUB_REPO=o/r NEW_STATUS="$NEW_STATUS" bash -c "$SPLICE" 2>&1)"; RC=$?
assert_exit "a lost NUMBER stops the splice" 1 "$RC"
OUT="$(PATH="$TMP/bin:$PATH" STUB_BODY=x NUMBER=7 GITHUB_REPO= NEW_STATUS="$NEW_STATUS" bash -c "$SPLICE" 2>&1)"; RC=$?
assert_exit "a lost GITHUB_REPO stops the splice" 1 "$RC"
OUT="$(PATH="$TMP/bin:$PATH" STUB_FAIL=1 NUMBER=7 GITHUB_REPO=o/r NEW_STATUS="$NEW_STATUS" bash -c "$SPLICE"$'\n''echo "UPDATED=[$UPDATED_BODY]"' 2>&1)"; RC=$?
assert_exit "a failed body read stops the splice (no status-only overwrite)" 1 "$RC"
refute_grep "…before any UPDATED_BODY exists" "UPDATED=[" "$OUT"
# the body is read in this block, never taken from a BODY left over from an earlier call
assert_true "the splice block reads the body itself" 'printf "%s" "$SPLICE" | grep -q "BODY=\$(gh api \"repos/\$GITHUB_REPO/issues/\$NUMBER\""'

refute_output_grep "the bare \\n--- append is gone from Branch B" 'append 新區塊(`\n---\n\n## Current Status...`)' "$SKILL"
print_summary "idd-update-splice (#413)"
Loading
Loading