From 7ef99618871ef4bce9c7d407a01bad314c657b1a Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 6 Aug 2026 21:15:54 -0500 Subject: [PATCH] fix(git-tools): git-cli issue/pr create reported wrong number on Gitea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emit_created_json` scraped the first issue/pull URL found anywhere in a create command's raw output. `tea issues create` / `tea pr create` render the whole created object — title, author, and the full body, indented — before the flush-left confirmation URL. When the body itself links to another issue in the same repo (e.g. "Child of .../issues/1", the normal shape for a ticket linking back to a parent), that embedded link matches the same URL pattern and appears earlier in the output, so the wrapper silently reported the linked issue's number instead of the one just created. Reproduced against a real Gitea repo: six sequential `issue create` calls, each with a body linking to issue #1, all returned `{"number":1}` while the tracker actually assigned #2-#7. `pr create` shares the same normalizer and is fixed by the same change. - Anchor the primary match to a line containing nothing but the URL and take the last such match — tea's confirmation line is printed flush left with nothing else on it, while every title/body line is indented. gh's output is just the bare URL, so this is a no-op there. - Keep the old permissive "URL anywhere" scrape as a fallback (last match, not first) for any output shape the anchor doesn't recognize, so an unverified shape degrades to the old best-effort behavior instead of a hard failure on a write that already succeeded. - tests/git-cli/test-issue-write-json.sh: replace the synthetic one-line tea mocks with realistic multi-line output carrying two decoys per the design above, and confirm the suite fails against the pre-fix code and passes against the fix. - bump git-tools 2.2.1->2.2.2 and session 4.5.0->4.5.1 (both claude and copilot flavors; both vendor/reference git-cli); re-sync vendored copies Co-Authored-By: Claude Opus 5 --- .../git-tools/.claude-plugin/plugin.json | 2 +- plugins-claude/git-tools/scripts/git-cli | 22 ++++++++++++++----- .../session/.claude-plugin/plugin.json | 2 +- plugins-claude/session/scripts/git-cli | 22 ++++++++++++++----- .../git-tools/.claude-plugin/plugin.json | 2 +- .../session/.claude-plugin/plugin.json | 2 +- tests/git-cli/test-issue-write-json.sh | 22 +++++++++++++++++-- utils/git-cli | 22 ++++++++++++++----- 8 files changed, 75 insertions(+), 21 deletions(-) diff --git a/plugins-claude/git-tools/.claude-plugin/plugin.json b/plugins-claude/git-tools/.claude-plugin/plugin.json index 518a5e1..50a3461 100644 --- a/plugins-claude/git-tools/.claude-plugin/plugin.json +++ b/plugins-claude/git-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git-tools", - "version": "2.2.1", + "version": "2.2.2", "description": "GitHub and Gitea tooling — unified CLI wrapper (issues, PRs, CI runs) plus a ship orchestrator that drives the full branch/commit/push/PR/watch/cleanup lifecycle", "author": { "name": "Logan Gagne" diff --git a/plugins-claude/git-tools/scripts/git-cli b/plugins-claude/git-tools/scripts/git-cli index 16ff305..2b232ce 100755 --- a/plugins-claude/git-tools/scripts/git-cli +++ b/plugins-claude/git-tools/scripts/git-cli @@ -137,14 +137,26 @@ parse_body_args() { COMMENT_JQ='{id, author: (.user.login // .user // ""), body, html_url: (.html_url // ""), created_at}' # Emit {number, url} JSON from a create command's human output by scraping the -# first issue/PR URL it printed. Used by issue/pr create so write commands return -# parseable JSON instead of gh/tea's human-rendered text. Dies with the raw -# output if no URL is found (so a silent format change surfaces loudly). +# create confirmation URL it printed. Used by issue/pr create so write commands +# return parseable JSON instead of gh/tea's human-rendered text. Dies with the +# raw output if no URL is found (so a silent format change surfaces loudly). +# +# tea renders the created object's body before its confirmation URL, so a body +# linking to another issue puts a decoy URL earlier in the output — hence the +# last URL-only line, and last (never first) in the fallback too. gh prints just +# the bare URL, so both paths are no-ops there. The fallback exists so an +# unrecognised output shape degrades to the old scrape instead of dying. emit_created_json() { local raw="$1" url num url=$(printf '%s\n' "$raw" \ - | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ - | head -n1 || true) + | grep -oiE '^[[:space:]]*https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+[[:space:]]*$' \ + | tail -n1 \ + | tr -d '[:space:]' || true) + if [[ -z "$url" ]]; then + url=$(printf '%s\n' "$raw" \ + | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ + | tail -n1 || true) + fi [[ -n "$url" ]] || die "could not parse created object URL from output: $raw" num="${url##*/}" # The regex guarantees a trailing [0-9]+, so number is always a true integer. diff --git a/plugins-claude/session/.claude-plugin/plugin.json b/plugins-claude/session/.claude-plugin/plugin.json index 5c64db7..850b42c 100644 --- a/plugins-claude/session/.claude-plugin/plugin.json +++ b/plugins-claude/session/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session", - "version": "4.5.0", + "version": "4.5.1", "description": "Work session management — issue-driven and freeform doors sharing an explore-then-plan spine, with multi-agent orchestration and a review-gated PR finalizer", "author": { "name": "Logan Gagne" diff --git a/plugins-claude/session/scripts/git-cli b/plugins-claude/session/scripts/git-cli index 16ff305..2b232ce 100755 --- a/plugins-claude/session/scripts/git-cli +++ b/plugins-claude/session/scripts/git-cli @@ -137,14 +137,26 @@ parse_body_args() { COMMENT_JQ='{id, author: (.user.login // .user // ""), body, html_url: (.html_url // ""), created_at}' # Emit {number, url} JSON from a create command's human output by scraping the -# first issue/PR URL it printed. Used by issue/pr create so write commands return -# parseable JSON instead of gh/tea's human-rendered text. Dies with the raw -# output if no URL is found (so a silent format change surfaces loudly). +# create confirmation URL it printed. Used by issue/pr create so write commands +# return parseable JSON instead of gh/tea's human-rendered text. Dies with the +# raw output if no URL is found (so a silent format change surfaces loudly). +# +# tea renders the created object's body before its confirmation URL, so a body +# linking to another issue puts a decoy URL earlier in the output — hence the +# last URL-only line, and last (never first) in the fallback too. gh prints just +# the bare URL, so both paths are no-ops there. The fallback exists so an +# unrecognised output shape degrades to the old scrape instead of dying. emit_created_json() { local raw="$1" url num url=$(printf '%s\n' "$raw" \ - | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ - | head -n1 || true) + | grep -oiE '^[[:space:]]*https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+[[:space:]]*$' \ + | tail -n1 \ + | tr -d '[:space:]' || true) + if [[ -z "$url" ]]; then + url=$(printf '%s\n' "$raw" \ + | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ + | tail -n1 || true) + fi [[ -n "$url" ]] || die "could not parse created object URL from output: $raw" num="${url##*/}" # The regex guarantees a trailing [0-9]+, so number is always a true integer. diff --git a/plugins-copilot/git-tools/.claude-plugin/plugin.json b/plugins-copilot/git-tools/.claude-plugin/plugin.json index 518a5e1..50a3461 100644 --- a/plugins-copilot/git-tools/.claude-plugin/plugin.json +++ b/plugins-copilot/git-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "git-tools", - "version": "2.2.1", + "version": "2.2.2", "description": "GitHub and Gitea tooling — unified CLI wrapper (issues, PRs, CI runs) plus a ship orchestrator that drives the full branch/commit/push/PR/watch/cleanup lifecycle", "author": { "name": "Logan Gagne" diff --git a/plugins-copilot/session/.claude-plugin/plugin.json b/plugins-copilot/session/.claude-plugin/plugin.json index 5c64db7..850b42c 100644 --- a/plugins-copilot/session/.claude-plugin/plugin.json +++ b/plugins-copilot/session/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session", - "version": "4.5.0", + "version": "4.5.1", "description": "Work session management — issue-driven and freeform doors sharing an explore-then-plan spine, with multi-agent orchestration and a review-gated PR finalizer", "author": { "name": "Logan Gagne" diff --git a/tests/git-cli/test-issue-write-json.sh b/tests/git-cli/test-issue-write-json.sh index 37a89d8..bc437d6 100644 --- a/tests/git-cli/test-issue-write-json.sh +++ b/tests/git-cli/test-issue-write-json.sh @@ -83,8 +83,26 @@ case "$1 $2 $3" in "login list --output") echo '[{"url":"https://git.stonefish.tech","user":"alice"}]'; exit 0 ;; esac case "$1 $2" in - "issues create") echo "Created issue https://git.stonefish.tech/owner/repo/issues/42"; exit 0 ;; - "pr create") echo "Created pull https://git.stonefish.tech/owner/repo/pulls/7"; exit 0 ;; + # Realistic tea output, not a synthetic one-liner: tea renders the whole + # created object (title/author/body, indented) before a flush-left + # confirmation URL. Decoy #1 (both cases): a markdown link to issue #1 + # embedded mid-body, mirroring how real tracker tickets link back to a + # parent issue — the anchored regex alone rejects this (it's not a bare + # URL line), so it also guards the pre-anchor fallback path. Decoy #2 + # (pr create): a *bare* URL on its own indented line, e.g. a "See " + # reference — this one DOES match the anchored "line is just a URL" + # pattern, so only taking the *last* match (not first) tells it apart + # from the true confirmation line that follows. Together these catch a + # regression of the bug where emit_created_json grabbed the first + # issue/pull URL anywhere in the output instead of the trailing + # confirmation line, always reporting the linked issue's number (#1) + # instead of the one actually created (#42 / #7). + "issues create") + printf ' # #42 x (open)\n\n @alice created 2026-06-02\n\n Child of [parent](https://git.stonefish.tech/owner/repo/issues/1).\n\nhttps://git.stonefish.tech/owner/repo/issues/42\n' + exit 0 ;; + "pr create") + printf ' # #7 x (open)\n\n @alice wants to merge\n\n Fixes [parent](https://git.stonefish.tech/owner/repo/issues/1).\n\n See also\n https://git.stonefish.tech/owner/repo/issues/1\n\nhttps://git.stonefish.tech/owner/repo/pulls/7\n' + exit 0 ;; esac if [[ "$1" == "api" ]]; then case "$args" in diff --git a/utils/git-cli b/utils/git-cli index 16ff305..2b232ce 100755 --- a/utils/git-cli +++ b/utils/git-cli @@ -137,14 +137,26 @@ parse_body_args() { COMMENT_JQ='{id, author: (.user.login // .user // ""), body, html_url: (.html_url // ""), created_at}' # Emit {number, url} JSON from a create command's human output by scraping the -# first issue/PR URL it printed. Used by issue/pr create so write commands return -# parseable JSON instead of gh/tea's human-rendered text. Dies with the raw -# output if no URL is found (so a silent format change surfaces loudly). +# create confirmation URL it printed. Used by issue/pr create so write commands +# return parseable JSON instead of gh/tea's human-rendered text. Dies with the +# raw output if no URL is found (so a silent format change surfaces loudly). +# +# tea renders the created object's body before its confirmation URL, so a body +# linking to another issue puts a decoy URL earlier in the output — hence the +# last URL-only line, and last (never first) in the fallback too. gh prints just +# the bare URL, so both paths are no-ops there. The fallback exists so an +# unrecognised output shape degrades to the old scrape instead of dying. emit_created_json() { local raw="$1" url num url=$(printf '%s\n' "$raw" \ - | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ - | head -n1 || true) + | grep -oiE '^[[:space:]]*https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+[[:space:]]*$' \ + | tail -n1 \ + | tr -d '[:space:]' || true) + if [[ -z "$url" ]]; then + url=$(printf '%s\n' "$raw" \ + | grep -oiE 'https?://[^[:space:]]+/(issues?|pulls?)/[0-9]+' \ + | tail -n1 || true) + fi [[ -n "$url" ]] || die "could not parse created object URL from output: $raw" num="${url##*/}" # The regex guarantees a trailing [0-9]+, so number is always a true integer.