From e7fc47009c56fa92aa358107e4c3367d861595a4 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:36:30 +0200 Subject: [PATCH] fix(harness): assign bot PRs as a soft-fail follow-up, not inline Some bot tokens (classic PATs scoped to repo only, no read:org) can create PRs fine but have the assignee-resolution step rejected. Doing it inline via `gh pr create --assignee` meant that failure took the whole PR create down with it. Split it into create-then-assign so a scope-rejected assignment just leaves the PR unassigned (warned on stderr) instead of losing the PR. Also adds a MIGRATION.md diligence step: diff each generic file against the plugin's shipped copy before deleting it, since a hand-copied install can carry real local patches (this bug's reDeploy workaround being the concrete example) that a blanket delete would silently lose. --- .claude/scripts/bot-gh.sh | 48 +++++++++++++++++++++++++++++++++++---- docs/MIGRATION.md | 18 +++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/.claude/scripts/bot-gh.sh b/.claude/scripts/bot-gh.sh index 3547f55..3c1be7b 100755 --- a/.claude/scripts/bot-gh.sh +++ b/.claude/scripts/bot-gh.sh @@ -65,6 +65,17 @@ args=("$@") # that a PR is waiting for review (a bot-authored PR otherwise has no assignee). # Only applies to `pr create`, and only if the caller didn't already pass # --assignee (or its short form -a) themselves. +# +# Assignment is a SEPARATE follow-up call after `pr create` succeeds, not an +# inline `--assignee` flag on the create itself. Some bot tokens (classic PATs +# scoped to `repo` only, no `read:org`) can create PRs fine but have the +# assignee-resolution step rejected — if that were inline, the whole +# `pr create` would fail and no PR would exist at all. As a follow-up, a +# scope-rejected assignment just leaves the PR unassigned (soft failure, +# warned on stderr) instead of losing the PR. +has_assignee=1 +owner="" +repo_full="" if [ "${1:-}" = "pr" ] && [ "${2:-}" = "create" ]; then has_assignee=0 for a in "$@"; do @@ -82,8 +93,8 @@ if [ "${1:-}" = "pr" ] && [ "${2:-}" = "create" ]; then if [ -z "$owner" ] && [ -n "$target_repo" ]; then owner="${target_repo%%/*}" fi + origin_url="$(git -C "$root" remote get-url origin 2>/dev/null || true)" if [ -z "$owner" ]; then - origin_url="$(git -C "$root" remote get-url origin 2>/dev/null || true)" case "$origin_url" in git@github.com:*) owner="${origin_url#git@github.com:}" @@ -98,12 +109,39 @@ if [ "${1:-}" = "pr" ] && [ "${2:-}" = "create" ]; then if [ -z "$owner" ]; then owner="$(GH_TOKEN="$GH_BOT_TOKEN" gh repo view --json owner --jq .owner.login 2>/dev/null || true)" fi - # Fail soft: if the owner can't be resolved, create the PR unassigned - # rather than erroring out. - if [ -n "$owner" ]; then - args+=(--assignee "$owner") + # repo_full (owner/name) is needed for the follow-up assignees API call + # below; reuse target_repo/origin-parsing where possible, gh repo view as + # a last resort. + repo_full="$target_repo" + if [ -z "$repo_full" ]; then + case "$origin_url" in + git@github.com:*) repo_full="${origin_url#git@github.com:}"; repo_full="${repo_full%.git}";; + https://github.com/*) repo_full="${origin_url#https://github.com/}"; repo_full="${repo_full%.git}";; + esac + fi + if [ -z "$repo_full" ]; then + repo_full="$(GH_TOKEN="$GH_BOT_TOKEN" gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true)" + fi + fi +fi + +if [ "$has_assignee" -eq 0 ] && [ -n "$owner" ]; then + # Create WITHOUT an inline --assignee (stdout captured only for the PR URL; + # stderr still streams live), then assign as a soft-fail follow-up. + if pr_url="$(GH_TOKEN="$GH_BOT_TOKEN" gh "${args[@]}")"; then + status=0 + else + status=$? + fi + printf '%s\n' "$pr_url" + if [ "$status" -ne 0 ]; then exit "$status"; fi + pr_number="${pr_url##*/}" + if [ -n "$repo_full" ] && [ -n "$pr_number" ]; then + if ! GH_TOKEN="$GH_BOT_TOKEN" gh api -X POST "repos/$repo_full/issues/$pr_number/assignees" -f "assignees[]=$owner" >/dev/null 2>&1; then + echo "bot-gh.sh: warning — created $pr_url but could not assign it to '$owner' (bot token may lack read:org); PR left unassigned." >&2 fi fi + exit 0 fi GH_TOKEN="$GH_BOT_TOKEN" exec gh ${args[@]+"${args[@]}"} diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 9693f31..79be7e2 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -23,6 +23,24 @@ frozen copy means you never get fixes/improvements: - `.claude/.claude-plugin/` — if you'd copied this too (it's this repo's own plugin manifest, not something a consumer needs locally). +### Before you delete: check for local patches +The list above assumes your copy of each generic file is identical to (or just staler than) what the plugin +ships. That's not always true — a hand-copied install can accumulate real, load-bearing local fixes (e.g. a +bot-token-scope workaround in `bot-gh.sh`, an extra retry in `notify-poll.sh`). Deleting one of those silently +loses the fix, and it can be a while before anyone notices it's gone. + +Before deleting each file, diff it against the plugin's shipped copy (`${CLAUDE_PLUGIN_ROOT}/` +once the plugin is enabled, or the sibling clone's `.claude/` if you're using the local-clone +install method) rather than assuming they match. For anything that diverges: +- **It's a generic improvement** (would help any consumer, not just this repo) — upstream it: open a PR + against `ai-project-orchestrator` with the fix, then delete your local copy once it's merged and the plugin + picks it up. No consumer-side sync step is needed for scripts/agents/commands (see "Enabling in a consuming + project" above) — once the fix lands upstream, everyone with the plugin enabled gets it immediately. +- **It's genuinely project-specific** (tied to something only your repo has — a different bot account's token + scopes, a stack-specific quirk) — don't delete it. Keep it as a locally-named override (e.g. rename it so it + doesn't collide with the plugin's file, and repoint whichever command/prompt referenced it), and note in + `CLAUDE.md` why the override exists so a future reader doesn't "clean it up" by mistake. + ## What to keep These are repo-specific — a plugin, by design, cannot carry them, so they stay yours regardless of the install method: