Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 41 additions & 6 deletions .github/workflows/initiative-planner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,44 @@ concurrency:
cancel-in-progress: false

jobs:
# ── discussion [labeled] bridge ───────────────────────────────────────────────
# claude-code-action aborts on `discussion` event contexts ("Unsupported event
# type: discussion"), so we never plan inline on a discussion event. When a
# maintainer adds `idea:approved`, this job resolves the discussion number and
# re-invokes THIS workflow via workflow_dispatch — under which the action runs.
# The label stays the human gate; the re-dispatch is an invisible bridge.
redispatch:
if: github.event_name == 'discussion' && github.event.label.name == 'idea:approved'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read # actions/checkout; the workflow_dispatch is fired with the PAT
steps:
- name: Checkout agent repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false

- name: Guard — PAT present
# PAT required: a workflow_dispatch fired with GITHUB_TOKEN is accepted but
# NEVER starts a run (loop prevention) — the planner would silently no-op.
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
run: |
if [ -z "${GH_TOKEN}" ]; then
echo "::error::GH_PAT_WORKFLOWS is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run."
exit 1
fi

- name: Re-dispatch under workflow_dispatch
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }}
REPO: ${{ github.repository }}
DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
run: bash scripts/initiative-planner/redispatch.sh

plan:
# Skip discussion-label events unless the label added is exactly idea:approved.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'discussion' && github.event.label.name == 'idea:approved')
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
Expand All @@ -61,9 +94,11 @@ jobs:
discussions: write # post the plan summary back to the idea
id-token: write # claude-code-action OIDC
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 }}
DISCUSSION_NUMBER: ${{ github.event.inputs.discussion || github.event.discussion.number }}
DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == true) && '1' || '0' }}
DISCUSSION_NUMBER: ${{ github.event.inputs.discussion }}
DRY_RUN: ${{ inputs.dry_run == true && '1' || '0' }}
TOOLING_DIR: ${{ github.workspace }}/scripts/initiative-planner
steps:
- name: Checkout agent repo
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jobs:
tests/test_cut_release.bats \
tests/test_gitleaks_config.bats \
tests/test_initiative_planner.bats \
tests/test_initiative_planner_redispatch.bats \
tests/test_idea_triage.bats

validate-agent-profiles:
Expand Down
3 changes: 2 additions & 1 deletion scripts/initiative-planner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ 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. |
| `gather-context.sh` | Fetch the approved Discussion + repo context → `$CONTEXT_PATH`; export `DISCUSSION_NODE_ID`. |
| `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). |
| `apply-plan.sh` | Materialize a validated plan. Creates issues labelled `initiative` only — **never** `initiative:auto`. |

Tests: `tests/test_initiative_planner.bats` (run via `lint.yml`).
Tests: `tests/test_initiative_planner.bats`, `tests/test_initiative_planner_redispatch.bats` (run via `lint.yml`).

## Local dry run

Expand Down
58 changes: 58 additions & 0 deletions scripts/initiative-planner/redispatch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# redispatch.sh — bridge a `discussion [labeled]` event to a supported event.
#
# The initiative planner's documented primary trigger is a maintainer adding
# `idea:approved` to an Ideas Discussion, which fires `on: discussion [labeled]`.
# But the planner's "Plan the initiative" step uses claude-code-action, which
# only recognizes issues / pull_request / issue_comment / workflow_dispatch event
# contexts and aborts on a `discussion` event:
#
# Action failed with error: Unsupported event type: discussion
#
# So on the discussion event we DON'T plan inline; we resolve the discussion
# number and re-invoke the SAME workflow via workflow_dispatch, under which the
# action runs cleanly. The `idea:approved` label stays the human gate; the
# re-dispatch is an invisible bridge.
#
# IMPORTANT — the re-dispatch requires a PAT, not GITHUB_TOKEN:
# GitHub does not start new workflow runs from events created with the default
# GITHUB_TOKEN (loop prevention). A workflow_dispatch fired with GITHUB_TOKEN
# would be accepted but would NEVER start a run, so the caller MUST provide a
# PAT (GH_PAT_WORKFLOWS) as GH_TOKEN. This is the same constraint documented in
# scripts/initiative-driver.sh for the dev-lead label trigger.
#
# Env:
# REPO owner/repo (required)
# 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}"
DISCUSSION_NUMBER="${DISCUSSION_NUMBER:?DISCUSSION_NUMBER required}"
WORKFLOW_FILE="${WORKFLOW_FILE:-initiative-planner.yml}"

# Validate before the value reaches the gh command line.
[[ "$DISCUSSION_NUMBER" =~ ^[0-9]+$ ]] || {
echo "::error::DISCUSSION_NUMBER must be a positive integer, got: '$DISCUSSION_NUMBER'" >&2
exit 1
}

# Map DRY_RUN to a boolean string for the workflow input
if [[ "${DRY_RUN:-}" == "1" || "${DRY_RUN:-}" == "true" ]]; then
DISPATCH_DRY_RUN="true"
else
DISPATCH_DRY_RUN="false"
fi

# Use GITHUB_REF if it is a branch or tag to support testing on feature branches
REF_FLAGS=()
if [[ -n "${GITHUB_REF:-}" && ( "$GITHUB_REF" =~ ^refs/heads/ || "$GITHUB_REF" =~ ^refs/tags/ ) ]]; then
REF_FLAGS=(--ref "$GITHUB_REF")
fi

echo "Discussion event → re-dispatching ${WORKFLOW_FILE} for discussion #${DISCUSSION_NUMBER} via workflow_dispatch."
gh workflow run "$WORKFLOW_FILE" --repo "$REPO" "${REF_FLAGS[@]}" \
-f discussion="$DISCUSSION_NUMBER" \
-f dry_run="$DISPATCH_DRY_RUN"
echo "Dispatched ${WORKFLOW_FILE} (discussion=${DISCUSSION_NUMBER}, dry_run=${DISPATCH_DRY_RUN})."
106 changes: 106 additions & 0 deletions tests/test_initiative_planner_redispatch.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bats
# Unit tests for scripts/initiative-planner/redispatch.sh
#
# The planner's primary trigger is a `discussion [labeled]` event, but
# claude-code-action aborts on `discussion` event contexts. redispatch.sh bridges
# that gap by re-invoking the workflow via workflow_dispatch. These tests mock the
# gh CLI to avoid network calls and assert on the dispatched command.
#
# Run with: bats tests/test_initiative_planner_redispatch.bats

SCRIPT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/scripts/initiative-planner/redispatch.sh"

setup() {
MOCK_BIN="$(mktemp -d)"
GH_LOG="$(mktemp)"
export GH_LOG
export PATH="$MOCK_BIN:$PATH"
export REPO="petry-projects/.github-private"
export DISCUSSION_NUMBER="572"
export GH_TOKEN="pat"

cat > "$MOCK_BIN/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >> "$GH_LOG"
exit 0
EOF
chmod +x "$MOCK_BIN/gh"
}

teardown() {
rm -rf "${MOCK_BIN:-}"
rm -f "${GH_LOG:-}"
}

@test "re-dispatches the planner workflow via workflow_dispatch" {
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF "workflow run initiative-planner.yml" "$GH_LOG"
}

@test "threads the discussion number and forces dry_run=false" {
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF -- "-f discussion=572" "$GH_LOG"
grep -qF -- "-f dry_run=false" "$GH_LOG"
}

@test "targets the correct repo" {
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF -- "--repo petry-projects/.github-private" "$GH_LOG"
}

@test "fails when DISCUSSION_NUMBER is unset" {
unset DISCUSSION_NUMBER
run bash "$SCRIPT"
[ "$status" -ne 0 ]
[ ! -s "$GH_LOG" ]
}

@test "rejects a non-numeric DISCUSSION_NUMBER without dispatching" {
export DISCUSSION_NUMBER="572; rm -rf /"
run bash "$SCRIPT"
[ "$status" -ne 0 ]
[[ "$output" == *"positive integer"* ]]
[ ! -s "$GH_LOG" ]
}

@test "fails when REPO is unset" {
unset REPO
run bash "$SCRIPT"
[ "$status" -ne 0 ]
}

@test "honors a WORKFLOW_FILE override" {
export WORKFLOW_FILE="other-planner.yml"
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF "workflow run other-planner.yml" "$GH_LOG"
}
Comment thread
don-petry marked this conversation as resolved.

@test "honors DRY_RUN=1 or DRY_RUN=true" {
export DRY_RUN="1"
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF -- "-f dry_run=true" "$GH_LOG"

export DRY_RUN="true"
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF -- "-f dry_run=true" "$GH_LOG"
}

@test "passes GITHUB_REF if it is a branch or tag" {
export GITHUB_REF="refs/heads/feature-branch"
run bash "$SCRIPT"
[ "$status" -eq 0 ]
grep -qF -- "--ref refs/heads/feature-branch" "$GH_LOG"
}

@test "ignores GITHUB_REF if it is a pull request ref" {
export GITHUB_REF="refs/pull/123/merge"
run bash "$SCRIPT"
[ "$status" -eq 0 ]
! grep -qF -- "--ref" "$GH_LOG"
}
Loading