From 22f44137dd1ecf52d6618a04b9566937ce560db9 Mon Sep 17 00:00:00 2001 From: Mads Frandsen Date: Thu, 11 Jun 2026 10:43:04 +0200 Subject: [PATCH 1/7] banzai: add handoff, candidate-summary and PRD actions Three composite actions supporting the Banzai Codes refinement loop: - banzai/handoff-work-orders: idempotent board scan that creates a linked harness sub-issue for every work order entering "In Progress" - banzai/post-candidate-summary: Codex-generated, PM-facing completion summary (with PR images as proof of work) posted unmarked on the parent work-order issue when a harness issue reaches "Human Review" - banzai/generate-prd: manually dispatched Codex pass that generates or incrementally updates a markdown PRD folder and opens a PR, grounding the define flow in current app behavior Both scanners are one-shot idempotent scans meant for cron wiring, since Projects v2 status changes cannot trigger workflows. Co-Authored-By: Claude Fable 5 --- banzai/README.md | 104 ++++++ banzai/generate-prd/README.md | 81 +++++ banzai/generate-prd/action.yml | 216 +++++++++++++ banzai/generate-prd/prompt.md | 81 +++++ banzai/handoff-work-orders/README.md | 74 +++++ banzai/handoff-work-orders/action.yml | 378 ++++++++++++++++++++++ banzai/post-candidate-summary/README.md | 82 +++++ banzai/post-candidate-summary/action.yml | 392 +++++++++++++++++++++++ banzai/post-candidate-summary/prompt.md | 39 +++ 9 files changed, 1447 insertions(+) create mode 100644 banzai/README.md create mode 100644 banzai/generate-prd/README.md create mode 100644 banzai/generate-prd/action.yml create mode 100644 banzai/generate-prd/prompt.md create mode 100644 banzai/handoff-work-orders/README.md create mode 100644 banzai/handoff-work-orders/action.yml create mode 100644 banzai/post-candidate-summary/README.md create mode 100644 banzai/post-candidate-summary/action.yml create mode 100644 banzai/post-candidate-summary/prompt.md diff --git a/banzai/README.md b/banzai/README.md new file mode 100644 index 0000000..90a6f7c --- /dev/null +++ b/banzai/README.md @@ -0,0 +1,104 @@ +# 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 work-order board get a linked sub-issue created on the harness board, where the orchestrator picks them up. No LLM involved. | +| [post-candidate-summary](post-candidate-summary) | Scheduled scan | Harness issues that reach **Human Review** get a Product-Manager-facing summary (generated by Codex from the issue conversation and the pull request, with PR images as proof of work) posted on the parent work-order issue. | +| [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. | + +## How they fit together + +``` +Banzai Codes (define flow) grounded by docs/prd (generate-prd) + │ publish + ▼ +work-order board ── "In Progress" ──▶ handoff-work-orders ──▶ harness issue (sub-issue, "Todo") + │ + banzai-codes-worker dispatches the coding agent, + which opens a PR and moves the issue to "Human Review" + │ +work-order issue ◀── candidate summary comment ◀── post-candidate-summary + │ + ▼ +Banzai Codes review gate (accept / reject) +``` + +GitHub Actions cannot trigger on Projects v2 status changes, so `handoff-work-orders` and +`post-candidate-summary` are **idempotent one-shot scans**: each invocation looks at the +board, does whatever is missing, and exits. Wire them to a `schedule:` cron (plus +`workflow_dispatch:` for manual runs) in the app repository: + +```yml +name: Banzai Pipeline +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +concurrency: + group: banzai-pipeline-${{ 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,banzai-work-orders + - uses: framna-dk/actions/banzai/handoff-work-orders@main + with: + github-token: ${{ steps.app-token.outputs.token }} + work-order-project-owner: framna-dk + work-order-project-number: 42 + target-repository: framna-dk/my-app + harness-project-owner: framna-dk + harness-project-number: 23 + + candidate-summary: + runs-on: framna-dk-macos-default + needs: handoff + 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,banzai-work-orders + - uses: framna-dk/actions/banzai/post-candidate-summary@main + with: + github-token: ${{ steps.app-token.outputs.token }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + harness-project-owner: framna-dk + harness-project-number: 23 +``` + +## Tokens + +The board-scanning actions cannot use the default `github.token` — it has no organization +Projects access and no cross-repository issue access. Mint a GitHub App installation token +(`actions/create-github-app-token@v3`) whose installation grants: + +- **Organization → Projects: Read & write** (board scans; the handoff writes the harness board) +- **Repository → Issues: Read & write** on both the app repository (harness issues) and the + work-order repository (issue creation targets / summary comments) +- 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-worker contract: the worker treats `Todo`, `In Progress` +and `Rework` as active states and the coding agent parks finished work in `Human Review`. +All status names are inputs, so boards with different vocabularies can override them. diff --git a/banzai/generate-prd/README.md b/banzai/generate-prd/README.md new file mode 100644 index 0000000..0e8fe17 --- /dev/null +++ b/banzai/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/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/generate-prd/action.yml b/banzai/generate-prd/action.yml new file mode 100644 index 0000000..0670bb1 --- /dev/null +++ b/banzai/generate-prd/action.yml @@ -0,0 +1,216 @@ +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: workspace-write + 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 }} + 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/generate-prd/prompt.md b/banzai/generate-prd/prompt.md new file mode 100644 index 0000000..89cebe7 --- /dev/null +++ b/banzai/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/handoff-work-orders/README.md b/banzai/handoff-work-orders/README.md new file mode 100644 index 0000000..0e99769 --- /dev/null +++ b/banzai/handoff-work-orders/README.md @@ -0,0 +1,74 @@ +## [Banzai handoff work orders](action.yml) + +Scans a work-order GitHub Projects v2 board and, for every open issue sitting in the +handoff status (default `In Progress`) that does not yet have a harness issue, creates one: + +1. Creates a new issue in `target-repository` with the work order's title and body + (plus a trailing `Work order: ` provenance line). +2. Links the work-order issue as the **parent** of the new issue via GitHub sub-issues. +3. Adds the new issue to the harness board and sets its initial status (default `Todo`), + where [banzai-codes-worker](https://github.com/framna-dk/banzai-codes-worker) picks it up. + +The scan is **idempotent and self-healing**: the sub-issue relationship is the marker, so +re-runs create nothing new; a sub-issue that fell off the harness board (or never got its +status) is repaired. An existing non-empty 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/write on the work-order and target repositories. | Yes | — | +| `work-order-project-owner` | Organization login that owns the work-order 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` | +| `target-repository` | Repository (`owner/repo`) in which harness issues are created. | Yes | — | +| `harness-project-owner` | Organization login that owns the harness project. | Yes | — | +| `harness-project-number` | Harness project number from the project URL. | Yes | — | +| `harness-status` | Initial status option set on newly created harness issues. | No | `Todo` | +| `status-field-name` | Name of the single-select status field on both boards. | No | `Status` | +| `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 | +|------|-------------| +| `created-issue-urls` | Newline-separated URLs of harness issues created by this run. | +| `created-count` | Number of harness issues created by this run. | +| `repaired-count` | Number of existing harness issues healed (re-added to the board or given their initial status). | +| `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,banzai-work-orders + +- uses: framna-dk/actions/banzai/handoff-work-orders@main + with: + github-token: ${{ steps.app-token.outputs.token }} + work-order-project-owner: framna-dk + work-order-project-number: 42 + target-repository: framna-dk/my-app + 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. +- If a run dies between creating the issue and linking it as a sub-issue, the next scan + creates a duplicate (the window is one API call wide). Every other partial state is + converged on the next run. diff --git a/banzai/handoff-work-orders/action.yml b/banzai/handoff-work-orders/action.yml new file mode 100644 index 0000000..3cc2178 --- /dev/null +++ b/banzai/handoff-work-orders/action.yml @@ -0,0 +1,378 @@ +name: Banzai handoff work orders +description: Create linked harness issues for work orders that reached the handoff status on a GitHub Projects v2 board. + +inputs: + github-token: + description: GitHub App installation token with org Projects read/write and Issues read/write on the work-order and target repositories. + required: true + work-order-project-owner: + description: Organization login that owns the work-order 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 + target-repository: + description: Repository (owner/repo) in which harness issues are created. + required: true + harness-project-owner: + description: Organization login that owns the harness project. + required: true + harness-project-number: + description: Harness project number from the project URL. + required: true + harness-status: + description: Initial status option set on newly created harness issues. + required: false + default: Todo + status-field-name: + description: Name of the single-select status field on both boards. + required: false + default: Status + 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: + created-issue-urls: + description: Newline-separated URLs of harness issues created by this run. + value: ${{ steps.handoff.outputs.created_issue_urls }} + created-count: + description: Number of harness issues created by this run. + value: ${{ steps.handoff.outputs.created_count }} + repaired-count: + description: Number of existing harness issues healed (re-added to the board or given their initial status). + 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 }} + TARGET_REPOSITORY: ${{ inputs.target-repository }} + 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 }} + 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 targetRepository = process.env.TARGET_REPOSITORY; + 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 maxItems = Number(process.env.MAX_ITEMS || '10'); + const dryRun = (process.env.DRY_RUN || 'false').toLowerCase() === 'true'; + + const [targetOwner, targetName] = (targetRepository || '').split('/'); + if (!targetOwner || !targetName) { + core.setFailed(`target-repository must be "owner/repo", got "${targetRepository}".`); + return; + } + 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 workOrderProject = await resolveProject(workOrderProjectOwner, workOrderProjectNumber); + 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 repoData = await github.graphql( + ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { id } + } + `, + { owner: targetOwner, name: targetName } + ); + const targetRepositoryId = repoData.repository.id; + + 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 + title + body + url + state + repository { nameWithOwner } + subIssues(first: 50) { + nodes { + id + number + url + repository { nameWithOwner } + } + } + } + } + } + } + } + } + } + `, + { + owner: workOrderProjectOwner, + number: workOrderProjectNumber, + statusField: statusFieldName, + after, + } + ); + const items = page.organization.projectV2.items; + for (const item of items.nodes) { + const status = item.fieldValueByName && item.fieldValueByName.name; + const issue = item.content; + if (status === workOrderStatus && issue && issue.id && issue.state === 'OPEN') { + workOrders.push(issue); + } + } + if (!items.pageInfo.hasNextPage) break; + after = items.pageInfo.endCursor; + } + core.info(`Found ${workOrders.length} work order(s) in "${workOrderStatus}" on project ${workOrderProjectNumber}.`); + + const findHarnessProjectItem = async (issueId) => { + const data = await github.graphql( + ` + query($id: ID!, $statusField: String!) { + node(id: $id) { + ... on Issue { + projectItems(first: 50, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: $statusField) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + `, + { id: issueId, statusField: statusFieldName } + ); + const nodes = (data.node && data.node.projectItems && data.node.projectItems.nodes) || []; + return nodes.find((node) => node.project && node.project.id === harnessProject.id) || null; + }; + + 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 buildHarnessBody = (workOrder) => { + const maxBodyLength = 60000; + let body = workOrder.body || ''; + if (body.length > maxBodyLength) { + body = `${body.slice(0, maxBodyLength)}\n\n…(truncated — the full requirements live on the work order)`; + } + return `${body}\n\n---\nWork order: ${workOrder.url}`; + }; + + const createdIssueUrls = []; + 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 existing = workOrder.subIssues.nodes.find( + (subIssue) => subIssue.repository.nameWithOwner.toLowerCase() === targetRepository.toLowerCase() + ); + + if (!existing) { + if (dryRun) { + core.info(`[dry-run] Would create a harness issue in ${targetRepository} for ${workOrder.url}, link it as a sub-issue, add it to project ${harnessProjectNumber} and set status "${harnessStatus}".`); + actedOn += 1; + continue; + } + const created = await github.graphql( + ` + mutation($repositoryId: ID!, $title: String!, $body: String!) { + createIssue(input: { repositoryId: $repositoryId, title: $title, body: $body }) { + issue { id number url } + } + } + `, + { + repositoryId: targetRepositoryId, + title: workOrder.title, + body: buildHarnessBody(workOrder), + } + ); + const harnessIssue = created.createIssue.issue; + await github.graphql( + ` + mutation($issueId: ID!, $subIssueId: ID!) { + addSubIssue(input: { issueId: $issueId, subIssueId: $subIssueId }) { + issue { id } + } + } + `, + { issueId: workOrder.id, subIssueId: harnessIssue.id } + ); + const itemId = await addToHarnessBoard(harnessIssue.id); + await setHarnessStatus(itemId); + createdIssueUrls.push(harnessIssue.url); + actedOn += 1; + core.info(`Created ${harnessIssue.url} for ${workOrder.url} (status "${harnessStatus}").`); + continue; + } + + // Sub-issue already exists: converge board membership and status without + // ever overwriting a status the orchestrator may have moved since. + const projectItem = await findHarnessProjectItem(existing.id); + if (!projectItem) { + if (dryRun) { + core.info(`[dry-run] Would re-add ${existing.url} to project ${harnessProjectNumber} and set status "${harnessStatus}".`); + } else { + const itemId = await addToHarnessBoard(existing.id); + await setHarnessStatus(itemId); + core.info(`Repaired ${existing.url}: re-added to the harness board with status "${harnessStatus}".`); + } + repairedCount += 1; + actedOn += 1; + } else if (!(projectItem.fieldValueByName && projectItem.fieldValueByName.name)) { + if (dryRun) { + core.info(`[dry-run] Would set status "${harnessStatus}" on ${existing.url}.`); + } else { + await setHarnessStatus(projectItem.id); + core.info(`Repaired ${existing.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('created_issue_urls', createdIssueUrls.join('\n')); + core.setOutput('created_count', String(createdIssueUrls.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/post-candidate-summary/README.md b/banzai/post-candidate-summary/README.md new file mode 100644 index 0000000..5cac575 --- /dev/null +++ b/banzai/post-candidate-summary/README.md @@ -0,0 +1,82 @@ +## [Banzai post candidate summary](action.yml) + +Scans a harness GitHub Projects v2 board for issues that reached the review status +(default `Human Review`) and have not yet been summarized, picks the **oldest one**, and: + +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 an **unmarked** comment on the **parent work-order issue** — + Banzai Codes ingests the latest unmarked comment as the candidate summary, so the + comment is defensively stripped of any HTML comments before posting. +4. Posts a marker comment (default ``) on the harness issue, + which is what makes the scan idempotent. + +One item is processed per invocation — a composite action cannot loop an LLM step — so +run it on a cron schedule and let the ticks drain the queue (see +[the folder README](../README.md)). The `remaining-count` output reports the backlog. +No repository checkout is required. + +### Inputs + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| `github-token` | App installation token with org Projects read and Issues read/write on the harness and work-order repositories. | Yes | — | +| `openai-api-key` | OpenAI API key used by Codex to generate the summary. | Yes | — | +| `harness-project-owner` | Organization login that owns the harness project. | Yes | — | +| `harness-project-number` | Harness project number from the project URL. | Yes | — | +| `repository` | Repository (`owner/repo`) whose issues on the harness board are eligible. | No | `${{ github.repository }}` | +| `trigger-status` | Status option on the harness board that makes an item eligible. | No | `Human Review` | +| `status-field-name` | Name of the single-select status field on the harness board. | No | `Status` | +| `summary-marker` | Marker comment posted on the harness issue once its summary has been delivered. | 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 an eligible item was summarized by this run. | +| `harness-issue-url` | URL of the harness issue handled by this run. | +| `work-order-issue-url` | URL of the parent work-order issue that received the summary. | +| `summary-comment-url` | URL of the summary comment posted on the work-order issue. | +| `remaining-count` | Number of eligible items still queued after this 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,banzai-work-orders + +- uses: framna-dk/actions/banzai/post-candidate-summary@main + with: + github-token: ${{ steps.app-token.outputs.token }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + harness-project-owner: framna-dk + harness-project-number: 23 +``` + +### Notes and troubleshooting + +- **A failing item fails the run** and, because the scan always picks the oldest item, + stalls the queue visibly. To skip a poisoned item, post the `summary-marker` comment on + the harness issue manually (or fix what made it fail) and let the next tick continue. +- Items in the trigger status **without a parent work order** are warned about and passed + over; they don't block the queue. +- If the run dies between posting the summary and posting the marker, the next run posts + the summary again. Banzai Codes reads the *latest* unmarked comment, so the duplicate + is benign. +- 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/post-candidate-summary/action.yml b/banzai/post-candidate-summary/action.yml new file mode 100644 index 0000000..6c11e82 --- /dev/null +++ b/banzai/post-candidate-summary/action.yml @@ -0,0 +1,392 @@ +name: Banzai post candidate summary +description: Generate a Product-Manager-facing summary for a harness issue in human review and post it on the parent work-order issue. + +inputs: + github-token: + description: GitHub App installation token with org Projects read and Issues read/write on the harness and work-order repositories. + required: true + openai-api-key: + description: OpenAI API key used by Codex to generate the summary. + required: true + harness-project-owner: + description: Organization login that owns the harness project. + required: true + harness-project-number: + description: Harness project number from the project URL. + required: true + repository: + description: Repository (owner/repo) whose issues on the harness board are eligible. + required: false + default: ${{ github.repository }} + trigger-status: + description: Status option on the harness board that makes an item eligible for a summary. + required: false + default: Human Review + status-field-name: + description: Name of the single-select status field on the harness board. + required: false + default: Status + summary-marker: + description: Marker comment posted on the harness issue once its summary has been delivered. + 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 an eligible item was summarized by this run. + value: ${{ steps.post.outputs.processed || 'false' }} + harness-issue-url: + description: URL of the harness issue handled by this run. + value: ${{ steps.scan.outputs.issue_url }} + work-order-issue-url: + description: URL of the parent work-order issue that received the summary. + value: ${{ steps.scan.outputs.parent_url }} + summary-comment-url: + description: URL of the summary comment posted on the work-order issue. + value: ${{ steps.post.outputs.summary_comment_url }} + remaining-count: + description: Number of eligible items still queued after this run. + value: ${{ steps.scan.outputs.remaining_count }} + +runs: + using: composite + steps: + - name: Scan harness board + id: scan + uses: actions/github-script@v8 + env: + HARNESS_PROJECT_OWNER: ${{ inputs.harness-project-owner }} + HARNESS_PROJECT_NUMBER: ${{ inputs.harness-project-number }} + REPOSITORY: ${{ inputs.repository }} + TRIGGER_STATUS: ${{ inputs.trigger-status }} + STATUS_FIELD_NAME: ${{ inputs.status-field-name }} + SUMMARY_MARKER: ${{ inputs.summary-marker }} + with: + github-token: ${{ inputs.github-token }} + script: | + const projectOwner = process.env.HARNESS_PROJECT_OWNER; + const projectNumber = Number(process.env.HARNESS_PROJECT_NUMBER); + const repository = process.env.REPOSITORY; + const triggerStatus = process.env.TRIGGER_STATUS || 'Human Review'; + const statusFieldName = process.env.STATUS_FIELD_NAME || 'Status'; + const summaryMarker = process.env.SUMMARY_MARKER; + + if (!Number.isInteger(projectNumber)) { + core.setFailed('harness-project-number must be an integer.'); + return; + } + if (!summaryMarker) { + core.setFailed('summary-marker must not be empty.'); + return; + } + + const eligible = []; + 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 { + number + title + url + state + repository { nameWithOwner } + parent { + number + url + repository { nameWithOwner } + } + comments(last: 100) { + nodes { body } + } + } + } + } + } + } + } + } + `, + { owner: projectOwner, number: projectNumber, statusField: statusFieldName, after } + ); + const project = page.organization && page.organization.projectV2; + if (!project) { + core.setFailed(`Project ${projectNumber} not found for organization "${projectOwner}" (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 !== triggerStatus || !issue || !issue.number || issue.state !== 'OPEN') continue; + if (issue.repository.nameWithOwner.toLowerCase() !== repository.toLowerCase()) continue; + if (issue.comments.nodes.some((comment) => comment.body.includes(summaryMarker))) continue; + if (!issue.parent) { + core.warning(`${issue.url} is in "${triggerStatus}" but has no parent work order; skipping.`); + continue; + } + eligible.push(issue); + } + if (!project.items.pageInfo.hasNextPage) break; + after = project.items.pageInfo.endCursor; + } + + if (eligible.length === 0) { + core.info(`No eligible items in "${triggerStatus}" on project ${projectNumber}.`); + core.setOutput('found', 'false'); + core.setOutput('remaining_count', '0'); + return; + } + + eligible.sort((a, b) => a.number - b.number); + const issue = eligible[0]; + core.info(`Summarizing ${issue.url} (${eligible.length - 1} more queued for subsequent runs).`); + core.setOutput('found', 'true'); + core.setOutput('issue_number', String(issue.number)); + core.setOutput('issue_url', issue.url); + core.setOutput('parent_repo', issue.parent.repository.nameWithOwner); + core.setOutput('parent_number', String(issue.parent.number)); + core.setOutput('parent_url', issue.parent.url); + core.setOutput('remaining_count', String(eligible.length - 1)); + + - name: Gather context and build prompt + id: prepare + if: steps.scan.outputs.found == 'true' + uses: actions/github-script@v8 + env: + ACTION_PATH: ${{ github.action_path }} + REPOSITORY: ${{ inputs.repository }} + ISSUE_NUMBER: ${{ steps.scan.outputs.issue_number }} + ISSUE_URL: ${{ steps.scan.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.scan.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.scan.outputs.found == 'true' + uses: actions/github-script@v8 + env: + FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }} + REPOSITORY: ${{ inputs.repository }} + ISSUE_NUMBER: ${{ steps.scan.outputs.issue_number }} + PARENT_REPO: ${{ steps.scan.outputs.parent_repo }} + PARENT_NUMBER: ${{ steps.scan.outputs.parent_number }} + PARENT_URL: ${{ steps.scan.outputs.parent_url }} + SUMMARY_MARKER: ${{ inputs.summary-marker }} + with: + github-token: ${{ inputs.github-token }} + script: | + // Banzai Codes treats the latest comment WITHOUT an HTML marker as the + // candidate summary, so the posted comment must contain no HTML comments. + const summary = (process.env.FINAL_MESSAGE || '').replace(//g, '').trim(); + if (!summary) { + core.setFailed('Codex returned an empty summary; nothing was posted.'); + return; + } + + const [parentOwner, parentRepo] = process.env.PARENT_REPO.split('/'); + const summaryComment = await github.rest.issues.createComment({ + owner: parentOwner, + repo: parentRepo, + issue_number: Number(process.env.PARENT_NUMBER), + body: summary, + }); + core.info(`Posted candidate summary: ${summaryComment.data.html_url}`); + + const [owner, repo] = process.env.REPOSITORY.split('/'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: Number(process.env.ISSUE_NUMBER), + body: `${process.env.SUMMARY_MARKER}\nPosted the candidate summary to ${process.env.PARENT_URL}: ${summaryComment.data.html_url}`, + }); + + core.setOutput('processed', 'true'); + core.setOutput('summary_comment_url', summaryComment.data.html_url); diff --git a/banzai/post-candidate-summary/prompt.md b/banzai/post-candidate-summary/prompt.md new file mode 100644 index 0000000..798c4c7 --- /dev/null +++ b/banzai/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. +- 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. +4. A section `## How to review` with one or two sentences pointing the reader at the + pull request: {{PR_URL}} + +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}} From 7ecf12118b6a28776271b5d1e5079c39fd69138e Mon Sep 17 00:00:00 2001 From: Mads Frandsen Date: Thu, 11 Jun 2026 10:54:08 +0200 Subject: [PATCH 2/7] banzai: rename folder to banzai-codes Co-Authored-By: Claude Fable 5 --- {banzai => banzai-codes}/README.md | 4 ++-- {banzai => banzai-codes}/generate-prd/README.md | 2 +- {banzai => banzai-codes}/generate-prd/action.yml | 0 {banzai => banzai-codes}/generate-prd/prompt.md | 0 {banzai => banzai-codes}/handoff-work-orders/README.md | 2 +- {banzai => banzai-codes}/handoff-work-orders/action.yml | 0 {banzai => banzai-codes}/post-candidate-summary/README.md | 2 +- {banzai => banzai-codes}/post-candidate-summary/action.yml | 0 {banzai => banzai-codes}/post-candidate-summary/prompt.md | 0 9 files changed, 5 insertions(+), 5 deletions(-) rename {banzai => banzai-codes}/README.md (96%) rename {banzai => banzai-codes}/generate-prd/README.md (98%) rename {banzai => banzai-codes}/generate-prd/action.yml (100%) rename {banzai => banzai-codes}/generate-prd/prompt.md (100%) rename {banzai => banzai-codes}/handoff-work-orders/README.md (98%) rename {banzai => banzai-codes}/handoff-work-orders/action.yml (100%) rename {banzai => banzai-codes}/post-candidate-summary/README.md (98%) rename {banzai => banzai-codes}/post-candidate-summary/action.yml (100%) rename {banzai => banzai-codes}/post-candidate-summary/prompt.md (100%) diff --git a/banzai/README.md b/banzai-codes/README.md similarity index 96% rename from banzai/README.md rename to banzai-codes/README.md index 90a6f7c..f05ffd9 100644 --- a/banzai/README.md +++ b/banzai-codes/README.md @@ -54,7 +54,7 @@ jobs: private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk repositories: my-app,banzai-work-orders - - uses: framna-dk/actions/banzai/handoff-work-orders@main + - uses: framna-dk/actions/banzai-codes/handoff-work-orders@main with: github-token: ${{ steps.app-token.outputs.token }} work-order-project-owner: framna-dk @@ -75,7 +75,7 @@ jobs: private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk repositories: my-app,banzai-work-orders - - uses: framna-dk/actions/banzai/post-candidate-summary@main + - 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 }} diff --git a/banzai/generate-prd/README.md b/banzai-codes/generate-prd/README.md similarity index 98% rename from banzai/generate-prd/README.md rename to banzai-codes/generate-prd/README.md index 0e8fe17..b08eed6 100644 --- a/banzai/generate-prd/README.md +++ b/banzai-codes/generate-prd/README.md @@ -63,7 +63,7 @@ jobs: with: fetch-depth: 0 - - uses: framna-dk/actions/banzai/generate-prd@main + - uses: framna-dk/actions/banzai-codes/generate-prd@main with: github-token: ${{ github.token }} openai-api-key: ${{ secrets.OPENAI_API_KEY }} diff --git a/banzai/generate-prd/action.yml b/banzai-codes/generate-prd/action.yml similarity index 100% rename from banzai/generate-prd/action.yml rename to banzai-codes/generate-prd/action.yml diff --git a/banzai/generate-prd/prompt.md b/banzai-codes/generate-prd/prompt.md similarity index 100% rename from banzai/generate-prd/prompt.md rename to banzai-codes/generate-prd/prompt.md diff --git a/banzai/handoff-work-orders/README.md b/banzai-codes/handoff-work-orders/README.md similarity index 98% rename from banzai/handoff-work-orders/README.md rename to banzai-codes/handoff-work-orders/README.md index 0e99769..5a9e9ce 100644 --- a/banzai/handoff-work-orders/README.md +++ b/banzai-codes/handoff-work-orders/README.md @@ -54,7 +54,7 @@ schedule (see [the folder README](../README.md) for the full pipeline workflow). owner: framna-dk repositories: my-app,banzai-work-orders -- uses: framna-dk/actions/banzai/handoff-work-orders@main +- uses: framna-dk/actions/banzai-codes/handoff-work-orders@main with: github-token: ${{ steps.app-token.outputs.token }} work-order-project-owner: framna-dk diff --git a/banzai/handoff-work-orders/action.yml b/banzai-codes/handoff-work-orders/action.yml similarity index 100% rename from banzai/handoff-work-orders/action.yml rename to banzai-codes/handoff-work-orders/action.yml diff --git a/banzai/post-candidate-summary/README.md b/banzai-codes/post-candidate-summary/README.md similarity index 98% rename from banzai/post-candidate-summary/README.md rename to banzai-codes/post-candidate-summary/README.md index 5cac575..a59b648 100644 --- a/banzai/post-candidate-summary/README.md +++ b/banzai-codes/post-candidate-summary/README.md @@ -59,7 +59,7 @@ No repository checkout is required. owner: framna-dk repositories: my-app,banzai-work-orders -- uses: framna-dk/actions/banzai/post-candidate-summary@main +- 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 }} diff --git a/banzai/post-candidate-summary/action.yml b/banzai-codes/post-candidate-summary/action.yml similarity index 100% rename from banzai/post-candidate-summary/action.yml rename to banzai-codes/post-candidate-summary/action.yml diff --git a/banzai/post-candidate-summary/prompt.md b/banzai-codes/post-candidate-summary/prompt.md similarity index 100% rename from banzai/post-candidate-summary/prompt.md rename to banzai-codes/post-candidate-summary/prompt.md From f9edcbd8d619fa794bdb2a687df55ec1292bf913 Mon Sep 17 00:00:00 2001 From: Mads Frandsen Date: Fri, 12 Jun 2026 13:05:59 +0200 Subject: [PATCH 3/7] banzai-codes: switch to single-issue, two-boards model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An issue can sit on multiple Projects v2 boards at once — and the banzai-codes contracts already model a Product board and a Development board for the same work-order issue. Drop the duplicate harness issue and sub-issue linking: - handoff-work-orders now just adds the In Progress work order to the harness board at Todo (membership = idempotency marker; missing status healed, existing status never overwritten) - post-candidate-summary posts the summary on the same issue, with the idempotency marker embedded in the summary comment (a separate marker comment would shadow the candidate in banzai-codes' parser, which keys on its own chat markers only), and then opens the acceptance gate by moving the Product-board status to Acceptance so agent chatter is never surfaced as a premature candidate Co-Authored-By: Claude Fable 5 --- banzai-codes/README.md | 56 ++++-- banzai-codes/handoff-work-orders/README.md | 48 ++--- banzai-codes/handoff-work-orders/action.yml | 187 +++++------------- banzai-codes/post-candidate-summary/README.md | 56 +++--- .../post-candidate-summary/action.yml | 175 ++++++++++++---- 5 files changed, 279 insertions(+), 243 deletions(-) diff --git a/banzai-codes/README.md b/banzai-codes/README.md index f05ffd9..61c02e6 100644 --- a/banzai-codes/README.md +++ b/banzai-codes/README.md @@ -5,24 +5,37 @@ Three composite actions that connect the [Banzai Codes](https://github.com/framn | Action | Trigger | What it does | |--------|---------|--------------| -| [handoff-work-orders](handoff-work-orders) | Scheduled scan | Work orders that reach **In Progress** on the work-order board get a linked sub-issue created on the harness board, where the orchestrator picks them up. No LLM involved. | -| [post-candidate-summary](post-candidate-summary) | Scheduled scan | Harness issues that reach **Human Review** get a Product-Manager-facing summary (generated by Codex from the issue conversation and the pull request, with PR images as proof of work) posted on the parent work-order issue. | +| [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) | Scheduled scan | Work orders that reach **Human Review** on the harness board get a Product-Manager-facing summary (generated by Codex from the issue conversation and the pull request, with PR images as proof of work) posted on the issue, and their Product-board status moved to **Acceptance**. | | [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. | -## How they fit together +## 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 + │ publish → Product board "In Progress" ▼ -work-order board ── "In Progress" ──▶ handoff-work-orders ──▶ harness issue (sub-issue, "Todo") - │ - banzai-codes-worker dispatches the coding agent, - which opens a PR and moves the issue to "Human Review" - │ -work-order issue ◀── candidate summary comment ◀── post-candidate-summary +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" + │ + ▼ +post-candidate-summary ──▶ candidate summary comment on the issue + │ + Product board "In Progress" → "Acceptance" + ▼ Banzai Codes review gate (accept / reject) ``` @@ -53,13 +66,12 @@ jobs: app-id: ${{ vars.PROJECTS_APP_ID }} private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk - repositories: my-app,banzai-work-orders + 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 - target-repository: framna-dk/my-app harness-project-owner: framna-dk harness-project-number: 23 @@ -74,24 +86,26 @@ jobs: app-id: ${{ vars.PROJECTS_APP_ID }} private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk - repositories: my-app,banzai-work-orders + 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 }} harness-project-owner: framna-dk harness-project-number: 23 + work-order-project-number: 42 ``` ## Tokens The board-scanning actions cannot use the default `github.token` — it has no organization -Projects access and no cross-repository issue access. Mint a GitHub App installation token +Projects access. Mint a GitHub App installation token (`actions/create-github-app-token@v3`) whose installation grants: -- **Organization → Projects: Read & write** (board scans; the handoff writes the harness board) -- **Repository → Issues: Read & write** on both the app repository (harness issues) and the - work-order repository (issue creation targets / summary comments) +- **Organization → Projects: Read & write** (board scans; the handoff writes the harness + board, the summary writes the Product board) +- **Repository → Issues: Read & write** on the repository holding the work-order issues + (the summary action comments on them) - For `generate-prd` only (when not using `github.token`): **Contents: Read & write** and **Pull requests: Read & write** @@ -99,6 +113,8 @@ Projects access and no cross-repository issue access. Mint a GitHub App installa ## Status name contract -Defaults follow the banzai-codes-worker contract: the worker treats `Todo`, `In Progress` -and `Rework` as active states and the coding agent parks finished work in `Human Review`. -All status names are inputs, so boards with different vocabularies can override them. +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/handoff-work-orders/README.md b/banzai-codes/handoff-work-orders/README.md index 5a9e9ce..30effda 100644 --- a/banzai-codes/handoff-work-orders/README.md +++ b/banzai-codes/handoff-work-orders/README.md @@ -1,18 +1,19 @@ ## [Banzai handoff work orders](action.yml) -Scans a work-order GitHub Projects v2 board and, for every open issue sitting in the -handoff status (default `In Progress`) that does not yet have a harness issue, creates one: +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. -1. Creates a new issue in `target-repository` with the work order's title and body - (plus a trailing `Work order: ` provenance line). -2. Links the work-order issue as the **parent** of the new issue via GitHub sub-issues. -3. Adds the new issue to the harness board and sets its 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**: the sub-issue relationship is the marker, so -re-runs create nothing new; a sub-issue that fell off the harness board (or never got its -status) is repaired. An existing non-empty status is never overwritten — once the -orchestrator owns the issue, this action keeps its hands off. +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). @@ -21,15 +22,15 @@ schedule (see [the folder README](../README.md) for the full pipeline workflow). | Name | Description | Required | Default | |------|-------------|----------|---------| -| `github-token` | App installation token with org Projects read/write and Issues read/write on the work-order and target repositories. | Yes | — | -| `work-order-project-owner` | Organization login that owns the work-order project. | Yes | — | +| `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` | -| `target-repository` | Repository (`owner/repo`) in which harness issues are created. | Yes | — | -| `harness-project-owner` | Organization login that owns the harness project. | Yes | — | +| `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 on newly created harness issues. | No | `Todo` | +| `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` | @@ -37,9 +38,9 @@ schedule (see [the folder README](../README.md) for the full pipeline workflow). | Name | Description | |------|-------------| -| `created-issue-urls` | Newline-separated URLs of harness issues created by this run. | -| `created-count` | Number of harness issues created by this run. | -| `repaired-count` | Number of existing harness issues healed (re-added to the board or given their initial status). | +| `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 @@ -52,14 +53,13 @@ schedule (see [the folder README](../README.md) for the full pipeline workflow). app-id: ${{ vars.PROJECTS_APP_ID }} private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk - repositories: my-app,banzai-work-orders + 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 - target-repository: framna-dk/my-app harness-project-owner: framna-dk harness-project-number: 23 ``` @@ -69,6 +69,6 @@ schedule (see [the folder README](../README.md) for the full pipeline workflow). - 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. -- If a run dies between creating the issue and linking it as a sub-issue, the next scan - creates a duplicate (the window is one API call wide). Every other partial state is - converged on the next run. +- 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 index 3cc2178..bfad140 100644 --- a/banzai-codes/handoff-work-orders/action.yml +++ b/banzai-codes/handoff-work-orders/action.yml @@ -1,12 +1,12 @@ name: Banzai handoff work orders -description: Create linked harness issues for work orders that reached the handoff status on a GitHub Projects v2 board. +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/write on the work-order and target repositories. + 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 project. + 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. @@ -15,23 +15,24 @@ inputs: description: Status option on the work-order board that triggers the handoff. required: false default: In Progress - target-repository: - description: Repository (owner/repo) in which harness issues are created. - required: true harness-project-owner: - description: Organization login that owns the harness project. + 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 on newly created harness issues. + 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 @@ -42,14 +43,14 @@ inputs: default: "false" outputs: - created-issue-urls: - description: Newline-separated URLs of harness issues created by this run. - value: ${{ steps.handoff.outputs.created_issue_urls }} - created-count: - description: Number of harness issues created by this run. - value: ${{ steps.handoff.outputs.created_count }} + 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 existing harness issues healed (re-added to the board or given their initial status). + 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). @@ -65,11 +66,11 @@ runs: 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 }} - TARGET_REPOSITORY: ${{ inputs.target-repository }} 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: @@ -78,19 +79,14 @@ runs: 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 targetRepository = process.env.TARGET_REPOSITORY; 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'; - const [targetOwner, targetName] = (targetRepository || '').split('/'); - if (!targetOwner || !targetName) { - core.setFailed(`target-repository must be "owner/repo", got "${targetRepository}".`); - return; - } if (!Number.isInteger(workOrderProjectNumber) || !Number.isInteger(harnessProjectNumber)) { core.setFailed('work-order-project-number and harness-project-number must be integers.'); return; @@ -126,7 +122,6 @@ runs: return project; }; - const workOrderProject = await resolveProject(workOrderProjectOwner, workOrderProjectNumber); const harnessProject = await resolveProject(harnessProjectOwner, harnessProjectNumber); const harnessStatusField = harnessProject.fields.nodes.find( @@ -143,16 +138,6 @@ runs: return; } - const repoData = await github.graphql( - ` - query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { id } - } - `, - { owner: targetOwner, name: targetName } - ); - const targetRepositoryId = repoData.repository.id; - const workOrders = []; let after = null; while (true) { @@ -171,17 +156,16 @@ runs: ... on Issue { id number - title - body url state repository { nameWithOwner } - subIssues(first: 50) { + projectItems(first: 50, includeArchived: true) { nodes { id - number - url - repository { nameWithOwner } + project { id } + fieldValueByName(name: $statusField) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } } } } @@ -199,44 +183,23 @@ runs: after, } ); - const items = page.organization.projectV2.items; - for (const item of items.nodes) { + 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') { - workOrders.push(issue); - } + if (status !== workOrderStatus || !issue || !issue.id || issue.state !== 'OPEN') continue; + if (repositoryFilter && issue.repository.nameWithOwner.toLowerCase() !== repositoryFilter) continue; + workOrders.push(issue); } - if (!items.pageInfo.hasNextPage) break; - after = items.pageInfo.endCursor; + 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 findHarnessProjectItem = async (issueId) => { - const data = await github.graphql( - ` - query($id: ID!, $statusField: String!) { - node(id: $id) { - ... on Issue { - projectItems(first: 50, includeArchived: true) { - nodes { - id - project { id } - fieldValueByName(name: $statusField) { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - `, - { id: issueId, statusField: statusFieldName } - ); - const nodes = (data.node && data.node.projectItems && data.node.projectItems.nodes) || []; - return nodes.find((node) => node.project && node.project.id === harnessProject.id) || null; - }; - const addToHarnessBoard = async (issueId) => { const data = await github.graphql( ` @@ -276,16 +239,7 @@ runs: ); }; - const buildHarnessBody = (workOrder) => { - const maxBodyLength = 60000; - let body = workOrder.body || ''; - if (body.length > maxBodyLength) { - body = `${body.slice(0, maxBodyLength)}\n\n…(truncated — the full requirements live on the work order)`; - } - return `${body}\n\n---\nWork order: ${workOrder.url}`; - }; - - const createdIssueUrls = []; + const addedIssueUrls = []; const errors = []; let repairedCount = 0; let actedOn = 0; @@ -296,68 +250,31 @@ runs: break; } try { - const existing = workOrder.subIssues.nodes.find( - (subIssue) => subIssue.repository.nameWithOwner.toLowerCase() === targetRepository.toLowerCase() + const harnessItem = workOrder.projectItems.nodes.find( + (node) => node.project && node.project.id === harnessProject.id ); - if (!existing) { + if (!harnessItem) { if (dryRun) { - core.info(`[dry-run] Would create a harness issue in ${targetRepository} for ${workOrder.url}, link it as a sub-issue, add it to project ${harnessProjectNumber} and set status "${harnessStatus}".`); - actedOn += 1; - continue; + 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); } - const created = await github.graphql( - ` - mutation($repositoryId: ID!, $title: String!, $body: String!) { - createIssue(input: { repositoryId: $repositoryId, title: $title, body: $body }) { - issue { id number url } - } - } - `, - { - repositoryId: targetRepositoryId, - title: workOrder.title, - body: buildHarnessBody(workOrder), - } - ); - const harnessIssue = created.createIssue.issue; - await github.graphql( - ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { issueId: $issueId, subIssueId: $subIssueId }) { - issue { id } - } - } - `, - { issueId: workOrder.id, subIssueId: harnessIssue.id } - ); - const itemId = await addToHarnessBoard(harnessIssue.id); - await setHarnessStatus(itemId); - createdIssueUrls.push(harnessIssue.url); actedOn += 1; - core.info(`Created ${harnessIssue.url} for ${workOrder.url} (status "${harnessStatus}").`); continue; } - // Sub-issue already exists: converge board membership and status without - // ever overwriting a status the orchestrator may have moved since. - const projectItem = await findHarnessProjectItem(existing.id); - if (!projectItem) { - if (dryRun) { - core.info(`[dry-run] Would re-add ${existing.url} to project ${harnessProjectNumber} and set status "${harnessStatus}".`); - } else { - const itemId = await addToHarnessBoard(existing.id); - await setHarnessStatus(itemId); - core.info(`Repaired ${existing.url}: re-added to the harness board with status "${harnessStatus}".`); - } - repairedCount += 1; - actedOn += 1; - } else if (!(projectItem.fieldValueByName && projectItem.fieldValueByName.name)) { + // 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 ${existing.url}.`); + core.info(`[dry-run] Would set status "${harnessStatus}" on ${workOrder.url}.`); } else { - await setHarnessStatus(projectItem.id); - core.info(`Repaired ${existing.url}: set missing status "${harnessStatus}".`); + await setHarnessStatus(harnessItem.id); + core.info(`Repaired ${workOrder.url}: set missing status "${harnessStatus}".`); } repairedCount += 1; actedOn += 1; @@ -369,8 +286,8 @@ runs: } } - core.setOutput('created_issue_urls', createdIssueUrls.join('\n')); - core.setOutput('created_count', String(createdIssueUrls.length)); + 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) { diff --git a/banzai-codes/post-candidate-summary/README.md b/banzai-codes/post-candidate-summary/README.md index a59b648..a984029 100644 --- a/banzai-codes/post-candidate-summary/README.md +++ b/banzai-codes/post-candidate-summary/README.md @@ -1,18 +1,26 @@ ## [Banzai post candidate summary](action.yml) -Scans a harness GitHub Projects v2 board for issues that reached the review status -(default `Human Review`) and have not yet been summarized, picks the **oldest one**, and: +Scans the harness (Development) GitHub Projects v2 board for work orders that reached the +review status (default `Human Review`) and have not yet been summarized, picks the +**oldest one**, and: 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 an **unmarked** comment on the **parent work-order issue** — - Banzai Codes ingests the latest unmarked comment as the candidate summary, so the - comment is defensively stripped of any HTML comments before posting. -4. Posts a marker comment (default ``) on the harness issue, - which is what makes the scan idempotent. +3. Posts the summary as a comment on the issue. Banzai Codes ingests the most recent + comment without one of its own chat markers as the candidate summary; stray HTML + comments from the model are stripped, and the idempotency marker (default + ``) is embedded **inside the summary comment** — a + separate marker comment posted afterwards would shadow the summary. +4. Opens the acceptance gate: moves the work order's **Product-board** status to + `Acceptance` (when `work-order-project-number` is set), so the Banzai Codes review + gate surfaces a summary that is guaranteed to exist. + +One issue, two boards: the work order sits on the Product board (user-facing lifecycle) +and the harness board (build pipeline) at the same time. This action reads the harness +board and writes the Product board. One item is processed per invocation — a composite action cannot loop an LLM step — so run it on a cron schedule and let the ticks drain the queue (see @@ -23,14 +31,17 @@ No repository checkout is required. | Name | Description | Required | Default | |------|-------------|----------|---------| -| `github-token` | App installation token with org Projects read and Issues read/write on the harness and work-order repositories. | Yes | — | +| `github-token` | App installation token with org Projects read/write and Issues read/write on the work-order repository. | Yes | — | | `openai-api-key` | OpenAI API key used by Codex to generate the summary. | Yes | — | -| `harness-project-owner` | Organization login that owns the harness project. | Yes | — | +| `harness-project-owner` | Organization login that owns the harness (Development) project. | Yes | — | | `harness-project-number` | Harness project number from the project URL. | Yes | — | +| `work-order-project-owner` | Organization login that owns the work-order (Product) project. | No | `harness-project-owner` | +| `work-order-project-number` | Work-order project number. When set, the Product-board status is moved to `acceptance-status` after posting; when empty, no status is changed. | No | — | +| `acceptance-status` | Status option set on the Product board after the summary is posted. | No | `Acceptance` | | `repository` | Repository (`owner/repo`) whose issues on the harness board are eligible. | No | `${{ github.repository }}` | | `trigger-status` | Status option on the harness board that makes an item eligible. | No | `Human Review` | -| `status-field-name` | Name of the single-select status field on the harness board. | No | `Status` | -| `summary-marker` | Marker comment posted on the harness issue once its summary has been delivered. | No | `` | +| `status-field-name` | Name of the single-select status field on both boards. | No | `Status` | +| `summary-marker` | Marker embedded in the summary comment, making the scan idempotent. | 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` | @@ -42,9 +53,8 @@ No repository checkout is required. | Name | Description | |------|-------------| | `processed` | `true` if an eligible item was summarized by this run. | -| `harness-issue-url` | URL of the harness issue handled by this run. | -| `work-order-issue-url` | URL of the parent work-order issue that received the summary. | -| `summary-comment-url` | URL of the summary comment posted on the work-order issue. | +| `issue-url` | URL of the work-order issue handled by this run. | +| `summary-comment-url` | URL of the summary comment posted on the issue. | | `remaining-count` | Number of eligible items still queued after this run. | ### Usage @@ -57,7 +67,7 @@ No repository checkout is required. app-id: ${{ vars.PROJECTS_APP_ID }} private-key: ${{ secrets.PROJECTS_APP_PEM }} owner: framna-dk - repositories: my-app,banzai-work-orders + repositories: my-app - uses: framna-dk/actions/banzai-codes/post-candidate-summary@main with: @@ -65,18 +75,20 @@ No repository checkout is required. openai-api-key: ${{ secrets.OPENAI_API_KEY }} harness-project-owner: framna-dk harness-project-number: 23 + work-order-project-number: 42 ``` ### Notes and troubleshooting - **A failing item fails the run** and, because the scan always picks the oldest item, - stalls the queue visibly. To skip a poisoned item, post the `summary-marker` comment on - the harness issue manually (or fix what made it fail) and let the next tick continue. -- Items in the trigger status **without a parent work order** are warned about and passed - over; they don't block the queue. -- If the run dies between posting the summary and posting the marker, the next run posts - the summary again. Banzai Codes reads the *latest* unmarked comment, so the duplicate - is benign. + stalls the queue visibly. To skip a poisoned item, post a comment containing the + `summary-marker` on the issue manually (or fix what made it fail) and let the next tick + continue. +- The acceptance flip is what keeps the build agent's intermediate comments from being + surfaced prematurely: Banzai Codes only shows the candidate at the review gate, and the + gate only opens (Product status → `Acceptance`) after the summary comment exists. +- 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 index 6c11e82..2a973db 100644 --- a/banzai-codes/post-candidate-summary/action.yml +++ b/banzai-codes/post-candidate-summary/action.yml @@ -1,19 +1,31 @@ name: Banzai post candidate summary -description: Generate a Product-Manager-facing summary for a harness issue in human review and post it on the parent work-order issue. +description: Generate a Product-Manager-facing summary for a work order in human review, post it on the issue and open the acceptance gate. inputs: github-token: - description: GitHub App installation token with org Projects read and Issues read/write on the harness and work-order repositories. + description: GitHub App installation token with org Projects read/write and Issues read/write on the work-order repository. required: true openai-api-key: description: OpenAI API key used by Codex to generate the summary. required: true harness-project-owner: - description: Organization login that owns the harness project. + description: Organization login that owns the harness (Development) project. required: true harness-project-number: description: Harness project number from the project URL. required: true + work-order-project-owner: + description: Organization login that owns the work-order (Product) project. Defaults to harness-project-owner. + required: false + default: "" + work-order-project-number: + description: Work-order project number. When set, the work order's Product-board status is moved to acceptance-status after the summary is posted; when empty, no status is changed. + required: false + default: "" + acceptance-status: + description: Status option set on the work-order (Product) board after the summary is posted. + required: false + default: Acceptance repository: description: Repository (owner/repo) whose issues on the harness board are eligible. required: false @@ -23,11 +35,11 @@ inputs: required: false default: Human Review status-field-name: - description: Name of the single-select status field on the harness board. + description: Name of the single-select status field on both boards. required: false default: Status summary-marker: - description: Marker comment posted on the harness issue once its summary has been delivered. + description: Marker embedded in the summary comment, making the scan idempotent. required: false default: "" codex-model: @@ -55,14 +67,11 @@ outputs: processed: description: Whether an eligible item was summarized by this run. value: ${{ steps.post.outputs.processed || 'false' }} - harness-issue-url: - description: URL of the harness issue handled by this run. + issue-url: + description: URL of the work-order issue handled by this run. value: ${{ steps.scan.outputs.issue_url }} - work-order-issue-url: - description: URL of the parent work-order issue that received the summary. - value: ${{ steps.scan.outputs.parent_url }} summary-comment-url: - description: URL of the summary comment posted on the work-order issue. + description: URL of the summary comment posted on the issue. value: ${{ steps.post.outputs.summary_comment_url }} remaining-count: description: Number of eligible items still queued after this run. @@ -121,11 +130,6 @@ runs: url state repository { nameWithOwner } - parent { - number - url - repository { nameWithOwner } - } comments(last: 100) { nodes { body } } @@ -150,10 +154,6 @@ runs: if (status !== triggerStatus || !issue || !issue.number || issue.state !== 'OPEN') continue; if (issue.repository.nameWithOwner.toLowerCase() !== repository.toLowerCase()) continue; if (issue.comments.nodes.some((comment) => comment.body.includes(summaryMarker))) continue; - if (!issue.parent) { - core.warning(`${issue.url} is in "${triggerStatus}" but has no parent work order; skipping.`); - continue; - } eligible.push(issue); } if (!project.items.pageInfo.hasNextPage) break; @@ -173,9 +173,6 @@ runs: core.setOutput('found', 'true'); core.setOutput('issue_number', String(issue.number)); core.setOutput('issue_url', issue.url); - core.setOutput('parent_repo', issue.parent.repository.nameWithOwner); - core.setOutput('parent_number', String(issue.parent.number)); - core.setOutput('parent_url', issue.parent.url); core.setOutput('remaining_count', String(eligible.length - 1)); - name: Gather context and build prompt @@ -348,7 +345,7 @@ runs: safety-strategy: ${{ inputs.codex-safety-strategy }} prompt: ${{ steps.prepare.outputs.prompt }} - - name: Post summary + - name: Post summary and open the acceptance gate id: post if: steps.scan.outputs.found == 'true' uses: actions/github-script@v8 @@ -356,37 +353,131 @@ runs: FINAL_MESSAGE: ${{ steps.run_codex.outputs.final-message }} REPOSITORY: ${{ inputs.repository }} ISSUE_NUMBER: ${{ steps.scan.outputs.issue_number }} - PARENT_REPO: ${{ steps.scan.outputs.parent_repo }} - PARENT_NUMBER: ${{ steps.scan.outputs.parent_number }} - PARENT_URL: ${{ steps.scan.outputs.parent_url }} SUMMARY_MARKER: ${{ inputs.summary-marker }} + WORK_ORDER_PROJECT_OWNER: ${{ inputs.work-order-project-owner }} + WORK_ORDER_PROJECT_NUMBER: ${{ inputs.work-order-project-number }} + ACCEPTANCE_STATUS: ${{ inputs.acceptance-status }} + HARNESS_PROJECT_OWNER: ${{ inputs.harness-project-owner }} + STATUS_FIELD_NAME: ${{ inputs.status-field-name }} with: github-token: ${{ inputs.github-token }} script: | - // Banzai Codes treats the latest comment WITHOUT an HTML marker as the - // candidate summary, so the posted comment must contain no HTML comments. + // Banzai Codes treats the most recent comment without one of ITS OWN markers + // (banzai:meta / banzai:chat:*) as the candidate summary. Stray HTML comments + // from the model are stripped, then our idempotency marker is embedded in the + // summary comment itself — a separate marker comment posted afterwards would + // shadow the summary as the most recent candidate. const summary = (process.env.FINAL_MESSAGE || '').replace(//g, '').trim(); if (!summary) { core.setFailed('Codex returned an empty summary; nothing was posted.'); return; } - const [parentOwner, parentRepo] = process.env.PARENT_REPO.split('/'); - const summaryComment = await github.rest.issues.createComment({ - owner: parentOwner, - repo: parentRepo, - issue_number: Number(process.env.PARENT_NUMBER), - body: summary, - }); - core.info(`Posted candidate summary: ${summaryComment.data.html_url}`); - const [owner, repo] = process.env.REPOSITORY.split('/'); - await github.rest.issues.createComment({ + const issueNumber = Number(process.env.ISSUE_NUMBER); + const summaryComment = await github.rest.issues.createComment({ owner, repo, - issue_number: Number(process.env.ISSUE_NUMBER), - body: `${process.env.SUMMARY_MARKER}\nPosted the candidate summary to ${process.env.PARENT_URL}: ${summaryComment.data.html_url}`, + 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); + + // Open the acceptance gate: move the work order's Product-board status so the + // Banzai Codes review gate surfaces the summary that now exists. + const workOrderProjectNumber = Number(process.env.WORK_ORDER_PROJECT_NUMBER || ''); + if (!process.env.WORK_ORDER_PROJECT_NUMBER) { + core.info('work-order-project-number not set; leaving the Product board untouched.'); + return; + } + if (!Number.isInteger(workOrderProjectNumber)) { + core.setFailed('work-order-project-number must be an integer when set.'); + return; + } + const workOrderProjectOwner = process.env.WORK_ORDER_PROJECT_OWNER || process.env.HARNESS_PROJECT_OWNER; + const statusFieldName = process.env.STATUS_FIELD_NAME || 'Status'; + const acceptanceStatus = process.env.ACCEPTANCE_STATUS || 'Acceptance'; + + const projectData = await github.graphql( + ` + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { id name } + } + } + } + } + } + } + `, + { owner: workOrderProjectOwner, number: workOrderProjectNumber } + ); + const project = projectData.organization && projectData.organization.projectV2; + if (!project) { + core.setFailed(`Project ${workOrderProjectNumber} not found for organization "${workOrderProjectOwner}" (user-owned projects are not supported).`); + return; + } + const statusField = project.fields.nodes.find( + (field) => field && field.name === statusFieldName && Array.isArray(field.options) + ); + const statusOption = statusField && statusField.options.find((option) => option.name === acceptanceStatus); + if (!statusOption) { + core.setFailed(`Status option "${acceptanceStatus}" not found on work-order project ${workOrderProjectNumber}.`); + return; + } + + const itemData = await github.graphql( + ` + query($owner: String!, $repo: String!, $number: Int!, $statusField: String!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + projectItems(first: 50, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: $statusField) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + `, + { owner, repo, number: issueNumber, statusField: statusFieldName } + ); + const item = itemData.repository.issue.projectItems.nodes.find( + (node) => node.project && node.project.id === project.id + ); + if (!item) { + core.setFailed(`Issue #${issueNumber} is not on work-order project ${workOrderProjectNumber}; cannot open the acceptance gate.`); + return; + } + 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: project.id, itemId: item.id, fieldId: statusField.id, optionId: statusOption.id } + ); + const previousStatus = (item.fieldValueByName && item.fieldValueByName.name) || '(none)'; + core.info(`Moved work-order status ${previousStatus} → "${acceptanceStatus}" on project ${workOrderProjectNumber}.`); From e7f8d2218a39b95d933d3baa6631400a8078ff59 Mon Sep 17 00:00:00 2001 From: Mads Frandsen <418210+madsbf@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:45:16 +0200 Subject: [PATCH 4/7] Add allow-bot-users input to action.yml --- banzai-codes/generate-prd/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/banzai-codes/generate-prd/action.yml b/banzai-codes/generate-prd/action.yml index 0670bb1..22fbf13 100644 --- a/banzai-codes/generate-prd/action.yml +++ b/banzai-codes/generate-prd/action.yml @@ -120,6 +120,7 @@ runs: 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 From 2726fa39162de5fc15f46f01a659bb04d7a1fbf0 Mon Sep 17 00:00:00 2001 From: Mads Frandsen <418210+madsbf@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:25:48 +0200 Subject: [PATCH 5/7] Change default value for codex-sandbox to danger-full-access --- banzai-codes/generate-prd/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/banzai-codes/generate-prd/action.yml b/banzai-codes/generate-prd/action.yml index 22fbf13..93a4cd2 100644 --- a/banzai-codes/generate-prd/action.yml +++ b/banzai-codes/generate-prd/action.yml @@ -31,7 +31,7 @@ inputs: codex-sandbox: description: Sandbox mode for Codex. required: false - default: workspace-write + default: danger-full-access codex-safety-strategy: description: Safety strategy passed to codex-action. required: false From ec0b12319f0faed29899ca9b9117ffc9f41e0397 Mon Sep 17 00:00:00 2001 From: Mads Frandsen Date: Fri, 19 Jun 2026 09:34:38 +0200 Subject: [PATCH 6/7] banzai-codes: trigger candidate summary on issue close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post-candidate-summary scanned the harness board for work orders in "Human Review", summarized the oldest one, posted a comment, and moved the Product board to "Acceptance" — designed to be driven by a cron that drains the queue. Drop the board/cron model: the action now summarizes a single issue passed via the new required `issue-number` input, intended to run from a workflow that triggers on `issues: closed`. - Replace the board scan with a lightweight idempotency guard: skip when a comment already carries the summary marker (handles reopen->close). - Remove the Product-board acceptance-gate mutation and all board inputs (harness/work-order project owner+number, acceptance-status, trigger-status, status-field-name) and the `remaining-count` output. - Update both READMEs: close-triggered behavior, new inputs/outputs, and split the folder README's example so only handoff-work-orders stays on a cron while the summary runs on issue close. Co-Authored-By: Claude Opus 4.8 --- banzai-codes/README.md | 45 +-- banzai-codes/post-candidate-summary/README.md | 97 +++---- .../post-candidate-summary/action.yml | 261 +++--------------- 3 files changed, 110 insertions(+), 293 deletions(-) diff --git a/banzai-codes/README.md b/banzai-codes/README.md index 61c02e6..77ae80d 100644 --- a/banzai-codes/README.md +++ b/banzai-codes/README.md @@ -6,7 +6,7 @@ Three composite actions that connect the [Banzai Codes](https://github.com/framn | 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) | Scheduled scan | Work orders that reach **Human Review** on the harness board get a Product-Manager-facing summary (generated by Codex from the issue conversation and the pull request, with PR images as proof of work) posted on the issue, and their Product-board status moved to **Acceptance**. | +| [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 @@ -33,26 +33,26 @@ banzai-codes-worker dispatches the coding agent, which opens a PR and moves the harness status to "Human Review" │ ▼ -post-candidate-summary ──▶ candidate summary comment on the issue - │ + Product board "In Progress" → "Acceptance" +issue is closed (completed) + │ ▼ -Banzai Codes review gate (accept / reject) +post-candidate-summary ──▶ candidate summary comment on the issue ``` -GitHub Actions cannot trigger on Projects v2 status changes, so `handoff-work-orders` and -`post-candidate-summary` are **idempotent one-shot scans**: each invocation looks at the -board, does whatever is missing, and exits. Wire them to a `schedule:` cron (plus -`workflow_dispatch:` for manual runs) in the app repository: +`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 Pipeline +name: Banzai handoff on: schedule: - cron: "*/15 * * * *" workflow_dispatch: concurrency: - group: banzai-pipeline-${{ github.repository }} + group: banzai-handoff-${{ github.repository }} cancel-in-progress: false jobs: @@ -74,10 +74,21 @@ jobs: 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 - needs: handoff + if: github.event.issue.state_reason == 'completed' steps: - name: Mint App token id: app-token @@ -91,21 +102,19 @@ jobs: with: github-token: ${{ steps.app-token.outputs.token }} openai-api-key: ${{ secrets.OPENAI_API_KEY }} - harness-project-owner: framna-dk - harness-project-number: 23 - work-order-project-number: 42 + issue-number: ${{ github.event.issue.number }} ``` ## Tokens -The board-scanning actions cannot use the default `github.token` — it has no organization +`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** (board scans; the handoff writes the harness - board, the summary writes the Product board) +- **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 - (the summary action comments on them) + (`post-candidate-summary` comments on them) - For `generate-prd` only (when not using `github.token`): **Contents: Read & write** and **Pull requests: Read & write** diff --git a/banzai-codes/post-candidate-summary/README.md b/banzai-codes/post-candidate-summary/README.md index a984029..df78f02 100644 --- a/banzai-codes/post-candidate-summary/README.md +++ b/banzai-codes/post-candidate-summary/README.md @@ -1,47 +1,34 @@ ## [Banzai post candidate summary](action.yml) -Scans the harness (Development) GitHub Projects v2 board for work orders that reached the -review status (default `Human Review`) and have not yet been summarized, picks the -**oldest one**, and: +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. Banzai Codes ingests the most recent - comment without one of its own chat markers as the candidate summary; stray HTML - comments from the model are stripped, and the idempotency marker (default - ``) is embedded **inside the summary comment** — a - separate marker comment posted afterwards would shadow the summary. -4. Opens the acceptance gate: moves the work order's **Product-board** status to - `Acceptance` (when `work-order-project-number` is set), so the Banzai Codes review - gate surfaces a summary that is guaranteed to exist. - -One issue, two boards: the work order sits on the Product board (user-facing lifecycle) -and the harness board (build pipeline) at the same time. This action reads the harness -board and writes the Product board. +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**. -One item is processed per invocation — a composite action cannot loop an LLM step — so -run it on a cron schedule and let the ticks drain the queue (see -[the folder README](../README.md)). The `remaining-count` output reports the backlog. -No repository checkout is required. +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 org Projects read/write and Issues read/write on the work-order repository. | Yes | — | +| `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 | — | -| `harness-project-owner` | Organization login that owns the harness (Development) project. | Yes | — | -| `harness-project-number` | Harness project number from the project URL. | Yes | — | -| `work-order-project-owner` | Organization login that owns the work-order (Product) project. | No | `harness-project-owner` | -| `work-order-project-number` | Work-order project number. When set, the Product-board status is moved to `acceptance-status` after posting; when empty, no status is changed. | No | — | -| `acceptance-status` | Status option set on the Product board after the summary is posted. | No | `Acceptance` | -| `repository` | Repository (`owner/repo`) whose issues on the harness board are eligible. | No | `${{ github.repository }}` | -| `trigger-status` | Status option on the harness board that makes an item eligible. | No | `Human Review` | -| `status-field-name` | Name of the single-select status field on both boards. | No | `Status` | -| `summary-marker` | Marker embedded in the summary comment, making the scan idempotent. | No | `` | +| `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` | @@ -52,41 +39,43 @@ No repository checkout is required. | Name | Description | |------|-------------| -| `processed` | `true` if an eligible item was summarized by this run. | -| `issue-url` | URL of the work-order issue handled by this run. | +| `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. | -| `remaining-count` | Number of eligible items still queued after this 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 +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 }} - harness-project-owner: framna-dk - harness-project-number: 23 - work-order-project-number: 42 + - 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 -- **A failing item fails the run** and, because the scan always picks the oldest item, - stalls the queue visibly. To skip a poisoned item, post a comment containing the - `summary-marker` on the issue manually (or fix what made it fail) and let the next tick - continue. -- The acceptance flip is what keeps the build agent's intermediate comments from being - surfaced prematurely: Banzai Codes only shows the candidate at the review gate, and the - gate only opens (Product status → `Acceptance`) after the summary comment exists. +- 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 diff --git a/banzai-codes/post-candidate-summary/action.yml b/banzai-codes/post-candidate-summary/action.yml index 2a973db..ea51e3e 100644 --- a/banzai-codes/post-candidate-summary/action.yml +++ b/banzai-codes/post-candidate-summary/action.yml @@ -1,45 +1,22 @@ name: Banzai post candidate summary -description: Generate a Product-Manager-facing summary for a work order in human review, post it on the issue and open the acceptance gate. +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 org Projects read/write and Issues read/write on the work-order repository. + 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 - harness-project-owner: - description: Organization login that owns the harness (Development) project. + issue-number: + description: Number of the closed issue to summarize. required: true - harness-project-number: - description: Harness project number from the project URL. - required: true - work-order-project-owner: - description: Organization login that owns the work-order (Product) project. Defaults to harness-project-owner. - required: false - default: "" - work-order-project-number: - description: Work-order project number. When set, the work order's Product-board status is moved to acceptance-status after the summary is posted; when empty, no status is changed. - required: false - default: "" - acceptance-status: - description: Status option set on the work-order (Product) board after the summary is posted. - required: false - default: Acceptance repository: - description: Repository (owner/repo) whose issues on the harness board are eligible. + description: Repository (owner/repo) that owns the issue. required: false default: ${{ github.repository }} - trigger-status: - description: Status option on the harness board that makes an item eligible for a summary. - required: false - default: Human Review - status-field-name: - description: Name of the single-select status field on both boards. - required: false - default: Status summary-marker: - description: Marker embedded in the summary comment, making the scan idempotent. + description: Marker embedded in the summary comment, making the post idempotent across repeated closes. required: false default: "" codex-model: @@ -65,43 +42,34 @@ inputs: outputs: processed: - description: Whether an eligible item was summarized by this run. + description: Whether the issue was summarized by this run. value: ${{ steps.post.outputs.processed || 'false' }} issue-url: - description: URL of the work-order issue handled by this run. - value: ${{ steps.scan.outputs.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 }} - remaining-count: - description: Number of eligible items still queued after this run. - value: ${{ steps.scan.outputs.remaining_count }} runs: using: composite steps: - - name: Scan harness board - id: scan + - name: Check issue + id: check uses: actions/github-script@v8 env: - HARNESS_PROJECT_OWNER: ${{ inputs.harness-project-owner }} - HARNESS_PROJECT_NUMBER: ${{ inputs.harness-project-number }} REPOSITORY: ${{ inputs.repository }} - TRIGGER_STATUS: ${{ inputs.trigger-status }} - STATUS_FIELD_NAME: ${{ inputs.status-field-name }} + ISSUE_NUMBER: ${{ inputs.issue-number }} SUMMARY_MARKER: ${{ inputs.summary-marker }} with: github-token: ${{ inputs.github-token }} script: | - const projectOwner = process.env.HARNESS_PROJECT_OWNER; - const projectNumber = Number(process.env.HARNESS_PROJECT_NUMBER); - const repository = process.env.REPOSITORY; - const triggerStatus = process.env.TRIGGER_STATUS || 'Human Review'; - const statusFieldName = process.env.STATUS_FIELD_NAME || 'Status'; + const [owner, repo] = process.env.REPOSITORY.split('/'); + const issueNumber = Number(process.env.ISSUE_NUMBER); const summaryMarker = process.env.SUMMARY_MARKER; - if (!Number.isInteger(projectNumber)) { - core.setFailed('harness-project-number must be an integer.'); + if (!Number.isInteger(issueNumber)) { + core.setFailed('issue-number must be an integer.'); return; } if (!summaryMarker) { @@ -109,81 +77,37 @@ runs: return; } - const eligible = []; - 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 { - number - title - url - state - repository { nameWithOwner } - comments(last: 100) { - nodes { body } - } - } - } - } - } - } - } - } - `, - { owner: projectOwner, number: projectNumber, statusField: statusFieldName, after } - ); - const project = page.organization && page.organization.projectV2; - if (!project) { - core.setFailed(`Project ${projectNumber} not found for organization "${projectOwner}" (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 !== triggerStatus || !issue || !issue.number || issue.state !== 'OPEN') continue; - if (issue.repository.nameWithOwner.toLowerCase() !== repository.toLowerCase()) continue; - if (issue.comments.nodes.some((comment) => comment.body.includes(summaryMarker))) continue; - eligible.push(issue); - } - if (!project.items.pageInfo.hasNextPage) break; - after = project.items.pageInfo.endCursor; - } + const issue = (await github.rest.issues.get({ owner, repo, issue_number: issueNumber })).data; - if (eligible.length === 0) { - core.info(`No eligible items in "${triggerStatus}" on project ${projectNumber}.`); + // 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('remaining_count', '0'); + core.setOutput('issue_url', issue.html_url); return; } - eligible.sort((a, b) => a.number - b.number); - const issue = eligible[0]; - core.info(`Summarizing ${issue.url} (${eligible.length - 1} more queued for subsequent runs).`); + core.info(`Summarizing ${issue.html_url}.`); core.setOutput('found', 'true'); - core.setOutput('issue_number', String(issue.number)); - core.setOutput('issue_url', issue.url); - core.setOutput('remaining_count', String(eligible.length - 1)); + core.setOutput('issue_number', String(issueNumber)); + core.setOutput('issue_url', issue.html_url); - name: Gather context and build prompt id: prepare - if: steps.scan.outputs.found == 'true' + if: steps.check.outputs.found == 'true' uses: actions/github-script@v8 env: ACTION_PATH: ${{ github.action_path }} REPOSITORY: ${{ inputs.repository }} - ISSUE_NUMBER: ${{ steps.scan.outputs.issue_number }} - ISSUE_URL: ${{ steps.scan.outputs.issue_url }} + 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 }} @@ -335,7 +259,7 @@ runs: - name: Generate summary id: run_codex - if: steps.scan.outputs.found == 'true' + if: steps.check.outputs.found == 'true' uses: openai/codex-action@v1 with: openai-api-key: ${{ inputs.openai-api-key }} @@ -345,28 +269,20 @@ runs: safety-strategy: ${{ inputs.codex-safety-strategy }} prompt: ${{ steps.prepare.outputs.prompt }} - - name: Post summary and open the acceptance gate + - name: Post summary id: post - if: steps.scan.outputs.found == 'true' + 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.scan.outputs.issue_number }} + ISSUE_NUMBER: ${{ steps.check.outputs.issue_number }} SUMMARY_MARKER: ${{ inputs.summary-marker }} - WORK_ORDER_PROJECT_OWNER: ${{ inputs.work-order-project-owner }} - WORK_ORDER_PROJECT_NUMBER: ${{ inputs.work-order-project-number }} - ACCEPTANCE_STATUS: ${{ inputs.acceptance-status }} - HARNESS_PROJECT_OWNER: ${{ inputs.harness-project-owner }} - STATUS_FIELD_NAME: ${{ inputs.status-field-name }} with: github-token: ${{ inputs.github-token }} script: | - // Banzai Codes treats the most recent comment without one of ITS OWN markers - // (banzai:meta / banzai:chat:*) as the candidate summary. Stray HTML comments - // from the model are stripped, then our idempotency marker is embedded in the - // summary comment itself — a separate marker comment posted afterwards would - // shadow the summary as the most recent candidate. + // 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.'); @@ -384,100 +300,3 @@ runs: core.info(`Posted candidate summary: ${summaryComment.data.html_url}`); core.setOutput('processed', 'true'); core.setOutput('summary_comment_url', summaryComment.data.html_url); - - // Open the acceptance gate: move the work order's Product-board status so the - // Banzai Codes review gate surfaces the summary that now exists. - const workOrderProjectNumber = Number(process.env.WORK_ORDER_PROJECT_NUMBER || ''); - if (!process.env.WORK_ORDER_PROJECT_NUMBER) { - core.info('work-order-project-number not set; leaving the Product board untouched.'); - return; - } - if (!Number.isInteger(workOrderProjectNumber)) { - core.setFailed('work-order-project-number must be an integer when set.'); - return; - } - const workOrderProjectOwner = process.env.WORK_ORDER_PROJECT_OWNER || process.env.HARNESS_PROJECT_OWNER; - const statusFieldName = process.env.STATUS_FIELD_NAME || 'Status'; - const acceptanceStatus = process.env.ACCEPTANCE_STATUS || 'Acceptance'; - - const projectData = await github.graphql( - ` - query($owner: String!, $number: Int!) { - organization(login: $owner) { - projectV2(number: $number) { - id - fields(first: 50) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { id name } - } - } - } - } - } - } - `, - { owner: workOrderProjectOwner, number: workOrderProjectNumber } - ); - const project = projectData.organization && projectData.organization.projectV2; - if (!project) { - core.setFailed(`Project ${workOrderProjectNumber} not found for organization "${workOrderProjectOwner}" (user-owned projects are not supported).`); - return; - } - const statusField = project.fields.nodes.find( - (field) => field && field.name === statusFieldName && Array.isArray(field.options) - ); - const statusOption = statusField && statusField.options.find((option) => option.name === acceptanceStatus); - if (!statusOption) { - core.setFailed(`Status option "${acceptanceStatus}" not found on work-order project ${workOrderProjectNumber}.`); - return; - } - - const itemData = await github.graphql( - ` - query($owner: String!, $repo: String!, $number: Int!, $statusField: String!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - projectItems(first: 50, includeArchived: true) { - nodes { - id - project { id } - fieldValueByName(name: $statusField) { - ... on ProjectV2ItemFieldSingleSelectValue { name } - } - } - } - } - } - } - `, - { owner, repo, number: issueNumber, statusField: statusFieldName } - ); - const item = itemData.repository.issue.projectItems.nodes.find( - (node) => node.project && node.project.id === project.id - ); - if (!item) { - core.setFailed(`Issue #${issueNumber} is not on work-order project ${workOrderProjectNumber}; cannot open the acceptance gate.`); - return; - } - 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: project.id, itemId: item.id, fieldId: statusField.id, optionId: statusOption.id } - ); - const previousStatus = (item.fieldValueByName && item.fieldValueByName.name) || '(none)'; - core.info(`Moved work-order status ${previousStatus} → "${acceptanceStatus}" on project ${workOrderProjectNumber}.`); From 58f5baab6e2ab7f447f6359a27f41b35b1a24cb8 Mon Sep 17 00:00:00 2001 From: Mads Frandsen <418210+madsbf@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:16:33 +0200 Subject: [PATCH 7/7] Revise summary writing guidelines in prompt.md Updated guidelines for writing summaries, emphasizing the use of markdown styles for readability and removing the section on how to review. --- banzai-codes/post-candidate-summary/prompt.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/banzai-codes/post-candidate-summary/prompt.md b/banzai-codes/post-candidate-summary/prompt.md index 798c4c7..02a4438 100644 --- a/banzai-codes/post-candidate-summary/prompt.md +++ b/banzai-codes/post-candidate-summary/prompt.md @@ -10,7 +10,9 @@ Hard rules: 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. + 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. @@ -26,8 +28,6 @@ Structure your reply exactly like this: 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. -4. A section `## How to review` with one or two sentences pointing the reader at the - pull request: {{PR_URL}} Respond with ONLY the summary markdown. Your entire reply is posted verbatim as a comment on the Product Manager's work-order issue.