diff --git a/.github/workflows/initiative-planner.yml b/.github/workflows/initiative-planner.yml index 61d322985..0a5bbb1ef 100644 --- a/.github/workflows/initiative-planner.yml +++ b/.github/workflows/initiative-planner.yml @@ -28,7 +28,12 @@ on: 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).' + description: 'Target repo (owner/repo) to plan FOR — create the epic + DAG there and read its _bmad-output/planning-artifacts/. Empty => this repo (the dogfood/self path).' + required: false + default: '' + type: string + discussion_repo: + description: 'Repo (owner/repo) holding the Discussion when it differs from target_repo (incubator idea -> product repo epic). The plan reply goes there. Empty => target_repo.' required: false default: '' type: string @@ -66,8 +71,13 @@ permissions: {} concurrency: # 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 }} + # two fleet repos that happen to share a discussion number from colliding; the + # discussion repo is keyed too, since a graduated idea's number is the + # incubator's, not the target's. + group: >- + initiative-planner-${{ github.event.inputs.target_repo || github.repository + }}-${{ github.event.inputs.discussion_repo || github.event.inputs.target_repo || github.repository + }}-${{ github.event.inputs.discussion || github.event.discussion.number }} cancel-in-progress: false jobs: @@ -139,6 +149,9 @@ jobs: # here via the `redispatch` job), so inputs are always present. # REPO is the TARGET repo to plan for; empty target_repo => this repo (self). REPO: ${{ github.event.inputs.target_repo || github.repository }} + # DISCUSSION_REPO holds the approved idea's Discussion; empty => REPO. It + # differs from REPO when an incubator idea graduates into a product repo. + DISCUSSION_REPO: ${{ github.event.inputs.discussion_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; @@ -146,7 +159,12 @@ jobs: # 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: ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) && secrets.GITHUB_TOKEN || secrets.GH_PAT_DON_PETRY || secrets.GH_PAT_WORKFLOWS }} + # GITHUB_TOKEN only when BOTH the target and the discussion repo are self; + # a cross-repo Discussion read/reply needs the PAT too. + TARGET_GH_TOKEN: >- + ${{ (github.event.inputs.target_repo == '' || github.event.inputs.target_repo == github.repository) + && (github.event.inputs.discussion_repo == '' || github.event.inputs.discussion_repo == github.repository) + && secrets.GITHUB_TOKEN || secrets.GH_PAT_DON_PETRY || 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' }} @@ -203,7 +221,8 @@ jobs: - name: Gather planning context env: - # Reads the TARGET repo's Discussion + open epics. + # Reads the Discussion from DISCUSSION_REPO, and the open epics + + # _bmad-output/planning-artifacts/ from the TARGET repo. GH_TOKEN: ${{ env.TARGET_GH_TOKEN }} run: bash scripts/initiative-planner/gather-context.sh @@ -281,8 +300,14 @@ jobs: ## Your input The approved idea + repo context is at `$CONTEXT_PATH` (JSON): the - Discussion title/body/comments, the open `initiative` epics, and the - issues the idea references (likely prerequisites). Read it, then ground + Discussion title/body/comments (from `discussion_repo`, which may be + an incubator repo rather than `repo`), the open `initiative` epics, + the issues the idea references (likely prerequisites), and + `planning_artifacts` — the target repo's + `_bmad-output/planning-artifacts/` (PRD in full, other docs + truncated), or `null` when it has none. When a PRD is present it is + your PRIMARY planning input; see "Planning inputs" in + `prompts/bmad/scrum-master.md`. Read it, then ground stories in the checked-out repo with Read/Grep/Glob (`AGENTS.md`, `CLAUDE.md`, `scripts/`, `.github/workflows/`, `docs/initiatives/`): @@ -373,6 +398,7 @@ jobs: ```bash REPO="$REPO" \ + DISCUSSION_REPO="$DISCUSSION_REPO" \ PLAN_PATH="$PLAN_PATH" \ DISCUSSION_NUMBER="$DISCUSSION_NUMBER" \ DISCUSSION_NODE_ID="$DISCUSSION_NODE_ID" \ diff --git a/docs/initiatives/idea-to-initiative-pipeline.md b/docs/initiatives/idea-to-initiative-pipeline.md index c1dddf048..cafcb746a 100644 --- a/docs/initiatives/idea-to-initiative-pipeline.md +++ b/docs/initiatives/idea-to-initiative-pipeline.md @@ -222,8 +222,66 @@ inputs. flow is unchanged. The handoff is artifact-download-by-run-ID; the artifact is re-fetched from GitHub Actions and not taken from a local file. This plan-review gate is **distinct** from the `initiative:auto` gate: the former binds the plan *contents*, the latter activates auto-*implementation*. +- **Planning inputs.** `gather-context.sh` also reads the target repo's + `_bmad-output/planning-artifacts/` (the planning source of truth named in org + `AGENTS.md`) into `planning_artifacts`: `prd.md` in full, every other + top-level `.md` truncated (12,000 chars by default). When a PRD is present Bob + plans from its functional requirements and success metrics and treats the + Discussion as context; with no folder (404) or no markdown in it, + `planning_artifacts` is `null` and Bob plans from the Discussion as before. +- **`discussion_repo`** (optional) names the repo the Discussion lives in when it + differs from `target_repo` — see [Graduation](#graduation-incubator-idea--product-repo). - Tooling: `scripts/initiative-planner/` (persona: `prompts/bmad/scrum-master.md`). +## Graduation: incubator idea → product repo + +An idea is approved in `combsmith/incubator`, but its epic belongs in the product +repo it graduates into. The planner reads the Discussion from one repo and plans +into another: + +``` +combsmith/incubator: ★ Ideas Discussion #N approved (decision brief merged) + │ + ▼ +product repo (e.g. combsmith/worldchest): seed _bmad-output/planning-artifacts/ + │ prd.md (+ brief.md, architecture.md, ...) committed on the default branch + ▼ +combsmith/.github-private initiative-planner.yml + -f discussion=N -f discussion_repo=combsmith/incubator -f target_repo= + │ reads Discussion #N from the incubator, the PRD + open epics from the + │ product repo; Bob plans from the PRD's FRs and success metrics + ▼ +inert epic + story DAG in the product repo; the plan reply is posted on the +incubator Discussion, with links qualified as #n +``` + +1. **Approve in the incubator.** The decision brief for Discussion #N is merged and + the owner decides to build. The incubator has no planner stub, so this does not + dispatch anything by itself. +2. **Seed the artifacts in the product repo.** Copy the PRD and its companions + into `_bmad-output/planning-artifacts/` on the product repo's default branch + (the planner reads it through the contents API, so it must be merged, not on + a branch). +3. **Dry-run the planner** with both repos set: + + ```bash + gh workflow run initiative-planner.yml --repo combsmith/.github-private \ + -f discussion=N -f discussion_repo=combsmith/incubator \ + -f target_repo=combsmith/ -f dry_run=true + ``` + +4. **Review and apply.** Download the `initiative-plan-dry-run` artifact, review + `plan.json`, then re-dispatch the same command with + `-f plan_artifact_run_id= -f dry_run=false`. +5. **Arm it.** Add `initiative:auto` to the new epic in the product repo. + +The epic's back-reference is `Planned from idea discussion combsmith/incubator#N`, +which is also the idempotency key, so re-dispatching (or `force_replan` / +`reconcile`) binds to the same epic. Same-repo runs keep the bare `#N` key, so +epics planned before this change still bind. A cross-repo run uses the PAT for +both repos (`GITHUB_TOKEN` only covers `.github-private`). No mirror Discussion in +the product repo is needed. + ## Labels the pipeline relies on | Label | On | Meaning | diff --git a/prompts/bmad/scrum-master.md b/prompts/bmad/scrum-master.md index e140b64c6..a9ed2bcdb 100644 --- a/prompts/bmad/scrum-master.md +++ b/prompts/bmad/scrum-master.md @@ -17,6 +17,35 @@ You are **Bob** 🏃 — Technical Scrum Master, crisp and checklist-driven, zer tolerance for ambiguity, whose job is to set the `dev-lead` developer agent up to succeed. +## Planning inputs — PRD first, Discussion as context + +`$CONTEXT_PATH` carries two kinds of input. Weigh them in this order: + +1. **`planning_artifacts`** — the target repo's BMAD planning output + (`_bmad-output/planning-artifacts/`), which org `AGENTS.md` names as the + source of truth for planning. `planning_artifacts.prd` is the PRD's path and + its `files[]` entry carries it in full; other files (brief, architecture, UX, + research) are truncated (`truncated: true`; read the full file from the + target repo if you need more). When a PRD is present: + - Its **functional requirements** are the scope. Every story traces to one or + more FRs (cite them, e.g. `FR3`, in `references` and the AC wording), and + every in-scope FR is covered by a story or explicitly deferred in + `open_questions`. Do not add scope the PRD does not ask for. + - Its **success metrics** become the epic's initiative-level success metric + (rubric item 2) — quote them; do not invent new ones. + - Its non-functional requirements and out-of-scope list bound the stories' + AC and Dev Notes. +2. **`discussion`** — the approved idea and its thread. With a PRD present it is + **context**: the why, the history, and decisions taken in comments. Where the + Discussion and the PRD disagree, follow the PRD and record the conflict as an + `open_questions` entry. With `planning_artifacts: null` (no PRD), the + Discussion is the primary input, as before. + +When `discussion_repo` differs from `repo`, the idea graduated from another repo +(usually the incubator). Its `referenced_issues` live in `discussion_repo` (each +entry carries `repo`); they are context only and must **not** become +`blocked_by_existing_issues`, which are issue numbers in `repo`. + ## Step 2 — Sprint planning (sequence the work) Read and follow the sprint-planning skill, applied to our input (the approved @@ -27,11 +56,12 @@ cat frameworks/bmad-method/src/bmm-skills/4-implementation/bmad-sprint-planning/ cat frameworks/bmad-method/src/bmm-skills/4-implementation/bmad-sprint-planning/checklist.md ``` -Decompose into the **smallest set of stories** (typically 3–8), each a single +Plan from the PRD's functional requirements when one exists (see "Planning +inputs" above). Decompose into the **smallest set of stories** (typically 3–8), each a single PR-sized unit for `dev-lead`, sequenced by a minimal `blocked_by` DAG (true ordering only; acyclic, with an entry point). Prerequisites already tracked as -repo issues (see `referenced_issues`) become `blocked_by_existing_issues`, not -new stories. +issues in the target repo (see `referenced_issues` whose `repo` is the target) +become `blocked_by_existing_issues`, not new stories. ## Step 3 — Create each story (BMAD create-story method) diff --git a/scripts/initiative-planner/README.md b/scripts/initiative-planner/README.md index 43df77ca7..50864e16b 100644 --- a/scripts/initiative-planner/README.md +++ b/scripts/initiative-planner/README.md @@ -14,7 +14,7 @@ create-story template, which `plan.schema.json` mirrors field-for-field. | File | Role | |------|------| | `redispatch.sh` | Bridge the `discussion [labeled]` trigger to `workflow_dispatch` (claude-code-action rejects `discussion` event contexts). Fired with a PAT so the dispatch actually starts a run. Forwards `force_replan=true` when the firing label is `initiative:replan`, or `reconcile=true` when it is `initiative:reconcile` (vs the default `idea:approved` plan path). | -| `gather-context.sh` | Fetch the approved Discussion + repo context → `$CONTEXT_PATH`; export `DISCUSSION_NODE_ID`. Under `RECONCILE=1` also harvests the bound epic's sub-issues (state, labels, comments) into a `reconcile` block so Bob can pick up comment-surfaced follow-ups (#708). | +| `gather-context.sh` | Fetch the approved Discussion (from `DISCUSSION_REPO`, default `REPO`) + the target repo's context and `_bmad-output/planning-artifacts/` (`planning_artifacts`; PRD in full, other `.md` truncated, `null` when absent) → `$CONTEXT_PATH`; export `DISCUSSION_NODE_ID`. Under `RECONCILE=1` also harvests the bound epic's sub-issues (state, labels, comments) into a `reconcile` block so Bob can pick up comment-surfaced follow-ups (#708). | | `plan.schema.json` | The plan contract Bob must emit (epic + stories + `blocked_by`). | | `validate-plan.py` | Schema + semantic checks: unique ids, acyclic DAG, an entry point, no dangling edges. | | `lib/mutations.sh` | DRY_RUN-aware GitHub helpers (create issue, link sub-issue, add `blocked_by`, comment on Discussion, find/close issues for the idempotency guard, list existing reconcile-keys for the reconcile diff). | diff --git a/scripts/initiative-planner/apply-plan.sh b/scripts/initiative-planner/apply-plan.sh index aefe55bdf..73264b255 100755 --- a/scripts/initiative-planner/apply-plan.sh +++ b/scripts/initiative-planner/apply-plan.sh @@ -25,6 +25,13 @@ # DISCUSSION_NUMBER source discussion number (for the summary text) # DISCUSSION_NODE_ID source discussion GraphQL node id (optional; if set, # the plan summary is posted back as a comment) +# DISCUSSION_REPO owner/repo the source discussion lives in (default: +# REPO). Cross-repo (graduation: incubator idea, product +# repo epic) the epic back-reference becomes +# "owner/repo#N" and issue links in the Discussion +# comment are qualified with REPO so they resolve. The +# comment itself goes to the Discussion's own repo via +# its global node id. # FORCE_REPLAN "1" => supersede an existing epic instead of no-op # DRY_RUN / DRY_RUN_LOG see lib/mutations.sh set -euo pipefail @@ -100,6 +107,18 @@ REPO="${REPO:?REPO required}" PLAN_PATH="${PLAN_PATH:?PLAN_PATH required}" DISCUSSION_NUMBER="${DISCUSSION_NUMBER:-}" DISCUSSION_NODE_ID="${DISCUSSION_NODE_ID:-}" +DISCUSSION_REPO="${DISCUSSION_REPO:-$REPO}" + +# Cross-repo references. Same-repo both prefixes are empty, so every rendered +# string (and the idempotency key) is byte-for-byte what it was before. +# disc_prefix qualifies the discussion number in text that lives in REPO +# issue_prefix qualifies REPO issue numbers in text posted to the Discussion +disc_prefix="" +issue_prefix="" +if [ "$DISCUSSION_REPO" != "$REPO" ]; then + disc_prefix="$DISCUSSION_REPO" + issue_prefix="$REPO" +fi [ -s "$PLAN_PATH" ] || { echo "::error::plan file '$PLAN_PATH' missing or empty" >&2; exit 1; } @@ -126,7 +145,7 @@ if [ "$blocking_count" -gt 0 ]; then other_list="$(jq -r '(.open_questions // []) | map(select((type=="string") or (type=="object" and .blocking != true))) | map(if type=="string" then . else .question end) | if length>0 then "\n\n_Also worth confirming (non-blocking):_\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" printf -v gate_comment '\n**⏸️ Not yet planned — the BMAD Scrum Master (Bob) has blocking open questions.**\n\nThis idea cannot be turned into an epic until these are answered:\n\n%s%s\n\n---\n**No epic or stories were created.** Answer the questions above, then re-approve / re-dispatch the planner and Bob will materialize the full epic + sub-issue DAG.' \ "$blocking_list" "$other_list" - comment_on_discussion "$DISCUSSION_NODE_ID" "$gate_comment" + comment_on_discussion "$DISCUSSION_NODE_ID" "$DISCUSSION_REPO" "$gate_comment" echo "posted 'not yet planned' open-questions back to discussion #${DISCUSSION_NUMBER:-?}" fi echo "::warning::initiative-planner: idea #${src_g:-?} has ${blocking_count} blocking open question(s) — no epic created. Answer them and re-dispatch." @@ -184,7 +203,7 @@ fi if [ -n "$src" ]; then # idem_key is the idempotency back-reference embedded in every epic body and # reused verbatim as the search key by find_existing_epic. - idem_key="Planned from idea discussion #${src}" + idem_key="Planned from idea discussion ${disc_prefix}#${src}" epic_body="${epic_body}"$'\n\n'"---"$'\n'"${idem_key} by the BMAD Scrum Master initiative-planner. **Inert until a maintainer adds \`initiative:auto\`.**" # Idempotency / supersede guard: a second dispatch/auto-trigger for the same @@ -222,9 +241,9 @@ if [ -n "$src" ]; then materialize_accepted_findings "$existing_epic" 1 if [ "$MATERIALIZED_COUNT" -gt 0 ] && [ -n "$DISCUSSION_NODE_ID" ]; then - printf -v reconcile_comment '\n**🔁 Reconciled epic #%s in place.**\n\n%s newly-surfaced item(s) were added as sub-issues of the existing epic (harvested from sub-issue/review comments, vetted through the plan-critic + acceptance gate). No existing story was rewritten; the epic stays inert (labelled `initiative`, NOT `initiative:auto`).' \ - "$existing_epic" "$MATERIALIZED_COUNT" - comment_on_discussion "$DISCUSSION_NODE_ID" "$reconcile_comment" + printf -v reconcile_comment '\n**🔁 Reconciled epic %s#%s in place.**\n\n%s newly-surfaced item(s) were added as sub-issues of the existing epic (harvested from sub-issue/review comments, vetted through the plan-critic + acceptance gate). No existing story was rewritten; the epic stays inert (labelled `initiative`, NOT `initiative:auto`).' \ + "$issue_prefix" "$existing_epic" "$MATERIALIZED_COUNT" + comment_on_discussion "$DISCUSSION_NODE_ID" "$DISCUSSION_REPO" "$reconcile_comment" echo "posted reconcile summary to discussion #${DISCUSSION_NUMBER:-?}" fi @@ -247,8 +266,8 @@ if [ -n "$src" ]; then else echo "already planned (epic #${existing_epic}) for idea discussion #${src} — skipping creation" if [ -n "$DISCUSSION_NODE_ID" ]; then - printf -v guard_comment '\n**ℹ️ Already planned — no new epic created.**\n\nThis idea is already materialized as epic #%s. Nothing was created on this run.\n\nTo re-plan from scratch (close the existing epic and its stories and build a fresh DAG), re-dispatch the planner with `force_replan=true`.' "$existing_epic" - comment_on_discussion "$DISCUSSION_NODE_ID" "$guard_comment" + printf -v guard_comment '\n**ℹ️ Already planned — no new epic created.**\n\nThis idea is already materialized as epic %s#%s. Nothing was created on this run.\n\nTo re-plan from scratch (close the existing epic and its stories and build a fresh DAG), re-dispatch the planner with `force_replan=true`.' "$issue_prefix" "$existing_epic" + comment_on_discussion "$DISCUSSION_NODE_ID" "$DISCUSSION_REPO" "$guard_comment" echo "posted 'already planned' notice back to discussion #${DISCUSSION_NUMBER:-?}" fi if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then @@ -350,12 +369,12 @@ if [ -n "$DISCUSSION_NODE_ID" ]; then for lid in "${local_ids[@]}"; do t="$(jq -r --argjson i "$lid" '.stories[] | select(.id==$i) | .title' "$PLAN_PATH")" sz="$(jq -r --argjson i "$lid" '.stories[] | select(.id==$i) | .size' "$PLAN_PATH")" - rows="${rows}- #${NUM_BY_LOCAL[$lid]} (${sz}) — ${t}"$'\n' + rows="${rows}- ${issue_prefix}#${NUM_BY_LOCAL[$lid]} (${sz}) — ${t}"$'\n' done oq="$(jq -r '(.open_questions // []) | map(if type=="string" then . else .question end) | if length>0 then "\n**Open questions for review:**\n" + (map("- " + .) | join("\n")) else "" end' "$PLAN_PATH")" - comment="$(printf '\n**📋 Initiative planned by the BMAD Scrum Master (Bob).**\n\nEpic **#%s** — %s\n\n%s stories created (inert — labelled `initiative`, NOT `initiative:auto`):\n\n%s\n%s\n---\nReview the epic and its sub-issue DAG, adjust as needed, then add **`initiative:auto`** to epic #%s to hand it to `initiative-driver` for auto-implementation.' \ - "$epic_number" "$epic_title" "$story_count" "$rows" "$oq" "$epic_number")" - comment_on_discussion "$DISCUSSION_NODE_ID" "$comment" + comment="$(printf '\n**📋 Initiative planned by the BMAD Scrum Master (Bob).**\n\nEpic **%s#%s** — %s\n\n%s stories created (inert — labelled `initiative`, NOT `initiative:auto`):\n\n%s\n%s\n---\nReview the epic and its sub-issue DAG, adjust as needed, then add **`initiative:auto`** to epic %s#%s to hand it to `initiative-driver` for auto-implementation.' \ + "$issue_prefix" "$epic_number" "$epic_title" "$story_count" "$rows" "$oq" "$issue_prefix" "$epic_number")" + comment_on_discussion "$DISCUSSION_NODE_ID" "$DISCUSSION_REPO" "$comment" echo "posted plan summary to discussion #${DISCUSSION_NUMBER:-?}" fi @@ -370,10 +389,10 @@ if [ -n "${SUPERSEDE_OLD_EPIC:-}" ]; then old_subs="$(list_sub_issue_numbers "$REPO" "$SUPERSEDE_OLD_EPIC")" while IFS= read -r old_sub; do [ -n "$old_sub" ] || continue - close_issue "$REPO" "$old_sub" "Superseded by the re-planned initiative epic #${epic_number} (re-planned from idea discussion #${src})." + close_issue "$REPO" "$old_sub" "Superseded by the re-planned initiative epic #${epic_number} (re-planned from idea discussion ${disc_prefix}#${src})." echo " closed superseded story #${old_sub}" done <<< "$old_subs" - close_issue "$REPO" "$SUPERSEDE_OLD_EPIC" "Superseded by #${epic_number} — re-planned from idea discussion #${src}. This epic and its stories were closed by the initiative-planner \`force_replan\` path." + close_issue "$REPO" "$SUPERSEDE_OLD_EPIC" "Superseded by #${epic_number} — re-planned from idea discussion ${disc_prefix}#${src}. This epic and its stories were closed by the initiative-planner \`force_replan\` path." echo " closed superseded epic #${SUPERSEDE_OLD_EPIC}" fi diff --git a/scripts/initiative-planner/gather-context.sh b/scripts/initiative-planner/gather-context.sh index 799a7500a..d6f4cd2e5 100755 --- a/scripts/initiative-planner/gather-context.sh +++ b/scripts/initiative-planner/gather-context.sh @@ -15,20 +15,60 @@ # threads and route them through the plan-critic acceptance gate. Read-only: this # never mutates the epic. # +# Cross-repo graduation: the approved idea's Discussion may live in a DIFFERENT +# repo (DISCUSSION_REPO, e.g. the incubator) from the repo we plan INTO (REPO, +# e.g. the product repo). The Discussion and the issues it references are read +# from DISCUSSION_REPO; open epics, the reconcile harvest and the planning +# artifacts are read from REPO. DISCUSSION_REPO defaults to REPO (same-repo path). +# +# Planning artifacts: when REPO has BMAD planning output under +# _bmad-output/planning-artifacts/ (the org's planning source of truth, see +# AGENTS.md), its markdown files are included under `planning_artifacts`: +# prd.md in full, every other .md truncated to PLANNING_ARTIFACT_MAX_CHARS. A +# 404 on the folder (or no markdown in it) yields `planning_artifacts: null` and +# Bob plans from the Discussion as before. Any other API failure fails loud. +# # Emits: # - $CONTEXT_PATH (JSON) the planning context # - DISCUSSION_NODE_ID -> $GITHUB_ENV so apply-plan can comment back # # Env: REPO, DISCUSSION_NUMBER, CONTEXT_PATH (all required); GH_TOKEN; -# RECONCILE (optional, "1" => also harvest the bound epic's sub-issues). +# DISCUSSION_REPO (optional owner/repo holding the Discussion; default REPO); +# RECONCILE (optional, "1" => also harvest the bound epic's sub-issues); +# PLANNING_ARTIFACTS_DIR (default _bmad-output/planning-artifacts); +# PLANNING_ARTIFACT_MAX_CHARS (default 12000; per non-PRD file); +# PLANNING_ARTIFACT_MAX_FILES (default 20; markdown files read). set -euo pipefail REPO="${REPO:?REPO required}" DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}" CONTEXT_PATH="${CONTEXT_PATH:?CONTEXT_PATH required}" +DISCUSSION_REPO="${DISCUSSION_REPO:-$REPO}" +PLANNING_ARTIFACTS_DIR="${PLANNING_ARTIFACTS_DIR:-_bmad-output/planning-artifacts}" +PLANNING_ARTIFACT_MAX_CHARS="${PLANNING_ARTIFACT_MAX_CHARS:-12000}" +PLANNING_ARTIFACT_MAX_FILES="${PLANNING_ARTIFACT_MAX_FILES:-20}" + +# Validate before the values reach a gh command line or an API path. +repo_re='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' +for v in REPO DISCUSSION_REPO; do + [[ ${!v} =~ $repo_re ]] || { echo "::error::${v} must be owner/repo, got: '${!v}'" >&2; exit 1; } +done +[[ $DISCUSSION_NUMBER =~ ^[1-9][0-9]*$ ]] || { + echo "::error::DISCUSSION_NUMBER must be a positive integer, got: '${DISCUSSION_NUMBER}'" >&2 + exit 1 +} -owner="${REPO%/*}" -name="${REPO#*/}" +owner="${DISCUSSION_REPO%/*}" +name="${DISCUSSION_REPO#*/}" + +# The idempotency back-reference apply-plan.sh stamps on the epic. Same-repo it +# stays "#N" (unchanged, so existing epics still bind); cross-repo it is the +# fully-qualified "owner/repo#N" so it links to the right Discussion. +disc_ref="#${DISCUSSION_NUMBER}" +[[ $DISCUSSION_REPO == "$REPO" ]] || disc_ref="${DISCUSSION_REPO}#${DISCUSSION_NUMBER}" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT # ── the approved idea (GraphQL: discussion is not in the REST issues space) ──── disc="$(gh api graphql -f query=' @@ -45,7 +85,7 @@ disc="$(gh api graphql -f query=' node_id="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.id')" category="$(printf '%s' "$disc" | jq -r '.data.repository.discussion.category.name // ""')" if [[ -z $node_id ]] || [[ $node_id = "null" ]]; then - echo "::error::discussion #${DISCUSSION_NUMBER} not found in ${REPO}" >&2 + echo "::error::discussion #${DISCUSSION_NUMBER} not found in ${DISCUSSION_REPO}" >&2 exit 1 fi if [[ $category != "Ideas" ]]; then @@ -68,13 +108,17 @@ ref_json='[]' if [[ -n $refs ]]; then ref_json="$(while IFS= read -r n; do [[ -n $n ]] || continue + # A bare #N in the Discussion resolves against the Discussion's OWN repo, + # so fetch it from DISCUSSION_REPO and record that repo on each entry + # (cross-repo, these are NOT issues in REPO and must not become + # blocked_by_existing_issues edges there). # A referenced #N may be an issue, a PR, a Discussion, or the idea's own # number. Note that the GitHub issues API also returns PRs, so both issues # and PRs are fetched successfully. Skip anything the issues endpoint # can't return (404 for discussions / self-refs) instead of failing the # whole run; a genuine API/auth fault still surfaces via the epics query # above (which fails fast). - gh api "repos/${REPO}/issues/${n}" --jq '{number, title, state}' 2>/dev/null \ + gh api "repos/${DISCUSSION_REPO}/issues/${n}" --jq "{repo: \"${DISCUSSION_REPO}\", number, title, state}" 2>/dev/null \ || echo "::notice::skipping referenced #${n} (not a fetchable issue/PR — discussion/self-ref)" >&2 done <<<"$refs" | jq -sc '.')" fi @@ -87,7 +131,7 @@ fi # emit an empty harvest and let apply-plan's reconcile no-op path handle it. reconcile_json='null' if [[ "${RECONCILE:-0}" == "1" ]]; then - backref="Planned from idea discussion #${DISCUSSION_NUMBER}" + backref="Planned from idea discussion ${disc_ref}" bound_epic="$(gh issue list --repo "$REPO" --label initiative --state open \ --search "\"$backref\"" --json number,body 2>/dev/null \ | jq -r --arg ref "$backref" 'first(.[] | select((.body // "" | tostring) | contains($ref)) | .number) // empty' || true)" @@ -113,25 +157,82 @@ if [[ "${RECONCILE:-0}" == "1" ]]; then reconcile_json="$(jq -nc --argjson epic "$bound_epic" --argjson subs "$subs_json" \ '{mode:"reconcile", epic:$epic, sub_issues:$subs}')" else - echo "::warning::reconcile: no existing epic bound to idea discussion #${DISCUSSION_NUMBER} — harvesting nothing." + echo "::warning::reconcile: no existing epic bound to idea discussion ${disc_ref} — harvesting nothing." reconcile_json="$(jq -nc '{mode:"reconcile", epic:null, sub_issues:[]}')" fi fi +# ── planning artifacts (target repo's BMAD planning output) ─────────────────── +# List the folder via the contents API. 404 => no artifacts (null, unchanged +# behaviour). Any other failure fails loud rather than letting Bob silently plan +# from Discussion prose when a PRD exists. Each file is fetched raw into $work and +# fed to jq with --rawfile, so a large PRD never rides on a command-line argument. +printf 'null\n' >"$work/artifacts.json" +art_err="$work/artifacts.err" +if listing="$(gh api "repos/${REPO}/contents/${PLANNING_ARTIFACTS_DIR}" 2>"$art_err")"; then + if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$listing"; then + echo "::notice::${REPO}:${PLANNING_ARTIFACTS_DIR} is not a directory listing — no planning artifacts." + listing='[]' + fi + # prd.md first, then the rest by name; only top-level markdown files are read. + mapfile -t md_paths < <(jq -r --argjson max "$PLANNING_ARTIFACT_MAX_FILES" ' + [.[] | select(.type == "file" and (.name | test("\\.md$"; "i")))] + | sort_by((.name | ascii_downcase) != "prd.md", .name) + | .[:$max][] | .path' <<<"$listing") + jq -c --argjson max "$PLANNING_ARTIFACT_MAX_FILES" ' + [.[] | select(.type == "file" and (.name | test("\\.md$"; "i")))] as $md + | [.[] | select((.type == "file" and (.name | test("\\.md$"; "i"))) | not) | .name] + + [$md | sort_by((.name | ascii_downcase) != "prd.md", .name) | .[$max:][] | .name]' \ + <<<"$listing" >"$work/skipped.json" + i=0 + for path in "${md_paths[@]}"; do + [[ $path =~ ^[A-Za-z0-9_./-]+$ ]] || { + echo "::warning::skipping planning artifact with an unsafe path: '${path}'" + continue + } + i=$((i + 1)) + gh api -H 'Accept: application/vnd.github.raw' "repos/${REPO}/contents/${path}" >"$work/art.$i.md" + jq -n --rawfile c "$work/art.$i.md" --arg path "$path" --argjson max "$PLANNING_ARTIFACT_MAX_CHARS" ' + ($path | split("/") | last) as $name + | (($name | ascii_downcase) == "prd.md") as $full + | {name: $name, path: $path, chars: ($c | length), + truncated: ((($full | not)) and (($c | length) > $max)), + content: (if $full then $c else $c[:$max] end)}' >"$work/art.$i.json" + done + if [[ $i -gt 0 ]]; then + jq -s --arg dir "$PLANNING_ARTIFACTS_DIR" --slurpfile skipped "$work/skipped.json" \ + '{dir: $dir, + prd: (map(select((.name | ascii_downcase) == "prd.md")) | first | .path? // null), + files: ., + skipped: $skipped[0]}' "$work"/art.*.json >"$work/artifacts.json" + else + echo "::notice::${REPO}:${PLANNING_ARTIFACTS_DIR} has no markdown files — no planning artifacts." + fi +elif grep -qE 'HTTP 404|Not Found' "$art_err"; then + echo "::notice::no ${PLANNING_ARTIFACTS_DIR} in ${REPO} — planning from the Discussion only." +else + echo "::error::failed to list ${PLANNING_ARTIFACTS_DIR} in ${REPO}: $(cat "$art_err")" >&2 + exit 1 +fi + # ── assemble ────────────────────────────────────────────────────────────────── jq -n \ --argjson disc "$disc" \ --argjson epics "$epics" \ --argjson refs "$ref_json" \ --argjson reconcile "$reconcile_json" \ + --slurpfile artifacts "$work/artifacts.json" \ --arg repo "$REPO" \ + --arg disc_repo "$DISCUSSION_REPO" \ --argjson num "$DISCUSSION_NUMBER" \ '{ repo: $repo, + discussion_repo: $disc_repo, discussion: ($disc.data.repository.discussion | {number: $num, title, body, url, category: .category.name, comments: [.comments.nodes[] | {author: .author.login, body, createdAt}]}), open_epics: $epics, referenced_issues: $refs, - reconcile: $reconcile + reconcile: $reconcile, + planning_artifacts: $artifacts[0] }' >"$CONTEXT_PATH" -echo "context written to ${CONTEXT_PATH} (node=${node_id}, refs=$(printf '%s' "$ref_json" | jq 'length'), epics=$(printf '%s' "$epics" | jq 'length')$(if [[ "${RECONCILE:-0}" == "1" ]]; then printf ', reconcile_subs=%s' "$(printf '%s' "$reconcile_json" | jq '.sub_issues | length')"; fi))" +echo "context written to ${CONTEXT_PATH} (discussion=${DISCUSSION_REPO}#${DISCUSSION_NUMBER}, node=${node_id}, artifacts=$(jq '.planning_artifacts.files // [] | length' "$CONTEXT_PATH"), refs=$(printf '%s' "$ref_json" | jq 'length'), epics=$(printf '%s' "$epics" | jq 'length')$(if [[ "${RECONCILE:-0}" == "1" ]]; then printf ', reconcile_subs=%s' "$(printf '%s' "$reconcile_json" | jq '.sub_issues | length')"; fi))" diff --git a/scripts/initiative-planner/lib/mutations.sh b/scripts/initiative-planner/lib/mutations.sh index ddfd26382..071802f7e 100644 --- a/scripts/initiative-planner/lib/mutations.sh +++ b/scripts/initiative-planner/lib/mutations.sh @@ -123,13 +123,16 @@ add_blocked_by() { -X POST -F issue_id="${blocker_id}" >/dev/null } -# comment_on_discussion +# comment_on_discussion +# The node id is global, so the comment lands on the Discussion in its OWN repo +# (which may differ from the repo the epic is created in). discussion_repo is +# recorded in the DRY_RUN log so that routing is auditable and testable. comment_on_discussion() { - local node_id="$1" body="$2" + local node_id="$1" disc_repo="$2" body="$3" if _is_dry_run; then _dry_log "$(jq -nc --arg op comment_on_discussion \ - --arg id "$node_id" --arg body "$body" \ - '{op:$op, discussion_id:$id, body:$body}')" + --arg id "$node_id" --arg repo "$disc_repo" --arg body "$body" \ + '{op:$op, discussion_id:$id, discussion_repo:$repo, body:$body}')" return 0 fi gh api graphql -f query=' diff --git a/scripts/initiative-planner/redispatch.sh b/scripts/initiative-planner/redispatch.sh index 57e631bf0..b1d30ed4a 100755 --- a/scripts/initiative-planner/redispatch.sh +++ b/scripts/initiative-planner/redispatch.sh @@ -27,6 +27,9 @@ # 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_REPO owner/repo holding the Discussion, when it differs from +# TARGET_REPO (graduation). Forwarded as discussion_repo only +# when set, so the default dispatch is unchanged. # DISCUSSION_NUMBER Ideas Discussion number to plan (required) # WORKFLOW_FILE workflow to dispatch (default: initiative-planner.yml) # DRY_RUN "1"/"true" => dispatch dry_run=true (default: false) @@ -86,10 +89,20 @@ 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} (target ${TARGET_REPO}) via workflow_dispatch." +DISC_FLAGS=() +if [[ -n "${DISCUSSION_REPO:-}" ]]; then + [[ "$DISCUSSION_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "::error::DISCUSSION_REPO must be owner/repo, got: '$DISCUSSION_REPO'" >&2 + exit 1 + } + DISC_FLAGS=(-f discussion_repo="$DISCUSSION_REPO") +fi + +echo "Discussion event → re-dispatching ${WORKFLOW_FILE} for discussion ${DISCUSSION_REPO:-}#${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" \ + "${DISC_FLAGS[@]}" \ -f dry_run="$DISPATCH_DRY_RUN" \ -f force_replan="$DISPATCH_FORCE_REPLAN" \ -f reconcile="$DISPATCH_RECONCILE" diff --git a/tests/test_initiative_planner.bats b/tests/test_initiative_planner.bats index a7ab30add..07ed341d6 100644 --- a/tests/test_initiative_planner.bats +++ b/tests/test_initiative_planner.bats @@ -364,6 +364,173 @@ EOF ! grep -qF -- "repos/combsmith/.github-private/issues" "$GH_LOG" } +# ── cross-repo Discussion + planning artifacts (graduation) ─────────────────── +# An incubator idea graduates into a product repo: the Discussion lives in +# DISCUSSION_REPO (the incubator), the epic and planning artifacts in REPO. gh is +# stubbed; the stub logs every call and serves the artifacts folder per test via +# ART_MODE (present | empty | 404 | 500). + +_gather_mock() { + MOCK_BIN="$TMP/mock_bin_ctx" + 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_incubator5","title":"WorldChest","body":"Builds on #12","url":"http://x","category":{"name":"Ideas"},"comments":{"nodes":[]}}}}}' ;; + *"issue list"*) echo '[]' ;; + *"/issues/12"*) echo '{"repo":"combsmith/incubator","number":12,"title":"Spike","state":"open"}' ;; + *"planning-artifacts/prd.md"*) printf 'PRD-START\nFR1: back up worlds\nSuccess metric: 100 weekly backups\n%s\nPRD-END\n' "$(printf 'p%.0s' $(seq 1 300))" ;; + *"planning-artifacts/brief.md"*) printf 'BRIEF-START %s BRIEF-END\n' "$(printf 'b%.0s' $(seq 1 300))" ;; + *"planning-artifacts"*) + case "${ART_MODE:-present}" in + present) echo '[{"name":"brief.md","path":"_bmad-output/planning-artifacts/brief.md","type":"file"},{"name":"prd.md","path":"_bmad-output/planning-artifacts/prd.md","type":"file"},{"name":"diagram.png","path":"_bmad-output/planning-artifacts/diagram.png","type":"file"},{"name":"research","path":"_bmad-output/planning-artifacts/research","type":"dir"}]' ;; + empty) echo '[]' ;; + 404) echo '{"message":"Not Found","status":"404"}'; echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + *) echo 'gh: Server Error (HTTP 500)' >&2; exit 1 ;; + esac ;; + *) echo '{}' ;; +esac +EOF + chmod +x "$MOCK_BIN/gh" + CTX="$TMP/context.json" +} + +@test "gather-context reads the Discussion from DISCUSSION_REPO but plans INTO REPO (cross-repo)" { + _gather_mock + ART_MODE=empty GITHUB_ENV="$TMP/gh.env" REPO="combsmith/worldchest" DISCUSSION_REPO="combsmith/incubator" \ + DISCUSSION_NUMBER=5 CONTEXT_PATH="$CTX" GH_TOKEN=x RECONCILE=1 \ + run bash "$PLANNER_DIR/gather-context.sh" + [ "$status" -eq 0 ] + # The Discussion query (and the issues it references) hit the incubator ... + # (the GraphQL query is multi-line in the log, so match its -F args directly) + grep -qF -- "name=incubator" "$GH_LOG" + grep -qF -- "repos/combsmith/incubator/issues/12" "$GH_LOG" + # ... while open epics, the reconcile back-reference and artifacts hit the product repo. + grep -F -- "issue list" "$GH_LOG" | grep -qF -- "--repo combsmith/worldchest" + grep -qF -- 'Planned from idea discussion combsmith/incubator#5' "$GH_LOG" + grep -qF -- "repos/combsmith/worldchest/contents/_bmad-output/planning-artifacts" "$GH_LOG" + run grep -F -- "--repo combsmith/incubator" "$GH_LOG" + [ "$status" -ne 0 ] + [ "$(jq -r '.repo' "$CTX")" = "combsmith/worldchest" ] + [ "$(jq -r '.discussion_repo' "$CTX")" = "combsmith/incubator" ] + [ "$(jq -r '.referenced_issues[0].repo' "$CTX")" = "combsmith/incubator" ] + grep -qx 'DISCUSSION_NODE_ID=D_incubator5' "$TMP/gh.env" +} + +@test "apply-plan (cross-repo) back-references the incubator Discussion and posts the reply there" { + jq '.source_discussion = 5' "$PLAN" >"$TMP/xrepo.json" + DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="combsmith/worldchest" DISCUSSION_REPO="combsmith/incubator" \ + DISCUSSION_NUMBER=5 DISCUSSION_NODE_ID="D_incubator5" PLAN_PATH="$TMP/xrepo.json" \ + run bash "$PLANNER_DIR/apply-plan.sh" + [ "$status" -eq 0 ] + epic_body="$(grep '"op":"create_issue"' "$LOG" | jq -r 'select(.title|startswith("Initiative:")) | .body')" + [[ "$epic_body" == *"Planned from idea discussion combsmith/incubator#5"* ]] + [ "$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.discussion_repo')" = "combsmith/incubator" ] + [ "$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.discussion_id')" = "D_incubator5" ] + # Issue links in the incubator-side comment are qualified with the product repo. + body="$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.body')" + [[ "$body" == *"Epic **combsmith/worldchest#"* ]] + [[ "$body" == *"- combsmith/worldchest#"* ]] +} + +@test "apply-plan (same-repo) keeps the bare #N back-reference and unqualified links" { + DRY_RUN=1 DRY_RUN_LOG="$LOG" REPO="combsmith/.github-private" \ + DISCUSSION_NUMBER=413 DISCUSSION_NODE_ID="D_test" PLAN_PATH="$PLAN" \ + run bash "$PLANNER_DIR/apply-plan.sh" + [ "$status" -eq 0 ] + epic_body="$(grep '"op":"create_issue"' "$LOG" | jq -r 'select(.title|startswith("Initiative:")) | .body')" + [[ "$epic_body" == *"Planned from idea discussion #413 by"* ]] + [ "$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.discussion_repo')" = "combsmith/.github-private" ] + body="$(grep '"op":"comment_on_discussion"' "$LOG" | jq -r '.body')" + [[ "$body" == *"Epic **#"* ]] +} + +@test "gather-context includes planning artifacts: prd.md in full, other markdown truncated" { + _gather_mock + ART_MODE=present PLANNING_ARTIFACT_MAX_CHARS=50 GITHUB_ENV="$TMP/gh.env" REPO="combsmith/worldchest" \ + DISCUSSION_NUMBER=6 CONTEXT_PATH="$CTX" GH_TOKEN=x \ + run bash "$PLANNER_DIR/gather-context.sh" + [ "$status" -eq 0 ] + [ "$(jq -r '.planning_artifacts.dir' "$CTX")" = "_bmad-output/planning-artifacts" ] + [ "$(jq -r '.planning_artifacts.prd' "$CTX")" = "_bmad-output/planning-artifacts/prd.md" ] + # prd.md sorts first and is complete, well past the 50-char cap. + [ "$(jq -r '.planning_artifacts.files[0].name' "$CTX")" = "prd.md" ] + [ "$(jq -r '.planning_artifacts.files[0].truncated' "$CTX")" = "false" ] + prd="$(jq -r '.planning_artifacts.files[0].content' "$CTX")" + [[ "$prd" == *"FR1: back up worlds"* ]] + [[ "$prd" == *"PRD-END"* ]] + # brief.md is cut to the cap and flagged. + [ "$(jq -r '.planning_artifacts.files[1].name' "$CTX")" = "brief.md" ] + [ "$(jq -r '.planning_artifacts.files[1].truncated' "$CTX")" = "true" ] + [ "$(jq -r '.planning_artifacts.files[1].content | length' "$CTX")" -eq 50 ] + [ "$(jq '.planning_artifacts.files[1].chars > 50' "$CTX")" = "true" ] + # Non-markdown entries are listed as skipped, not read. + [ "$(jq -c '.planning_artifacts.skipped' "$CTX")" = '["diagram.png","research"]' ] + run grep -F -- "diagram.png" "$GH_LOG" + [ "$status" -ne 0 ] + [ "$(grep -c 'contents/_bmad-output/planning-artifacts/' "$GH_LOG")" -eq 2 ] + # Raw fetch, so content is never base64 and never rides on argv. + grep -F -- "planning-artifacts/prd.md" "$GH_LOG" | grep -qF -- "application/vnd.github.raw" +} + +@test "gather-context without planning artifacts keeps the prior context unchanged (null)" { + _gather_mock + ART_MODE=empty 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 ] + [ "$(jq -r '.planning_artifacts' "$CTX")" = "null" ] + # Same-repo default: the Discussion is read from REPO itself. + [ "$(jq -r '.discussion_repo' "$CTX")" = "acme/widgets" ] + grep -qF -- "name=widgets" "$GH_LOG" + [ "$(jq -c 'keys' "$CTX")" = '["discussion","discussion_repo","open_epics","planning_artifacts","reconcile","referenced_issues","repo"]' ] + [ "$(jq -r '.discussion.title' "$CTX")" = "WorldChest" ] +} + +@test "gather-context treats a 404 on the planning-artifacts folder as no artifacts" { + _gather_mock + ART_MODE=404 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 ] + [[ "$output" == *"planning from the Discussion only"* ]] + [ "$(jq -r '.planning_artifacts' "$CTX")" = "null" ] + [ "$(jq -r '.discussion.title' "$CTX")" = "WorldChest" ] +} + +@test "gather-context fails loud on a non-404 error listing planning artifacts" { + _gather_mock + ART_MODE=500 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" -ne 0 ] + [[ "$output" == *"failed to list _bmad-output/planning-artifacts in acme/widgets"* ]] +} + +@test "gather-context rejects a malformed DISCUSSION_REPO before any API call" { + _gather_mock + GITHUB_ENV="$TMP/gh.env" REPO="acme/widgets" DISCUSSION_REPO="not a repo" \ + DISCUSSION_NUMBER=413 CONTEXT_PATH="$CTX" GH_TOKEN=x \ + run bash "$PLANNER_DIR/gather-context.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"DISCUSSION_REPO must be owner/repo"* ]] + [ ! -s "$GH_LOG" ] +} + +@test "initiative-planner.yml exposes discussion_repo and threads it to DISCUSSION_REPO + apply-plan" { + PLANNER_YML="$ROOT/.github/workflows/initiative-planner.yml" + [ "$(yq '.on.workflow_dispatch.inputs.discussion_repo.required' "$PLANNER_YML")" = "false" ] + [ "$(yq '.on.workflow_dispatch.inputs.discussion_repo.default' "$PLANNER_YML")" = "" ] + [ "$(yq '.jobs.plan.env.DISCUSSION_REPO' "$PLANNER_YML")" = "\${{ github.event.inputs.discussion_repo || github.event.inputs.target_repo || github.repository }}" ] + yq '.jobs.plan.steps[] | select(.id == "bob") | .with.prompt' "$PLANNER_YML" | grep -qF 'DISCUSSION_REPO="$DISCUSSION_REPO"' + # A cross-repo Discussion must not fall back to the self-scoped GITHUB_TOKEN. + yq '.jobs.plan.env.TARGET_GH_TOKEN' "$PLANNER_YML" | grep -qF "github.event.inputs.discussion_repo == github.repository" +} + # ── 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 22850123c..bf4b832cc 100644 --- a/tests/test_initiative_planner_redispatch.bats +++ b/tests/test_initiative_planner_redispatch.bats @@ -158,3 +158,23 @@ teardown() { [ "$status" -eq 0 ] ! grep -qF -- "--ref" "$GH_LOG" } + +@test "omits discussion_repo by default (same-repo dispatch unchanged)" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + run grep -F -- "discussion_repo" "$GH_LOG" + [ "$status" -ne 0 ] +} + +@test "forwards DISCUSSION_REPO as discussion_repo (graduation path)" { + DISCUSSION_REPO="combsmith/incubator" TARGET_REPO="combsmith/worldchest" run bash "$SCRIPT" + [ "$status" -eq 0 ] + grep -qF -- "-f discussion_repo=combsmith/incubator" "$GH_LOG" + grep -qF -- "-f target_repo=combsmith/worldchest" "$GH_LOG" +} + +@test "rejects a malformed DISCUSSION_REPO without dispatching" { + DISCUSSION_REPO="bad repo" run bash "$SCRIPT" + [ "$status" -ne 0 ] + [ ! -s "$GH_LOG" ] +}