diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 19a3de704c..ee48496747 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.26.7", + "version": "0.26.8", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and merge-rung raises binding from the team-tracked layer only), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index dd3ab2dac5..b7a52f68ee 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.26.8] + +### Fixed + +- **`fetch-all-pr-comments.sh` output can choke a downstream Python consumer on Windows + (emoji/cp1252 mismatch) (#597).** The script's UTF-8 JSON output commonly carries non-ASCII + bytes — bot badge images, reaction emoji — from bot review comments. Reproduced directly: a + Python consumer that opens the output (or reads this script's stdout) without an explicit UTF-8 + encoding inherits the interpreter's default ANSI code page on Windows (cp1252) and raises + `UnicodeDecodeError` on those bytes; this repo's own consumers (`babysit_findings.py`) already + pin `encoding="utf-8"` explicitly and are unaffected, so the gap is external/downstream + consumers. `fetch-all-pr-comments.sh --help` now documents the `PYTHONUTF8=1` (PEP 540) + requirement for Windows consumers that don't pin the encoding themselves. + `babysit-readiness-gate.sh` — the one `babysit_python` caller that parses this script's + comment-JSON schema and lacked the `export PYTHONUTF8=1` convention the two `bin/` babysit + wrappers already apply — now sets it too, closing the inconsistency. + ## [0.26.7] ### Fixed diff --git a/plugins/source-control/scripts/babysit-readiness-gate.sh b/plugins/source-control/scripts/babysit-readiness-gate.sh index aef603d27f..88ae6277f3 100755 --- a/plugins/source-control/scripts/babysit-readiness-gate.sh +++ b/plugins/source-control/scripts/babysit-readiness-gate.sh @@ -73,6 +73,16 @@ # for the cause (owner/repo may be unresolved; see Repo resolution above) set -uo pipefail +# Matches the `export PYTHONUTF8=1` convention the bin/ babysit wrappers already +# apply before invoking babysit_python (source-control-babysit-merge, +# source-control-babysit-resolve-thread). This gate is the third babysit_python +# caller and the one that parses fetch-all-pr-comments.sh-shaped JSON (via +# babysit_findings.py --comments-json), which commonly carries non-ASCII bytes +# (bot badges, reaction emoji) from bot review comments — PEP 540 UTF-8 mode +# keeps the interpreter's default I/O encoding independent of the Windows ANSI +# code page (cp1252) for any code path that does not pin encoding="utf-8" +# itself (#597). +export PYTHONUTF8=1 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/plugins/source-control/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/scripts/babysit-readiness-gate.test.sh index bcf708bf91..c8b4d18076 100755 --- a/plugins/source-control/scripts/babysit-readiness-gate.test.sh +++ b/plugins/source-control/scripts/babysit-readiness-gate.test.sh @@ -206,6 +206,20 @@ else fail "portability: uses POSIX grep -ow" "present" "missing" fi +# --- Case: PYTHONUTF8=1 exported before invoking babysit_python (#597) -------- +# Matches the convention the bin/ babysit wrappers already apply +# (source-control-babysit-merge, source-control-babysit-resolve-thread): this +# gate is the third babysit_python caller and the one that parses +# fetch-all-pr-comments.sh-shaped JSON, which commonly carries non-ASCII bytes +# (bot badges, reaction emoji) that choke a Python code path relying on the +# interpreter's default I/O encoding on Windows (cp1252) instead of pinning +# encoding="utf-8" itself. +if [[ "$GATE_BODY" == *'export PYTHONUTF8=1'* ]]; then + pass "#597: exports PYTHONUTF8=1" +else + fail "#597: exports PYTHONUTF8=1" "present" "missing" +fi + # --- Case: adjacent severity words BOTH count (whole-word, no shared-boundary loss) - # `grep -ow` matches each word even when adjacent; an alternation-boundary regex # (^|[^w])WORD([^w]|$) would consume the shared space and undercount. @@ -277,6 +291,37 @@ probe_py() { "$@" -c 'import sys; raise SystemExit(0 if sys.version_info[:2] >= (3, 11) else 1)' \ >/dev/null 2>&1 } + +# --- Case: PYTHONUTF8=1 actually reaches the Python child process (#597) ------ +# A stubbed `py -3` records the PYTHONUTF8 value it inherited when +# babysit_python execs it, proving the export reaches the child process (not +# just present as dead source text in the static check above). +if probe_py py -3 || probe_py python3 || probe_py python; then + PYSTUB_BIN="$TEST_TMPDIR/bin-pystub" + mkdir -p "$PYSTUB_BIN" + PYUTF8_PROBE_FILE="$TEST_TMPDIR/pyutf8-probe.txt" + export PYUTF8_PROBE_FILE + cat >"$PYSTUB_BIN/py" <<'STUB' +#!/usr/bin/env bash +args=("$@") +for a in "${args[@]}"; do + if [[ "$a" == "-c" ]]; then + exit 0 # version probe + fi +done +printf '%s' "${PYTHONUTF8:-unset}" >"$PYUTF8_PROBE_FILE" +printf 'findings=0 classified=0\n' +STUB + chmod +x "$PYSTUB_BIN/py" + F=$(mkjson pyutf8-probe-fixture '[{author:"claude[bot]", body:"no findings here"}]') + PATH="$PYSTUB_BIN:$PATH" bash "$GATE" 123 --comments-json "$F" --self 'me[bot]' >/dev/null 2>&1 + probe_seen="$(cat "$PYUTF8_PROBE_FILE" 2>/dev/null || echo "missing")" + assert_eq "#597: child process inherits PYTHONUTF8=1" "1" "$probe_seen" + unset PYUTF8_PROBE_FILE +else + pass "#597: PYTHONUTF8 child-inheritance probe skipped (no Python 3.11+)" +fi + if probe_py py -3 || probe_py python3 || probe_py python; then F=$(mkjson lifetime-open '[ {author:"codex[bot]", body:"[CRITICAL] resolved earlier", isResolved:true}, diff --git a/plugins/source-control/scripts/fetch-all-pr-comments.sh b/plugins/source-control/scripts/fetch-all-pr-comments.sh index d92a648f50..7a3abd90a9 100755 --- a/plugins/source-control/scripts/fetch-all-pr-comments.sh +++ b/plugins/source-control/scripts/fetch-all-pr-comments.sh @@ -17,6 +17,15 @@ # FETCH_COMMENTS_OWNER default `gh repo view --json owner -q .owner.login` # FETCH_COMMENTS_REPO default `gh repo view --json name -q .name` # +# Windows Python consumers (#597): +# Output commonly carries non-ASCII bytes (bot badge images, reaction +# emoji) from bot review comments. A downstream Python consumer on Windows +# that opens the JSON output, or reads this script's stdout, WITHOUT an +# explicit UTF-8 encoding inherits the interpreter's default ANSI code page +# (cp1252) and raises UnicodeDecodeError on those bytes. Either open with +# `encoding="utf-8"` (or reconfigure the stream to UTF-8 at runtime), or run +# the consumer with `PYTHONUTF8=1` (PEP 540) set in the environment. +# # Exit codes: # 0 success (zero or more comments emitted) # 1 invalid argument @@ -30,7 +39,7 @@ set -uo pipefail # -e omitted: gh api failures explicitly guarded with || { exit PR_NUMBER="" usage() { - sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit 0 } diff --git a/plugins/source-control/scripts/fetch-all-pr-comments.test.sh b/plugins/source-control/scripts/fetch-all-pr-comments.test.sh index 351a9937c4..983103b587 100755 --- a/plugins/source-control/scripts/fetch-all-pr-comments.test.sh +++ b/plugins/source-control/scripts/fetch-all-pr-comments.test.sh @@ -226,6 +226,15 @@ out=$(FETCH_COMMENTS_OWNER=o FETCH_COMMENTS_REPO=r \ rc=$? assert_eq "env-var override resolves owner/repo (exit 0)" "0" "$rc" +# Case 11 (#597): --help documents the Windows PYTHONUTF8=1 gotcha for a +# downstream Python consumer parsing this script's UTF-8 JSON output (bot +# badges / reaction emoji commonly present in review comment bodies raise +# UnicodeDecodeError under the Windows default cp1252 encoding if the +# consumer does not pin encoding="utf-8" or PYTHONUTF8=1 itself). +help_out=$(bash "$SCRIPT" --help 2>&1) +assert_contains "--help documents PYTHONUTF8=1 for Windows consumers" "$help_out" "PYTHONUTF8=1" +assert_contains "--help names the UnicodeDecodeError failure mode" "$help_out" "UnicodeDecodeError" + # ---- Summary ---------------------------------------------------------------- if [[ "$FAILED" -eq 0 ]]; then