Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions .github/workflows/initiative-planner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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 }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }}
DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }}
FORCE_REPLAN: ${{ inputs.force_replan == true && '1' || '0' }}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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" \
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions scripts/initiative-planner/redispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand All @@ -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})."
64 changes: 64 additions & 0 deletions tests/test_initiative_planner.bats
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,70 @@ 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="$TMP/mock_bin_gather"
mkdir -p "$MOCK_BIN"
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" ]
}
Comment thread
don-petry marked this conversation as resolved.

@test "apply-plan creates the epic IN the target repo (non-self, live)" {
MOCK_BIN="$TMP/mock_bin_apply"
mkdir -p "$MOCK_BIN"
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/<target>/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"
}
Comment thread
don-petry marked this conversation as resolved.

# ── 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.
Expand Down
14 changes: 14 additions & 0 deletions tests/test_initiative_planner_redispatch.bats
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ teardown() {
grep -qF -- "-f dry_run=true" "$GH_LOG"
}

@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"
}
Comment thread
don-petry marked this conversation as resolved.

@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"
Expand Down
Loading