From 7daa9be6b319e1a3954fd246cdcf44214a523237 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sat, 20 Jun 2026 04:07:31 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20issue=20#819=20?= =?UTF-8?q?=E2=80=94=20Parameterize=20the=20central=20initiative-planner?= =?UTF-8?q?=20on=20a=20target=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/initiative-planner.yml | 52 +++++++++++---- scripts/initiative-planner/redispatch.sh | 12 +++- tests/test_initiative_planner.bats | 66 +++++++++++++++++++ tests/test_initiative_planner_redispatch.bats | 13 ++++ 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/.github/workflows/initiative-planner.yml b/.github/workflows/initiative-planner.yml index 14a91fde3..1708871b8 100644 --- a/.github/workflows/initiative-planner.yml +++ b/.github/workflows/initiative-planner.yml @@ -26,6 +26,11 @@ on: description: 'Ideas Discussion number to plan into an initiative' required: true type: string + target_repo: + description: 'Target repo (owner/repo) to plan FOR — read the Discussion, create the epic + DAG, post the reply there. Empty => this repo (the dogfood/self path).' + required: false + default: '' + type: string dry_run: description: 'Plan only — log intended issue/DAG mutations to an artifact instead of creating them' required: false @@ -53,8 +58,10 @@ on: permissions: {} concurrency: - # One plan per discussion at a time; never cancel an in-flight planning run. - group: initiative-planner-${{ github.event.inputs.discussion || github.event.discussion.number }} + # One plan per (target repo, discussion) at a time; never cancel an in-flight + # planning run. Keying on the target repo as well as the discussion number stops + # two fleet repos that happen to share a discussion number from colliding. + group: initiative-planner-${{ github.event.inputs.target_repo || github.repository }}-${{ github.event.inputs.discussion || github.event.discussion.number }} cancel-in-progress: false jobs: @@ -91,6 +98,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} REPO: ${{ github.repository }} + # Dogfood path: this repo plans its own discussions, so the target is + # self. Fleet callers (#820) live in their own repos and set their host. + TARGET_REPO: ${{ github.repository }} DISCUSSION_NUMBER: ${{ github.event.discussion.number }} run: bash scripts/initiative-planner/redispatch.sh @@ -107,7 +117,15 @@ jobs: env: # This job only runs on workflow_dispatch (the discussion path re-dispatches # here via the `redispatch` job), so inputs are always present. - REPO: ${{ github.repository }} + # REPO is the TARGET repo to plan for; empty target_repo => this repo (self). + REPO: ${{ github.event.inputs.target_repo || github.repository }} + # Token for every WRITE to the TARGET repo (gather-context, the Bob + # materialization, apply-reviewed-plan, and the plan reply). GITHUB_TOKEN is + # scoped only to .github-private, so it is valid for the self path only; + # S1 (#818) mints a cross-repo App token here and points this at it. The + # workflow_dispatch re-invoke keeps GH_PAT_WORKFLOWS (redispatch job) — that + # role is loop-prevention, not content writes, and is unaffected. + TARGET_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} FORCE_REPLAN: ${{ inputs.force_replan == true && '1' || '0' }} @@ -143,8 +161,11 @@ jobs: run: | dest="${{ runner.temp }}/reviewed-plan" mkdir -p "$dest" + # The dry-run artifact is produced by a planner run, which always runs + # in THIS (central) repo — not the target repo. Download from self with + # GITHUB_TOKEN's actions:read, regardless of the plan's target_repo. gh run download "$PLAN_ARTIFACT_RUN_ID" \ - --repo "$REPO" \ + --repo "${{ github.repository }}" \ --name initiative-plan-dry-run \ --dir "$dest" if [ ! -s "$dest/plan.json" ]; then @@ -156,7 +177,8 @@ jobs: - name: Gather planning context env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Reads the TARGET repo's Discussion + open epics. + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} run: bash scripts/initiative-planner/gather-context.sh - name: Plan the initiative (BMAD Scrum Master) @@ -165,21 +187,22 @@ jobs: # supplied, we apply it verbatim and never re-plan. if: ${{ env.PLAN_ARTIFACT_RUN_ID == '' }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Bob materializes the epic + DAG in the TARGET repo via apply-plan.sh. + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} uses: anthropics/claude-code-action@d5726de019ec4498aa667642bc3a80fca83aa102 # v1.0.148 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # CRITICAL (same gotcha as feature-ideation): pass the workflow's - # GITHUB_TOKEN explicitly so the job-level issues/discussions write - # grants apply. The action's own app token lacks them and mutations - # would fail silently. - github_token: ${{ secrets.GITHUB_TOKEN }} + # CRITICAL (same gotcha as feature-ideation): pass the target-repo write + # token explicitly so the issues/discussions writes apply on the TARGET + # repo. The action's own app token lacks them and mutations would fail + # silently. + github_token: ${{ env.TARGET_GH_TOKEN }} claude_args: | --model ${{ github.event.inputs.model || 'claude-opus-4-8' }} --allowedTools Bash,Read,Glob,Grep prompt: | You are **Bob**, the BMAD Scrum Master, running as an automated, - non-interactive initiative planner for the ${{ github.repository }} repo. + non-interactive initiative planner for the ${{ env.REPO }} repo. ## Method — follow the vendored BMAD skills (by path) @@ -293,7 +316,7 @@ jobs: of writing to GitHub): ```bash - REPO="${{ github.repository }}" \ + REPO="$REPO" \ PLAN_PATH="$PLAN_PATH" \ DISCUSSION_NUMBER="$DISCUSSION_NUMBER" \ DISCUSSION_NODE_ID="$DISCUSSION_NODE_ID" \ @@ -336,7 +359,8 @@ jobs: - name: Apply the reviewed plan (no re-plan) if: ${{ env.PLAN_ARTIFACT_RUN_ID != '' }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Materializes the reviewed plan in the TARGET repo. + GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} run: bash scripts/initiative-planner/apply-reviewed-plan.sh - name: Upload dry-run plan log diff --git a/scripts/initiative-planner/redispatch.sh b/scripts/initiative-planner/redispatch.sh index fa20db254..4d0fb82e8 100755 --- a/scripts/initiative-planner/redispatch.sh +++ b/scripts/initiative-planner/redispatch.sh @@ -22,13 +22,18 @@ # scripts/initiative-driver.sh for the dev-lead label trigger. # # Env: -# REPO owner/repo (required) +# REPO owner/repo to DISPATCH the planner in (required). For the +# dogfood path this is .github-private itself; for fleet +# callers (per-repo stub, #820) it is the central planner repo. +# TARGET_REPO owner/repo the planner should PLAN FOR (default: REPO). +# The dogfood/self path leaves this unset so target == self. # DISCUSSION_NUMBER Ideas Discussion number to plan (required) # WORKFLOW_FILE workflow to dispatch (default: initiative-planner.yml) # GH_TOKEN a PAT with workflow scope (required at the call site) set -euo pipefail REPO="${REPO:?REPO required}" +TARGET_REPO="${TARGET_REPO:-$REPO}" DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" WORKFLOW_FILE="${WORKFLOW_FILE:-initiative-planner.yml}" @@ -52,8 +57,9 @@ if [[ -n "${GITHUB_REF:-}" && ( "$GITHUB_REF" == refs/heads/* || "$GITHUB_REF" = REF_FLAGS=(--ref "$GITHUB_REF") fi -echo "Discussion event → re-dispatching ${WORKFLOW_FILE} for discussion #${DISCUSSION_NUMBER} via workflow_dispatch." +echo "Discussion event → re-dispatching ${WORKFLOW_FILE} for discussion #${DISCUSSION_NUMBER} (target ${TARGET_REPO}) via workflow_dispatch." gh workflow run "$WORKFLOW_FILE" --repo "$REPO" "${REF_FLAGS[@]}" \ -f discussion="$DISCUSSION_NUMBER" \ + -f target_repo="$TARGET_REPO" \ -f dry_run="$DISPATCH_DRY_RUN" -echo "Dispatched ${WORKFLOW_FILE} (discussion=${DISCUSSION_NUMBER}, dry_run=${DISPATCH_DRY_RUN})." +echo "Dispatched ${WORKFLOW_FILE} (discussion=${DISCUSSION_NUMBER}, target_repo=${TARGET_REPO}, dry_run=${DISPATCH_DRY_RUN})." diff --git a/tests/test_initiative_planner.bats b/tests/test_initiative_planner.bats index 43869605a..4f103bf06 100644 --- a/tests/test_initiative_planner.bats +++ b/tests/test_initiative_planner.bats @@ -300,6 +300,72 @@ teardown() { rm -rf "$TMP"; } [ "$(grep -c '"op":"close_issue"' "$LOG")" -eq 0 ] } +# ── cross-repo target_repo parameterization (#819) ──────────────────────────── +# The central planner can plan for a NON-self target repo: context is gathered +# from, and the epic is created in, that target repo. The scripts already take +# REPO; these assert REPO threads through to the gh/GraphQL calls for a target +# repo that is NOT .github-private. gh is stubbed so no network is touched. + +@test "gather-context gathers context FROM the target repo (non-self)" { + MOCK_BIN="$(mktemp -d)" + GH_LOG="$TMP/gh.log" + export GH_LOG + export PATH="$MOCK_BIN:$PATH" + cat >"$MOCK_BIN/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$GH_LOG" +case "$*" in + *graphql*) echo '{"data":{"repository":{"discussion":{"id":"D_target","title":"Idea","body":"no refs here","url":"http://x","category":{"name":"Ideas"},"comments":{"nodes":[]}}}}}' ;; + *"issue list"*) echo '[]' ;; + *) echo '{}' ;; +esac +EOF + chmod +x "$MOCK_BIN/gh" + + CTX="$TMP/context.json" + GITHUB_ENV="$TMP/gh.env" REPO="acme/widgets" DISCUSSION_NUMBER=413 \ + CONTEXT_PATH="$CTX" GH_TOKEN=x \ + run bash "$PLANNER_DIR/gather-context.sh" + [ "$status" -eq 0 ] + # GraphQL discussion query targets the target repo's owner/name. + grep -qF -- "owner=acme" "$GH_LOG" + grep -qF -- "name=widgets" "$GH_LOG" + # Open-epics lookup runs against the target repo, not .github-private. + grep -qF -- "--repo acme/widgets" "$GH_LOG" + ! grep -qF -- ".github-private" "$GH_LOG" + # The assembled context records the target repo. + [ "$(jq -r '.repo' "$CTX")" = "acme/widgets" ] + + rm -rf "$MOCK_BIN" +} + +@test "apply-plan creates the epic IN the target repo (non-self, live)" { + MOCK_BIN="$(mktemp -d)" + GH_LOG="$TMP/gh.log" + export GH_LOG + export PATH="$MOCK_BIN:$PATH" + cat >"$MOCK_BIN/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$GH_LOG" +case "$*" in + *"issue list"*) echo '[]' ;; + *graphql*) echo '{}' ;; + *) echo '{"number":1,"id":1}' ;; +esac +EOF + chmod +x "$MOCK_BIN/gh" + + # Live path (no DRY_RUN): create_issue POSTs to repos//issues. + REPO="acme/widgets" DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" \ + PLAN_PATH="$PLAN" GH_TOKEN=x \ + run bash "$PLANNER_DIR/apply-plan.sh" + [ "$status" -eq 0 ] + grep -qF -- "api repos/acme/widgets/issues" "$GH_LOG" + ! grep -qF -- "repos/petry-projects/.github-private/issues" "$GH_LOG" + + rm -rf "$MOCK_BIN" +} + # ── reviewed-plan apply path (#604) ─────────────────────────────────────────── # apply-reviewed-plan.sh is the no-re-plan handoff: a maintainer-reviewed plan.json # is validated then handed straight to apply-plan.sh — no LLM planning step runs. diff --git a/tests/test_initiative_planner_redispatch.bats b/tests/test_initiative_planner_redispatch.bats index 5b780015e..3fd272eeb 100644 --- a/tests/test_initiative_planner_redispatch.bats +++ b/tests/test_initiative_planner_redispatch.bats @@ -91,6 +91,19 @@ teardown() { grep -qF -- "-f dry_run=true" "$GH_LOG" } +@test "passes target_repo defaulting to the dispatch repo (dogfood self path)" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -qF -- "-f target_repo=petry-projects/.github-private" "$GH_LOG" +} + +@test "honors a TARGET_REPO override (fleet host repo)" { + export TARGET_REPO="acme/widgets" + run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -qF -- "-f target_repo=acme/widgets" "$GH_LOG" +} + @test "passes GITHUB_REF if it is a branch or tag" { export GITHUB_REF="refs/heads/feature-branch" run bash "$SCRIPT" From 59a0707c10e566caa57b65fe67fbf3b9eaf2bb06 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sat, 20 Jun 2026 04:12:47 +0000 Subject: [PATCH 2/3] fix(reviews): address review comments [skip ci-relay] --- tests/test_initiative_planner.bats | 10 ++++------ tests/test_initiative_planner_redispatch.bats | 1 + 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_initiative_planner.bats b/tests/test_initiative_planner.bats index 4f103bf06..5b0cafc03 100644 --- a/tests/test_initiative_planner.bats +++ b/tests/test_initiative_planner.bats @@ -307,7 +307,8 @@ teardown() { rm -rf "$TMP"; } # repo that is NOT .github-private. gh is stubbed so no network is touched. @test "gather-context gathers context FROM the target repo (non-self)" { - MOCK_BIN="$(mktemp -d)" + MOCK_BIN="$TMP/mock_bin_gather" + mkdir -p "$MOCK_BIN" GH_LOG="$TMP/gh.log" export GH_LOG export PATH="$MOCK_BIN:$PATH" @@ -335,12 +336,11 @@ EOF ! grep -qF -- ".github-private" "$GH_LOG" # The assembled context records the target repo. [ "$(jq -r '.repo' "$CTX")" = "acme/widgets" ] - - rm -rf "$MOCK_BIN" } @test "apply-plan creates the epic IN the target repo (non-self, live)" { - MOCK_BIN="$(mktemp -d)" + MOCK_BIN="$TMP/mock_bin_apply" + mkdir -p "$MOCK_BIN" GH_LOG="$TMP/gh.log" export GH_LOG export PATH="$MOCK_BIN:$PATH" @@ -362,8 +362,6 @@ EOF [ "$status" -eq 0 ] grep -qF -- "api repos/acme/widgets/issues" "$GH_LOG" ! grep -qF -- "repos/petry-projects/.github-private/issues" "$GH_LOG" - - rm -rf "$MOCK_BIN" } # ── reviewed-plan apply path (#604) ─────────────────────────────────────────── diff --git a/tests/test_initiative_planner_redispatch.bats b/tests/test_initiative_planner_redispatch.bats index 3fd272eeb..107eadff5 100644 --- a/tests/test_initiative_planner_redispatch.bats +++ b/tests/test_initiative_planner_redispatch.bats @@ -92,6 +92,7 @@ teardown() { } @test "passes target_repo defaulting to the dispatch repo (dogfood self path)" { + unset TARGET_REPO run bash "$SCRIPT" [ "$status" -eq 0 ] grep -qF -- "-f target_repo=petry-projects/.github-private" "$GH_LOG" From 41eba1ce5f5a06793e78f86c66a0ddfdd34ef803 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Sat, 20 Jun 2026 04:26:37 +0000 Subject: [PATCH 3/3] fix(reviews): address review comments [skip ci-relay] --- .github/workflows/initiative-planner.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/initiative-planner.yml b/.github/workflows/initiative-planner.yml index 1708871b8..01049c5d3 100644 --- a/.github/workflows/initiative-planner.yml +++ b/.github/workflows/initiative-planner.yml @@ -122,10 +122,11 @@ jobs: # Token for every WRITE to the TARGET repo (gather-context, the Bob # materialization, apply-reviewed-plan, and the plan reply). GITHUB_TOKEN is # scoped only to .github-private, so it is valid for the self path only; - # S1 (#818) mints a cross-repo App token here and points this at it. The - # workflow_dispatch re-invoke keeps GH_PAT_WORKFLOWS (redispatch job) — that + # Cross-repo runs use GH_PAT_WORKFLOWS (same pattern as actions-fleet-monitor.yml); + # S1 (#818) will replace this with a minted GitHub App token for tighter scoping. + # The workflow_dispatch re-invoke keeps GH_PAT_WORKFLOWS (redispatch job) — that # role is loop-prevention, not content writes, and is unaffected. - TARGET_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_GH_TOKEN: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_WORKFLOWS }} DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }} DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }} FORCE_REPLAN: ${{ inputs.force_replan == true && '1' || '0' }}