diff --git a/banzai-codes/README.md b/banzai-codes/README.md new file mode 100644 index 0000000..77ae80d --- /dev/null +++ b/banzai-codes/README.md @@ -0,0 +1,129 @@ +# Banzai pipeline actions + +Three composite actions that connect the [Banzai Codes](https://github.com/framna-dk/banzai-codes) refinement flow to the +[harness](../harness) execution loop and back: + +| Action | Trigger | What it does | +|--------|---------|--------------| +| [handoff-work-orders](handoff-work-orders) | Scheduled scan | Work orders that reach **In Progress** on the Product board are added to the harness (Development) board at **Todo**, where the orchestrator picks them up. No LLM involved. | +| [post-candidate-summary](post-candidate-summary) | Issue closed | When a work-order issue is closed as completed, a Product-Manager-facing summary (generated by Codex from the issue conversation and the pull request, with PR images as proof of work) is posted on the issue. No boards are touched. | +| [generate-prd](generate-prd) | Manual dispatch | Generates or incrementally updates a Product Requirements Document — a folder of markdown files describing how the app currently works — and opens a PR. Used to ground Banzai Codes' define flow. | + +## One issue, two boards + +A work order is a single GitHub issue that sits on two Projects v2 boards at once, +matching the banzai-codes contracts: + +- the **Product board** tracks the user-facing lifecycle: `Define → In Progress → + Acceptance → Done`; +- the **Development (harness) board** tracks the build pipeline: `Ready / Todo / + In Progress / Human Review / Merging / Rework / Done`. + +No issues are duplicated anywhere; the actions only move board memberships, statuses +and comments on the one issue. + +``` +Banzai Codes (define flow) grounded by docs/prd (generate-prd) + │ publish → Product board "In Progress" + ▼ +handoff-work-orders ──▶ issue added to harness board at "Todo" + │ + ▼ +banzai-codes-worker dispatches the coding agent, which opens a PR +and moves the harness status to "Human Review" + │ + ▼ +issue is closed (completed) + │ + ▼ +post-candidate-summary ──▶ candidate summary comment on the issue +``` + +`handoff-work-orders` reads Projects v2 boards, and GitHub Actions cannot trigger on +Projects v2 status changes, so it is an **idempotent one-shot scan**: each invocation +looks at the board, does whatever is missing, and exits. Wire it to a `schedule:` cron +(plus `workflow_dispatch:` for manual runs) in the app repository: + +```yml +name: Banzai handoff +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +concurrency: + group: banzai-handoff-${{ github.repository }} + cancel-in-progress: false + +jobs: + handoff: + runs-on: framna-dk-macos-default + steps: + - name: Mint App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.PROJECTS_APP_ID }} + private-key: ${{ secrets.PROJECTS_APP_PEM }} + owner: framna-dk + repositories: my-app + - uses: framna-dk/actions/banzai-codes/handoff-work-orders@main + with: + github-token: ${{ steps.app-token.outputs.token }} + work-order-project-owner: framna-dk + work-order-project-number: 42 + harness-project-owner: framna-dk + harness-project-number: 23 +``` + +`post-candidate-summary` is not board-driven — it triggers on `issues: closed` and +summarizes the issue that was just closed, so it runs in its own workflow: + +```yml +name: Banzai candidate summary +on: + issues: + types: [closed] + +jobs: + candidate-summary: + runs-on: framna-dk-macos-default + if: github.event.issue.state_reason == 'completed' + steps: + - name: Mint App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.PROJECTS_APP_ID }} + private-key: ${{ secrets.PROJECTS_APP_PEM }} + owner: framna-dk + repositories: my-app + - uses: framna-dk/actions/banzai-codes/post-candidate-summary@main + with: + github-token: ${{ steps.app-token.outputs.token }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + issue-number: ${{ github.event.issue.number }} +``` + +## Tokens + +`handoff-work-orders` cannot use the default `github.token` — it has no organization +Projects access. Mint a GitHub App installation token +(`actions/create-github-app-token@v3`) whose installation grants: + +- **Organization → Projects: Read & write** (the handoff scans the Product board and + writes the harness board) +- **Repository → Issues: Read & write** on the repository holding the work-order issues + (`post-candidate-summary` comments on them) +- For `generate-prd` only (when not using `github.token`): **Contents: Read & write** and + **Pull requests: Read & write** + +`post-candidate-summary` and `generate-prd` additionally need an `OPENAI_API_KEY` secret for Codex. + +## Status name contract + +Defaults follow the banzai-codes contracts and the banzai-codes-worker: the worker treats +`Todo`, `In Progress` and `Rework` as active states, the coding agent parks finished work +in `Human Review`, and Banzai Codes opens its review gate when the Product board reaches +`Acceptance`. All status names are inputs, so boards with different vocabularies can +override them. diff --git a/banzai-codes/generate-prd/README.md b/banzai-codes/generate-prd/README.md new file mode 100644 index 0000000..b08eed6 --- /dev/null +++ b/banzai-codes/generate-prd/README.md @@ -0,0 +1,81 @@ +## [Banzai generate PRD](action.yml) + +Generates — or incrementally updates — a Product Requirements Document for the +checked-out application and opens a pull request with the result. The PRD is a folder of +markdown files (default `docs/prd/`): an `index.md` product overview plus one file per +feature area, written for LLM readability. Banzai Codes uses it to ground the define +flow, so new feature requests are refined against how the application actually works +today. + +Codex explores the codebase and writes the documentation; the action then verifies that +nothing outside the PRD folder was touched, commits to a fixed branch (force-pushed, so +re-runs update rather than stack), and creates or updates the pull request. + +When the PRD folder already exists, the prompt switches to incremental mode: existing +files are read first, structure and file names are preserved, and only statements the +codebase contradicts (or gaps it reveals) are changed. The PRD deliberately contains no +file paths or code snippets — behavior, not implementation — so it stays valid across +refactors. + +### Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `github-token` | Token with Contents read/write and Pull requests read/write on the repository. | Yes | — | +| `openai-api-key` | OpenAI API key used by Codex. | Yes | — | +| `output-directory` | PRD folder, relative to the repository root. | No | `docs/prd` | +| `base-branch` | Base branch for the pull request. | No | currently checked-out branch | +| `branch-name` | Working branch the PRD is pushed to (fixed name = idempotent re-runs). | No | `banzai/prd-update` | +| `codex-model` | Model used by Codex. | No | `gpt-5.4` | +| `codex-effort` | Reasoning effort used by Codex. | No | — | +| `codex-sandbox` | Sandbox mode for Codex. | No | `workspace-write` | +| `codex-safety-strategy` | Safety strategy passed to codex-action. | No | `unsafe` | +| `extra-instructions` | Additional instructions appended to the PRD prompt. | No | — | +| `pr-title` | Title for the PRD pull request. | No | `Update product requirements documentation` | + +### Outputs + +| Name | Description | +|------|-------------| +| `changed` | Whether the PRD changed in this run. | +| `pr-url` | URL of the created or updated pull request (empty when unchanged). | +| `pr-number` | Number of the created or updated pull request (empty when unchanged). | + +### Usage + +```yml +name: Generate PRD +on: + workflow_dispatch: + inputs: + extra-instructions: + description: Optional focus areas for this PRD pass. + required: false + +jobs: + prd: + runs-on: framna-dk-macos-default + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: framna-dk/actions/banzai-codes/generate-prd@main + with: + github-token: ${{ github.token }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + extra-instructions: ${{ inputs.extra-instructions }} +``` + +### Notes + +- The repository **must be checked out** before this action runs; it fails fast otherwise. +- The branch push uses the credentials persisted by `actions/checkout`; the + `github-token` input is only used for the pull-request API calls. If your organization + blocks `github.token` from creating pull requests, mint a GitHub App token with + Contents + Pull requests write and pass it to both `actions/checkout` and this action. +- If Codex modifies anything outside `output-directory`, the run fails before committing + (scope guard), so a bad generation can never reach the repository. diff --git a/banzai-codes/generate-prd/action.yml b/banzai-codes/generate-prd/action.yml new file mode 100644 index 0000000..93a4cd2 --- /dev/null +++ b/banzai-codes/generate-prd/action.yml @@ -0,0 +1,217 @@ +name: Banzai generate PRD +description: Generate or incrementally update a Product Requirements Document for the checked-out app and open a pull request. + +inputs: + github-token: + description: Token with Contents read/write and Pull requests read/write on the repository. + required: true + openai-api-key: + description: OpenAI API key used by Codex to explore the codebase and write the PRD. + required: true + output-directory: + description: PRD folder, relative to the repository root. + required: false + default: docs/prd + base-branch: + description: Base branch for the pull request. Defaults to the currently checked-out branch. + required: false + default: "" + branch-name: + description: Working branch the PRD is pushed to. The fixed name makes re-runs update the same pull request. + required: false + default: banzai/prd-update + codex-model: + description: Model used by Codex. + required: false + default: gpt-5.4 + codex-effort: + description: Reasoning effort used by Codex. + required: false + default: "" + codex-sandbox: + description: Sandbox mode for Codex. + required: false + default: danger-full-access + codex-safety-strategy: + description: Safety strategy passed to codex-action. + required: false + default: unsafe + extra-instructions: + description: Additional instructions appended to the PRD prompt (product context, areas to emphasize). + required: false + default: "" + pr-title: + description: Title for the PRD pull request. + required: false + default: Update product requirements documentation + +outputs: + changed: + description: Whether the PRD changed in this run. + value: ${{ steps.push.outputs.changed }} + pr-url: + description: URL of the created or updated pull request (empty when unchanged). + value: ${{ steps.pr.outputs.pr_url }} + pr-number: + description: Number of the created or updated pull request (empty when unchanged). + value: ${{ steps.pr.outputs.pr_number }} + +runs: + using: composite + steps: + - name: Preflight + id: preflight + shell: bash + env: + BASE_BRANCH: ${{ inputs.base-branch }} + BRANCH_NAME: ${{ inputs.branch-name }} + OUTPUT_DIRECTORY: ${{ inputs.output-directory }} + run: | + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "::error::generate-prd must run inside a checked-out repository. Add an actions/checkout step before this action." + exit 1 + fi + OUTPUT_DIR="${OUTPUT_DIRECTORY%/}" + case "$OUTPUT_DIR" in + ""|/*|*..*) + echo "::error::output-directory must be a relative path inside the repository, got \"$OUTPUT_DIRECTORY\"." + exit 1 + ;; + esac + BASE="$BASE_BRANCH" + if [ -z "$BASE" ]; then + if ! BASE="$(git symbolic-ref --short HEAD 2>/dev/null)"; then + echo "::error::HEAD is detached and no base-branch input was provided." + exit 1 + fi + fi + if [ "$BASE" = "$BRANCH_NAME" ]; then + echo "::error::branch-name ($BRANCH_NAME) must differ from the base branch ($BASE)." + exit 1 + fi + echo "base_branch=$BASE" >> "$GITHUB_OUTPUT" + echo "output_dir=$OUTPUT_DIR" >> "$GITHUB_OUTPUT" + + - name: Build PRD prompt + id: prompt + uses: actions/github-script@v8 + env: + ACTION_PATH: ${{ github.action_path }} + OUTPUT_DIRECTORY: ${{ steps.preflight.outputs.output_dir }} + EXTRA_INSTRUCTIONS: ${{ inputs.extra-instructions }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const template = fs.readFileSync(path.join(process.env.ACTION_PATH, 'prompt.md'), 'utf8'); + let prompt = template.replaceAll('{{OUTPUT_DIRECTORY}}', process.env.OUTPUT_DIRECTORY); + const extra = (process.env.EXTRA_INSTRUCTIONS || '').trim(); + if (extra) { + prompt += `\n\n## Additional instructions\n\n${extra}`; + } + core.setOutput('prompt', prompt); + + - name: Generate PRD + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ inputs.openai-api-key }} + model: ${{ inputs.codex-model }} + effort: ${{ inputs.codex-effort }} + sandbox: ${{ inputs.codex-sandbox }} + safety-strategy: ${{ inputs.codex-safety-strategy }} + allow-bot-users: "banzai-codes[bot]" + prompt: ${{ steps.prompt.outputs.prompt }} + + - name: Guard change scope + shell: bash + env: + OUTPUT_DIR: ${{ steps.preflight.outputs.output_dir }} + run: | + violations="$(git status --porcelain=v1 --untracked-files=all \ + | sed -E 's/^.{3}//' \ + | sed -E 's/^.* -> //' \ + | sed -E 's/^"(.*)"$/\1/' \ + | grep -v "^${OUTPUT_DIR}/" || true)" + if [ -n "$violations" ]; then + echo "::error::Codex modified files outside ${OUTPUT_DIR}/:" + echo "$violations" + exit 1 + fi + + - name: Commit and push PRD branch + id: push + shell: bash + env: + OUTPUT_DIR: ${{ steps.preflight.outputs.output_dir }} + BRANCH_NAME: ${{ inputs.branch-name }} + run: | + if [ -z "$(git status --porcelain --untracked-files=all -- "$OUTPUT_DIR")" ]; then + echo "PRD is already up to date; nothing to push." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH_NAME" + git add -- "$OUTPUT_DIR" + git commit -m "docs: update PRD" + git push --force origin "HEAD:refs/heads/$BRANCH_NAME" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Open or update pull request + id: pr + if: steps.push.outputs.changed == 'true' + uses: actions/github-script@v8 + env: + BRANCH_NAME: ${{ inputs.branch-name }} + BASE_BRANCH: ${{ steps.preflight.outputs.base_branch }} + OUTPUT_DIR: ${{ steps.preflight.outputs.output_dir }} + PR_TITLE: ${{ inputs.pr-title }} + with: + github-token: ${{ inputs.github-token }} + script: | + const branch = process.env.BRANCH_NAME; + const body = [ + `Regenerated Product Requirements Documentation in \`${process.env.OUTPUT_DIR}/\`.`, + '', + 'The PRD describes how the application currently works. It grounds the Banzai Codes', + 'define flow, so feature refinement starts from the actual behavior of the product.', + '', + 'Review the changed files for statements that misrepresent the product before merging.', + 'This branch is force-pushed on every run, so the PR always reflects the latest generation.', + ].join('\n'); + + const { data: existing } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${context.repo.owner}:${branch}`, + }); + + let pullRequest; + if (existing.length > 0) { + pullRequest = existing[0]; + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullRequest.number, + title: process.env.PR_TITLE, + body, + }); + core.info(`Updated existing PR ${pullRequest.html_url}`); + } else { + const { data: created } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: process.env.PR_TITLE, + head: branch, + base: process.env.BASE_BRANCH, + body, + }); + pullRequest = created; + core.info(`Opened PR ${pullRequest.html_url}`); + } + + core.setOutput('pr_url', pullRequest.html_url); + core.setOutput('pr_number', String(pullRequest.number)); diff --git a/banzai-codes/generate-prd/prompt.md b/banzai-codes/generate-prd/prompt.md new file mode 100644 index 0000000..89cebe7 --- /dev/null +++ b/banzai-codes/generate-prd/prompt.md @@ -0,0 +1,81 @@ +You are documenting an existing application as a Product Requirements Document (PRD). The +repository is checked out in your working directory. The PRD describes how the product +**currently works** — it is consumed by an LLM during product refinement sessions, so new +feature requests can be grounded in the application's existing behavior. + +## Your task + +Explore the codebase thoroughly before writing anything: entry points, navigation/routes, +domain models, user-facing flows, integrations, and configuration. Then create or update +the PRD under `{{OUTPUT_DIRECTORY}}/`: + +- `{{OUTPUT_DIRECTORY}}/index.md` — the product overview: what the product is, who it is + for, and a table of every feature area with a one-line description and a relative link + to its file. +- `{{OUTPUT_DIRECTORY}}/.md` — one file per feature area, named in + kebab-case (for example `account-overview.md`, `onboarding.md`). + +## Per-file template + +Each feature-area file uses exactly these sections: + +``` +# + +## Problem Statement +The problem this feature solves, from the user's perspective. + +## Solution +How the product solves it today, from the user's perspective. + +## User Stories +A LONG, numbered list covering all aspects of the feature, each in the format: +1. As an , I want , so that +Example: As a mobile bank customer, I want to see the balance on my accounts, so that I +can make better informed decisions about my spending. + +## Current Behavior +What the feature does today: states, rules, defaults, validation, error handling, +permissions — described as observable behavior. + +## Key Flows +Step-by-step descriptions of the main user journeys through the feature. + +## Out of Scope +Adjacent concerns this feature deliberately does not handle. + +## Further Notes +Anything else worth knowing (known limitations, behavioral quirks, dependencies on +other feature areas). +``` + +## Writing rules + +- Do NOT include file paths or code snippets — they go stale quickly. Describe behavior + and contracts, not implementation. +- Each file must be self-contained: restate any needed context, define product + terminology on first use, and never write "see above". Cross-reference other feature + areas by their PRD file name only. +- Use the exact section headings from the template so files stay diffable across updates. +- Aim for 100–300 lines per file. Split a feature area in two rather than exceeding that. +- Describe what IS, not what should be. If behavior looks unfinished or inconsistent, + record it factually under Further Notes. + +## Incremental updates + +If `{{OUTPUT_DIRECTORY}}/` already exists: + +- Read every existing file first. +- Preserve existing file names and structure. Update only statements the codebase + contradicts, and fill gaps the codebase reveals. +- Add new files for feature areas that are not yet documented. Delete a file only when + its feature no longer exists in the product. +- Always regenerate `index.md` so it exactly matches the set of feature-area files. + +## Boundaries + +- Modify files ONLY inside `{{OUTPUT_DIRECTORY}}/`. Do not touch any other path, and do + not run formatters, builds, or tests. +- Do not commit, branch, or push — the workflow handles git. + +When you are done, reply with a one-paragraph summary of what you created or changed. diff --git a/banzai-codes/handoff-work-orders/README.md b/banzai-codes/handoff-work-orders/README.md new file mode 100644 index 0000000..30effda --- /dev/null +++ b/banzai-codes/handoff-work-orders/README.md @@ -0,0 +1,74 @@ +## [Banzai handoff work orders](action.yml) + +Scans the work-order (Product) GitHub Projects v2 board and adds every open issue sitting +in the handoff status (default `In Progress`) to the harness (Development) board with an +initial status (default `Todo`), where +[banzai-codes-worker](https://github.com/framna-dk/banzai-codes-worker) picks it up. + +One issue, two boards: the work order itself is the unit of work throughout the pipeline. +The Product board tracks the user-facing lifecycle (`Define → In Progress → Acceptance → +Done`) while the Development board tracks the build pipeline — both as Status fields on +the same issue. No duplicate issue is created. + +The scan is **idempotent and self-healing**: harness-board membership is the marker, so +re-runs add nothing twice; an issue whose harness status was never set gets it repaired. +An existing harness status is never overwritten — once the orchestrator owns the issue, +this action keeps its hands off. + +GitHub Actions cannot trigger on Projects v2 status changes, so run this on a cron +schedule (see [the folder README](../README.md) for the full pipeline workflow). + +### Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `github-token` | App installation token with org Projects read/write and Issues read on the work-order repository. | Yes | — | +| `work-order-project-owner` | Organization login that owns the work-order (Product) project. | Yes | — | +| `work-order-project-number` | Work-order project number from the project URL. | Yes | — | +| `work-order-status` | Status option on the work-order board that triggers the handoff. | No | `In Progress` | +| `harness-project-owner` | Organization login that owns the harness (Development) project. | Yes | — | +| `harness-project-number` | Harness project number from the project URL. | Yes | — | +| `harness-status` | Initial status option set when an issue is added to the harness board. | No | `Todo` | +| `status-field-name` | Name of the single-select status field on both boards. | No | `Status` | +| `repository` | Optional repository (`owner/repo`) filter; when set, only issues in this repository are handed off. | No | — | +| `max-items` | Maximum number of work orders acted on per scan. | No | `10` | +| `dry-run` | Log intended changes without performing any mutation. | No | `false` | + +### Outputs + +| Name | Description | +|------|-------------| +| `added-issue-urls` | Newline-separated URLs of issues added to the harness board by this run. | +| `added-count` | Number of issues added to the harness board by this run. | +| `repaired-count` | Number of issues healed (given their missing initial status on the harness board). | +| `errors` | Newline-separated per-work-order error summaries (empty on a clean run). | + +### Usage + +```yml +- name: Mint App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.PROJECTS_APP_ID }} + private-key: ${{ secrets.PROJECTS_APP_PEM }} + owner: framna-dk + repositories: my-app + +- uses: framna-dk/actions/banzai-codes/handoff-work-orders@main + with: + github-token: ${{ steps.app-token.outputs.token }} + work-order-project-owner: framna-dk + work-order-project-number: 42 + harness-project-owner: framna-dk + harness-project-number: 23 +``` + +### Notes + +- Only organization-owned projects are supported (matching the rest of the Banzai tooling). +- A failed work order is reported and skipped; the rest of the scan continues. The run + still fails at the end so the error is visible in the Actions UI. +- Defaults match the banzai-codes contracts: the Product board's `In Progress` means + "published; the build pipeline owns it", and `Todo` is a banzai-codes-worker active + state, so the coding agent is dispatched on the worker's next tick. diff --git a/banzai-codes/handoff-work-orders/action.yml b/banzai-codes/handoff-work-orders/action.yml new file mode 100644 index 0000000..bfad140 --- /dev/null +++ b/banzai-codes/handoff-work-orders/action.yml @@ -0,0 +1,295 @@ +name: Banzai handoff work orders +description: Add work orders that reached the handoff status on the Product board to the harness Projects v2 board. + +inputs: + github-token: + description: GitHub App installation token with org Projects read/write and Issues read on the work-order repository. + required: true + work-order-project-owner: + description: Organization login that owns the work-order (Product) project. + required: true + work-order-project-number: + description: Work-order project number from the project URL. + required: true + work-order-status: + description: Status option on the work-order board that triggers the handoff. + required: false + default: In Progress + harness-project-owner: + description: Organization login that owns the harness (Development) project. + required: true + harness-project-number: + description: Harness project number from the project URL. + required: true + harness-status: + description: Initial status option set when an issue is added to the harness board. + required: false + default: Todo + status-field-name: + description: Name of the single-select status field on both boards. + required: false + default: Status + repository: + description: Optional repository (owner/repo) filter; when set, only issues in this repository are handed off. + required: false + default: "" + max-items: + description: Maximum number of work orders acted on per scan. + required: false + default: "10" + dry-run: + description: Log intended changes without performing any mutation. + required: false + default: "false" + +outputs: + added-issue-urls: + description: Newline-separated URLs of issues added to the harness board by this run. + value: ${{ steps.handoff.outputs.added_issue_urls }} + added-count: + description: Number of issues added to the harness board by this run. + value: ${{ steps.handoff.outputs.added_count }} + repaired-count: + description: Number of issues healed (given their missing initial status on the harness board). + value: ${{ steps.handoff.outputs.repaired_count }} + errors: + description: Newline-separated per-work-order error summaries (empty on a clean run). + value: ${{ steps.handoff.outputs.errors }} + +runs: + using: composite + steps: + - name: Hand off work orders + id: handoff + uses: actions/github-script@v8 + env: + WORK_ORDER_PROJECT_OWNER: ${{ inputs.work-order-project-owner }} + WORK_ORDER_PROJECT_NUMBER: ${{ inputs.work-order-project-number }} + WORK_ORDER_STATUS: ${{ inputs.work-order-status }} + HARNESS_PROJECT_OWNER: ${{ inputs.harness-project-owner }} + HARNESS_PROJECT_NUMBER: ${{ inputs.harness-project-number }} + HARNESS_STATUS: ${{ inputs.harness-status }} + STATUS_FIELD_NAME: ${{ inputs.status-field-name }} + REPOSITORY: ${{ inputs.repository }} + MAX_ITEMS: ${{ inputs.max-items }} + DRY_RUN: ${{ inputs.dry-run }} + with: + github-token: ${{ inputs.github-token }} + script: | + const workOrderProjectOwner = process.env.WORK_ORDER_PROJECT_OWNER; + const workOrderProjectNumber = Number(process.env.WORK_ORDER_PROJECT_NUMBER); + const workOrderStatus = process.env.WORK_ORDER_STATUS || 'In Progress'; + const harnessProjectOwner = process.env.HARNESS_PROJECT_OWNER; + const harnessProjectNumber = Number(process.env.HARNESS_PROJECT_NUMBER); + const harnessStatus = process.env.HARNESS_STATUS || 'Todo'; + const statusFieldName = process.env.STATUS_FIELD_NAME || 'Status'; + const repositoryFilter = (process.env.REPOSITORY || '').toLowerCase(); + const maxItems = Number(process.env.MAX_ITEMS || '10'); + const dryRun = (process.env.DRY_RUN || 'false').toLowerCase() === 'true'; + + if (!Number.isInteger(workOrderProjectNumber) || !Number.isInteger(harnessProjectNumber)) { + core.setFailed('work-order-project-number and harness-project-number must be integers.'); + return; + } + + const resolveProject = async (owner, number) => { + const data = await github.graphql( + ` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + } + `, + { owner, number } + ); + const project = data.organization && data.organization.projectV2; + if (!project) { + throw new Error(`Project ${number} not found for organization "${owner}" (user-owned projects are not supported).`); + } + return project; + }; + + const harnessProject = await resolveProject(harnessProjectOwner, harnessProjectNumber); + + const harnessStatusField = harnessProject.fields.nodes.find( + (field) => field && field.name === statusFieldName && Array.isArray(field.options) + ); + if (!harnessStatusField) { + core.setFailed(`Single-select field "${statusFieldName}" not found on harness project ${harnessProjectNumber}.`); + return; + } + const harnessStatusOption = harnessStatusField.options.find((option) => option.name === harnessStatus); + if (!harnessStatusOption) { + const available = harnessStatusField.options.map((option) => option.name).join(', '); + core.setFailed(`Status option "${harnessStatus}" not found on harness project ${harnessProjectNumber}. Available: ${available}.`); + return; + } + + const workOrders = []; + let after = null; + while (true) { + const page = await github.graphql( + ` + query($owner: String!, $number: Int!, $statusField: String!, $after: String) { + organization(login: $owner) { + projectV2(number: $number) { + items(first: 50, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + fieldValueByName(name: $statusField) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + content { + ... on Issue { + id + number + url + state + repository { nameWithOwner } + projectItems(first: 50, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: $statusField) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + } + } + } + `, + { + owner: workOrderProjectOwner, + number: workOrderProjectNumber, + statusField: statusFieldName, + after, + } + ); + const project = page.organization && page.organization.projectV2; + if (!project) { + core.setFailed(`Project ${workOrderProjectNumber} not found for organization "${workOrderProjectOwner}" (user-owned projects are not supported).`); + return; + } + for (const item of project.items.nodes) { + const status = item.fieldValueByName && item.fieldValueByName.name; + const issue = item.content; + if (status !== workOrderStatus || !issue || !issue.id || issue.state !== 'OPEN') continue; + if (repositoryFilter && issue.repository.nameWithOwner.toLowerCase() !== repositoryFilter) continue; + workOrders.push(issue); + } + if (!project.items.pageInfo.hasNextPage) break; + after = project.items.pageInfo.endCursor; + } + core.info(`Found ${workOrders.length} work order(s) in "${workOrderStatus}" on project ${workOrderProjectNumber}.`); + + const addToHarnessBoard = async (issueId) => { + const data = await github.graphql( + ` + mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + } + `, + { projectId: harnessProject.id, contentId: issueId } + ); + return data.addProjectV2ItemById.item.id; + }; + + const setHarnessStatus = async (itemId) => { + await github.graphql( + ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { id } + } + } + `, + { + projectId: harnessProject.id, + itemId, + fieldId: harnessStatusField.id, + optionId: harnessStatusOption.id, + } + ); + }; + + const addedIssueUrls = []; + const errors = []; + let repairedCount = 0; + let actedOn = 0; + + for (const workOrder of workOrders) { + if (actedOn >= maxItems) { + core.info(`Reached max-items (${maxItems}); remaining work orders are picked up by the next scan.`); + break; + } + try { + const harnessItem = workOrder.projectItems.nodes.find( + (node) => node.project && node.project.id === harnessProject.id + ); + + if (!harnessItem) { + if (dryRun) { + core.info(`[dry-run] Would add ${workOrder.url} to project ${harnessProjectNumber} with status "${harnessStatus}".`); + } else { + const itemId = await addToHarnessBoard(workOrder.id); + await setHarnessStatus(itemId); + core.info(`Added ${workOrder.url} to the harness board with status "${harnessStatus}".`); + addedIssueUrls.push(workOrder.url); + } + actedOn += 1; + continue; + } + + // Already on the harness board: only heal a missing status — never + // overwrite one the orchestrator may have moved since. + if (!(harnessItem.fieldValueByName && harnessItem.fieldValueByName.name)) { + if (dryRun) { + core.info(`[dry-run] Would set status "${harnessStatus}" on ${workOrder.url}.`); + } else { + await setHarnessStatus(harnessItem.id); + core.info(`Repaired ${workOrder.url}: set missing status "${harnessStatus}".`); + } + repairedCount += 1; + actedOn += 1; + } + } catch (error) { + const message = `${workOrder.url}: ${error.message}`; + core.warning(message); + errors.push(message); + } + } + + core.setOutput('added_issue_urls', addedIssueUrls.join('\n')); + core.setOutput('added_count', String(addedIssueUrls.length)); + core.setOutput('repaired_count', String(repairedCount)); + core.setOutput('errors', errors.join('\n')); + if (errors.length > 0) { + core.setFailed(`Handoff failed for ${errors.length} work order(s):\n${errors.join('\n')}`); + } diff --git a/banzai-codes/post-candidate-summary/README.md b/banzai-codes/post-candidate-summary/README.md new file mode 100644 index 0000000..df78f02 --- /dev/null +++ b/banzai-codes/post-candidate-summary/README.md @@ -0,0 +1,83 @@ +## [Banzai post candidate summary](action.yml) + +Summarizes a **closed issue** for a Product Manager and posts the summary as a comment on +the issue. Drive it from a workflow that triggers on `issues: closed`, passing the closed +issue's number. + +For the given issue it: + +1. Gathers the issue's requirements and conversation, the linked pull request (resolved + via the PR's closing reference, with a cross-reference fallback) and its conversation, + and every image they contain. +2. Asks Codex for a Product-Manager-facing, **non-technical** summary of how the task was + completed, embedding the images as proof of work. +3. Posts the summary as a comment on the issue. Stray HTML comments from the model are + stripped, and the idempotency marker (default ``) is + embedded **inside the summary comment**. + +The marker makes the action idempotent: if a summary comment is already present (for +example after a reopen→close cycle), the action skips and posts nothing. No repository +checkout is required, and the action reads and writes nothing outside the issue — no +project boards are touched. + +### Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `github-token` | App installation token with Issues read/write on the repository. | Yes | — | +| `openai-api-key` | OpenAI API key used by Codex to generate the summary. | Yes | — | +| `issue-number` | Number of the closed issue to summarize. | Yes | — | +| `repository` | Repository (`owner/repo`) that owns the issue. | No | `${{ github.repository }}` | +| `summary-marker` | Marker embedded in the summary comment, making the post idempotent across repeated closes. | No | `` | +| `codex-model` | Model used by Codex. | No | `gpt-5.4` | +| `codex-effort` | Reasoning effort used by Codex. | No | — | +| `codex-sandbox` | Sandbox mode for Codex (summary generation needs no write access). | No | `read-only` | +| `codex-safety-strategy` | Safety strategy passed to codex-action. | No | `unsafe` | +| `extra-instructions` | Additional instructions appended to the summary prompt. | No | — | + +### Outputs + +| Name | Description | +|------|-------------| +| `processed` | `true` if the issue was summarized by this run. | +| `issue-url` | URL of the issue handled by this run. | +| `summary-comment-url` | URL of the summary comment posted on the issue. | + +### Usage + +```yml +on: + issues: + types: [closed] + +jobs: + summary: + runs-on: ubuntu-latest + if: github.event.issue.state_reason == 'completed' + steps: + - name: Mint App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.PROJECTS_APP_ID }} + private-key: ${{ secrets.PROJECTS_APP_PEM }} + owner: framna-dk + repositories: my-app + + - uses: framna-dk/actions/banzai-codes/post-candidate-summary@main + with: + github-token: ${{ steps.app-token.outputs.token }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + issue-number: ${{ github.event.issue.number }} +``` + +### Notes and troubleshooting + +- The action runs once per close. Gate the workflow on + `github.event.issue.state_reason == 'completed'` so `not planned` closures (wontfix, + duplicate) don't get a summary. +- The build conversation happens on the same issue, so the gathered context includes the + refinement chat and agent comments — useful input for the summary. +- Images are embedded as their original GitHub attachment URLs. On private repositories + these render for anyone with repository access when viewed on GitHub; they may not + render if the markdown is proxied elsewhere. diff --git a/banzai-codes/post-candidate-summary/action.yml b/banzai-codes/post-candidate-summary/action.yml new file mode 100644 index 0000000..ea51e3e --- /dev/null +++ b/banzai-codes/post-candidate-summary/action.yml @@ -0,0 +1,302 @@ +name: Banzai post candidate summary +description: Generate a Product-Manager-facing summary for a closed issue and post it as a comment on the issue. + +inputs: + github-token: + description: GitHub App installation token with Issues read/write on the repository. + required: true + openai-api-key: + description: OpenAI API key used by Codex to generate the summary. + required: true + issue-number: + description: Number of the closed issue to summarize. + required: true + repository: + description: Repository (owner/repo) that owns the issue. + required: false + default: ${{ github.repository }} + summary-marker: + description: Marker embedded in the summary comment, making the post idempotent across repeated closes. + required: false + default: "" + codex-model: + description: Model used by Codex. + required: false + default: gpt-5.4 + codex-effort: + description: Reasoning effort used by Codex. + required: false + default: "" + codex-sandbox: + description: Sandbox mode for Codex (summary generation needs no write access). + required: false + default: read-only + codex-safety-strategy: + description: Safety strategy passed to codex-action. + required: false + default: unsafe + extra-instructions: + description: Additional instructions appended to the summary prompt. + required: false + default: "" + +outputs: + processed: + description: Whether the issue was summarized by this run. + value: ${{ steps.post.outputs.processed || 'false' }} + issue-url: + description: URL of the issue handled by this run. + value: ${{ steps.check.outputs.issue_url }} + summary-comment-url: + description: URL of the summary comment posted on the issue. + value: ${{ steps.post.outputs.summary_comment_url }} + +runs: + using: composite + steps: + - name: Check issue + id: check + uses: actions/github-script@v8 + env: + REPOSITORY: ${{ inputs.repository }} + ISSUE_NUMBER: ${{ inputs.issue-number }} + SUMMARY_MARKER: ${{ inputs.summary-marker }} + with: + github-token: ${{ inputs.github-token }} + script: | + const [owner, repo] = process.env.REPOSITORY.split('/'); + const issueNumber = Number(process.env.ISSUE_NUMBER); + const summaryMarker = process.env.SUMMARY_MARKER; + + if (!Number.isInteger(issueNumber)) { + core.setFailed('issue-number must be an integer.'); + return; + } + if (!summaryMarker) { + core.setFailed('summary-marker must not be empty.'); + return; + } + + const issue = (await github.rest.issues.get({ owner, repo, issue_number: issueNumber })).data; + + // Idempotency guard: a reopen→close cycle re-fires this action, so skip + // when a summary comment carrying the marker is already on the issue. + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + if (comments.some((comment) => (comment.body || '').includes(summaryMarker))) { + core.info(`Issue #${issueNumber} already has a summary comment; skipping.`); + core.setOutput('found', 'false'); + core.setOutput('issue_url', issue.html_url); + return; + } + + core.info(`Summarizing ${issue.html_url}.`); + core.setOutput('found', 'true'); + core.setOutput('issue_number', String(issueNumber)); + core.setOutput('issue_url', issue.html_url); + + - name: Gather context and build prompt + id: prepare + if: steps.check.outputs.found == 'true' + uses: actions/github-script@v8 + env: + ACTION_PATH: ${{ github.action_path }} + REPOSITORY: ${{ inputs.repository }} + ISSUE_NUMBER: ${{ steps.check.outputs.issue_number }} + ISSUE_URL: ${{ steps.check.outputs.issue_url }} + EXTRA_INSTRUCTIONS: ${{ inputs.extra-instructions }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const path = require('path'); + + const [owner, repo] = process.env.REPOSITORY.split('/'); + const issueNumber = Number(process.env.ISSUE_NUMBER); + + const issueData = await github.graphql( + ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + title + body + closedByPullRequestsReferences(first: 10, includeClosedPrs: true) { + nodes { number title body url state merged } + } + timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], last: 50) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + body + url + state + merged + repository { nameWithOwner } + } + } + } + } + } + } + } + } + `, + { owner, repo, number: issueNumber } + ); + const issue = issueData.repository.issue; + + // Prefer the PR that declares it closes this issue; fall back to the most + // recent same-repo PR that cross-references it. + const closingPrs = issue.closedByPullRequestsReferences.nodes.filter(Boolean); + const crossRefPrs = issue.timelineItems.nodes + .map((node) => node && node.source) + .filter((source) => source && source.url && source.repository.nameWithOwner.toLowerCase() === process.env.REPOSITORY.toLowerCase()); + const candidates = closingPrs.length > 0 ? closingPrs : crossRefPrs; + const pullRequest = candidates.length > 0 + ? candidates.reduce((latest, pr) => (pr.number > latest.number ? pr : latest)) + : null; + if (!pullRequest) { + core.warning(`No linked pull request found for ${process.env.ISSUE_URL}; summarizing from the issue conversation alone.`); + } + + const issueComments = (await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + })).map((comment) => ({ author: comment.user.login, createdAt: comment.created_at, body: comment.body || '' })); + + const prComments = pullRequest + ? (await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullRequest.number, + per_page: 100, + })).map((comment) => ({ author: comment.user.login, createdAt: comment.created_at, body: comment.body || '' })) + : []; + + const imageUrls = []; + const collectImages = (text) => { + for (const match of text.matchAll(/!\[[^\]]*\]\((https?:[^)\s]+)\)/g)) { + if (!imageUrls.includes(match[1])) imageUrls.push(match[1]); + } + for (const match of text.matchAll(/]*src=["']([^"']+)["']/g)) { + if (!imageUrls.includes(match[1])) imageUrls.push(match[1]); + } + }; + if (pullRequest) collectImages(pullRequest.body || ''); + for (const comment of prComments) collectImages(comment.body); + for (const comment of issueComments) collectImages(comment.body); + core.info(`Found ${imageUrls.length} proof-of-work image(s).`); + + const renderComments = (comments, dropped) => { + const parts = []; + if (dropped > 0) parts.push(`_(${dropped} earlier comment(s) omitted for length)_`); + for (const comment of comments) { + parts.push(`### Comment by ${comment.author} (${comment.createdAt})\n\n${comment.body}`); + } + return parts.join('\n\n') || '_(no comments)_'; + }; + + let droppedIssueComments = 0; + let droppedPrComments = 0; + const assembleContext = () => { + const sections = [ + `## Original requirements — issue #${issueNumber}: ${issue.title}`, + issue.body || '_(empty)_', + '## Conversation on the issue', + renderComments(issueComments, droppedIssueComments), + ]; + if (pullRequest) { + sections.push( + `## Pull request: ${pullRequest.title} (${pullRequest.url}) — ${pullRequest.merged ? 'merged' : pullRequest.state.toLowerCase()}`, + pullRequest.body || '_(empty)_', + '## Conversation on the pull request', + renderComments(prComments, droppedPrComments) + ); + } else { + sections.push('## Pull request', '_(no linked pull request was found)_'); + } + sections.push( + '## Proof-of-work images', + imageUrls.length > 0 ? imageUrls.map((url) => `- ${url}`).join('\n') : '_(none)_' + ); + return sections.join('\n\n'); + }; + + const maxContextLength = 60000; + let contextText = assembleContext(); + while (contextText.length > maxContextLength && (issueComments.length > 0 || prComments.length > 0)) { + if (issueComments.length > 0) { + issueComments.shift(); + droppedIssueComments += 1; + } else { + prComments.shift(); + droppedPrComments += 1; + } + contextText = assembleContext(); + } + if (contextText.length > maxContextLength) { + contextText = `${contextText.slice(0, maxContextLength)}\n\n…(truncated for length)`; + } + + const reviewUrl = pullRequest ? pullRequest.url : process.env.ISSUE_URL; + const template = fs.readFileSync(path.join(process.env.ACTION_PATH, 'prompt.md'), 'utf8'); + let prompt = template.replaceAll('{{PR_URL}}', reviewUrl).replaceAll('{{CONTEXT}}', contextText); + const extra = (process.env.EXTRA_INSTRUCTIONS || '').trim(); + if (extra) { + prompt += `\n\n# Additional instructions\n\n${extra}`; + } + core.setOutput('prompt', prompt); + + - name: Generate summary + id: run_codex + if: steps.check.outputs.found == 'true' + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ inputs.openai-api-key }} + model: ${{ inputs.codex-model }} + effort: ${{ inputs.codex-effort }} + sandbox: ${{ inputs.codex-sandbox }} + safety-strategy: ${{ inputs.codex-safety-strategy }} + prompt: ${{ steps.prepare.outputs.prompt }} + + - name: Post summary + id: post + if: steps.check.outputs.found == 'true' + uses: actions/github-script@v8 + env: + FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }} + REPOSITORY: ${{ inputs.repository }} + ISSUE_NUMBER: ${{ steps.check.outputs.issue_number }} + SUMMARY_MARKER: ${{ inputs.summary-marker }} + with: + github-token: ${{ inputs.github-token }} + script: | + // Strip any stray HTML comments the model emitted, then embed our idempotency + // marker inside the summary comment itself so a reopen→close cycle finds it. + const summary = (process.env.FINAL_MESSAGE || '').replace(//g, '').trim(); + if (!summary) { + core.setFailed('Codex returned an empty summary; nothing was posted.'); + return; + } + + const [owner, repo] = process.env.REPOSITORY.split('/'); + const issueNumber = Number(process.env.ISSUE_NUMBER); + const summaryComment = await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${process.env.SUMMARY_MARKER}\n\n${summary}`, + }); + core.info(`Posted candidate summary: ${summaryComment.data.html_url}`); + core.setOutput('processed', 'true'); + core.setOutput('summary_comment_url', summaryComment.data.html_url); diff --git a/banzai-codes/post-candidate-summary/prompt.md b/banzai-codes/post-candidate-summary/prompt.md new file mode 100644 index 0000000..02a4438 --- /dev/null +++ b/banzai-codes/post-candidate-summary/prompt.md @@ -0,0 +1,39 @@ +You are writing a completion summary for a Product Manager who requested a piece of work. +The work was carried out by an autonomous coding agent; the material below contains the +original requirements, the conversation that happened while the work was done, and the +resulting pull request. Your job is to tell the Product Manager how their request was +fulfilled. + +Hard rules: + +- Write for a non-technical reader. Describe what was accomplished in product terms. + Never mention file names, branch names, code identifiers, tests, commits or any other + engineering jargon. +- Output pure markdown only. Never include HTML comments (``) anywhere in your + reply — they break downstream processing of the summary. Do use markdown styles such + as bold, italic and thoughtfull paragraphs and headers to increase readability and + higlight important text. +- Embed only image URLs listed under "Proof-of-work images". Do not invent, alter or + omit-and-describe URLs; if no images are listed, skip the images entirely. +- Keep the whole summary under roughly 300 words. +- Do not speculate about work that is not evidenced by the material below. If the + requirements were only partially fulfilled, say so plainly. + +Structure your reply exactly like this: + +1. A short opening paragraph stating in plain language what was requested and what was + delivered. Start with the outcome, not with "This task...". +2. A section `## What you can now do` with a few bullet points written from the user's + perspective. +3. If proof-of-work images are provided: a section `## Proof of work` embedding each + image as `![]()`, with the caption describing what the image + shows. + +Respond with ONLY the summary markdown. Your entire reply is posted verbatim as a comment +on the Product Manager's work-order issue. + +--- + +# Material + +{{CONTEXT}}