diff --git a/.claude/learnings.md b/.claude/learnings.md index 74171ea..5a412e3 100644 --- a/.claude/learnings.md +++ b/.claude/learnings.md @@ -18,6 +18,9 @@ - Codex Stop-hook protocol accepts BOTH `{"decision":"block","reason":"..."}` (legacy) and `{"continue":true,"stopReason":"..."}` (new) on stdout with exit 0 to force the agent to take another turn — confirmed against developers.openai.com/codex/hooks. Either format works; the codebase uses the legacy one for parity with the Claude commit-guard. - `fixAndRetryPush` must dispatch the configured agent's CLI, not hardcode `claude`. When AGENT_KIND=codex the claude binary isn't installed and `|| true` swallows the failure, leaving the same broken HEAD to be force-pushed. +## Claude Code agent auth in sandbox +- The Claude Code CLI rejects OAuth tokens (`sk-ant-oat...`, issued by `claude setup-token`) when supplied via `ANTHROPIC_API_KEY` — that variable only accepts standard API keys (`sk-ant-api...`). OAuth tokens must be exported as `CLAUDE_CODE_OAUTH_TOKEN`. Commit `350a754` ("drop CLAUDE_CODE_OAUTH_TOKEN, require ANTHROPIC_API_KEY for Claude") consolidated to a single operator-facing var but lost the OAuth path; agent runs failed with "Invalid API key" at the research phase. Fix in `src/sandbox/agents/claude.ts:configure` — detect `sk-ant-oat` prefix and route the value to `CLAUDE_CODE_OAUTH_TOKEN` inside the sandbox, so the operator still pastes into a single `ANTHROPIC_API_KEY` env var. + ## E2E in GitHub Actions - `@vercel/sandbox` reads credentials from `process.env` — a GH secret is not enough; it must be mapped via the job's `env:` block (e.g. `VERCEL_OIDC_TOKEN: ${{ secrets.VERCEL_OIDC_TOKEN }}`). Prefer long-lived `VERCEL_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID` over OIDC — OIDC tokens expire in ~12h and the SDK's refresh path requires `.vercel/project.json`, which CI doesn't have. - Reconcile (`src/lib/reconcile.ts`) has a 30s `ORPHAN_GRACE_MS` window that skips entries younger than 30s. Any e2e test seeding a registry entry via `setEntry` and expecting reconcile to cancel it on the next cron tick must backdate the timestamp past the grace window (`setEntry(key, runId, { ageMs: 60_000 })`). Without backdating the test is racy — it only passes if Vercel's 1-min scheduled cron happens to fire at T>30s during the test's wait window. diff --git a/.claude/skills/init-agent/SKILL.md b/.claude/skills/init-agent/SKILL.md index 15f237f..8d11124 100644 --- a/.claude/skills/init-agent/SKILL.md +++ b/.claude/skills/init-agent/SKILL.md @@ -1,11 +1,11 @@ --- name: init-agent -description: Configure or rotate the agent runtime (Claude or Codex) for the Blazebot workflow. Branches on runtime choice and emits a single paste-template for the chosen kind. Defaults to API key with OAuth alternative documented in references. Use for "set up claude", "set up codex", "rotate anthropic key", "switch agent to codex", "configure agent runtime". +description: Configure or rotate the agent runtime (Claude or Codex) for the Blazebot workflow. Branches on runtime choice and emits a single paste-template for the chosen kind. Use for "set up claude", "set up codex", "rotate anthropic key", "switch agent to codex", "configure agent runtime". --- # Initialize Agent Runtime -Branch-on-choice skill. Asks **Claude or Codex**, then emits a single paste-template for the chosen runtime. Cross-field rule in `env.ts` (`AGENT_KIND=claude` requires `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`; `AGENT_KIND=codex` requires `CODEX_API_KEY` or `CODEX_CHATGPT_OAUTH_TOKEN`) is enforced by construction. +Branch-on-choice skill. Asks **Claude or Codex**, then emits a single paste-template for the chosen runtime. Cross-field rule in `env.ts` (`AGENT_KIND=claude` requires `ANTHROPIC_API_KEY`; `AGENT_KIND=codex` requires `CODEX_API_KEY` or `CODEX_CHATGPT_OAUTH_TOKEN`) is enforced by construction. > If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles the agent runtime. @@ -28,9 +28,9 @@ If switching from a previously-configured runtime, the user should also remove t ## Step 2 — Emit paste-template -### Claude branch (default API key, OAuth alternative) +### Claude branch -Walk the user through https://console.anthropic.com/settings/keys to create an API key. The default flow uses `ANTHROPIC_API_KEY`; OAuth via `CLAUDE_CODE_OAUTH_TOKEN` is documented in `references/oauth-alternative.md`. +Walk the user through https://console.anthropic.com/settings/keys to create an API key. Codex OAuth is documented in `references/oauth-alternative.md`. Collect: - `ANTHROPIC_API_KEY` (starts with `sk-ant-`) diff --git a/.claude/skills/init-agent/references/oauth-alternative.md b/.claude/skills/init-agent/references/oauth-alternative.md index 6488342..4061190 100644 --- a/.claude/skills/init-agent/references/oauth-alternative.md +++ b/.claude/skills/init-agent/references/oauth-alternative.md @@ -1,22 +1,12 @@ # OAuth alternative -Both Claude and Codex accept either an API key or an OAuth token. The default `init-agent` flow uses API keys because they're simpler — one secret, no expiration handling. Use OAuth when: +Codex accepts either an API key or an OAuth token. The default `init-agent` flow uses API keys because they're simpler — one secret, no expiration handling. Use OAuth when: - Your org issues OAuth tokens via SSO and you can't mint long-lived API keys. - You want the bot to act under a specific human's account (each request shows up under that account in usage logs). - You're on a managed plan that doesn't expose API key management. -## Claude — `CLAUDE_CODE_OAUTH_TOKEN` - -Replace the API_KEY line in the paste-template with: - -``` -CLAUDE_CODE_OAUTH_TOKEN= -``` - -The validator in `env.ts:130` accepts either `ANTHROPIC_API_KEY` *or* `CLAUDE_CODE_OAUTH_TOKEN`. Don't paste both — pick one. - -OAuth tokens are obtained via Claude Code's `claude login` flow (run locally, copy the token from the resulting credentials file). They expire — track expiry, set a calendar reminder. +> Claude only supports `ANTHROPIC_API_KEY` in this project — OAuth was removed. ## Codex — `CODEX_CHATGPT_OAUTH_TOKEN` diff --git a/.claude/skills/init-env/SKILL.md b/.claude/skills/init-env/SKILL.md index 75804f1..7f5f69d 100644 --- a/.claude/skills/init-env/SKILL.md +++ b/.claude/skills/init-env/SKILL.md @@ -196,7 +196,7 @@ pnpm tsx --env-file=.env.local env.ts The validator (`env.ts` via `@t3-oss/env-core`) catches: - Missing required keys. - URL/email/UUID format violations. -- Cross-field violations (`VCS_KIND=github` requires `GITHUB_TOKEN/OWNER/REPO`; `AGENT_KIND=claude` requires `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`; etc.). +- Cross-field violations (`VCS_KIND=github` requires `GITHUB_TOKEN/OWNER/REPO`; `AGENT_KIND=claude` requires `ANTHROPIC_API_KEY`; etc.). **On failure:** the validator prints `Invalid environment variables:` followed by the specific paths. Identify the responsible subskill from the path prefix (`JIRA_*` → init-jira; `GITHUB_*` / `GITLAB_*` → init-vcs; etc.) and direct the user to fix in the Vercel dashboard, then re-run this step. @@ -361,8 +361,10 @@ Skipped (see SETUP.md for the full how-to): - GitLab swap — SETUP.md §12. Flip VCS_KIND=gitlab and provide GITLAB_TOKEN + GITLAB_PROJECT_ID (+ GITLAB_HOST for self-hosted). - CI / GitHub Actions — SETUP.md §11. The `e2e` GitHub environment - needs the prod env vars plus E2E_BASE_URL, E2E_GITHUB_TOKEN/OWNER/ - REPO, and VERCEL_AUTOMATION_BYPASS_SECRET as secrets. + needs the prod env vars plus E2E_BASE_URL, E2E_GITHUB_APP_ID, + E2E_GITHUB_APP_PRIVATE_KEY (base64 PEM), E2E_GITHUB_INSTALLATION_ID, + E2E_GITHUB_OWNER, E2E_GITHUB_REPO, and VERCEL_AUTOMATION_BYPASS_SECRET + as secrets. - Custom domain — point a domain at the Vercel project for a stable webhook URL (then update Jira webhook + Slack request URLs). - WORKFLOW_POSTGRES_URL — local dev only (SETUP.md §6). diff --git a/.env.example b/.env.example index f11af98..343b006 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,12 @@ JIRA_WEBHOOK_SECRET= VCS_KIND=github # --- GitHub (active when VCS_KIND=github) --- -GITHUB_TOKEN=ghp_xxxxxxxxxxxx +# GitHub App auth — see docs/GITHUB-APP-SETUP.md for the registration walkthrough. +# GITHUB_APP_PRIVATE_KEY is base64-encoded PEM (so it round-trips cleanly through +# Vercel's env UI): `base64 -i app.private-key.pem | tr -d '\n'` +GITHUB_APP_ID=1234567 +GITHUB_APP_PRIVATE_KEY=base64-encoded-pem-contents +GITHUB_INSTALLATION_ID=98765432 GITHUB_OWNER=your-org GITHUB_REPO=your-repo GITHUB_BASE_BRANCH=main @@ -39,7 +44,6 @@ AGENT_KIND=claude # Claude (active when AGENT_KIND=claude — one of API_KEY or OAUTH_TOKEN required) ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx -# CLAUDE_CODE_OAUTH_TOKEN= CLAUDE_MODEL=claude-opus-4-6 # Codex (active when AGENT_KIND=codex — one of API_KEY or OAUTH_TOKEN required) @@ -49,8 +53,12 @@ CLAUDE_MODEL=claude-opus-4-6 # CODEX_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json # CODEX_PRICING_TTL_MS=3600000 -COMMIT_AUTHOR=ai-workflow-blazity -COMMIT_EMAIL=ai-workflow@blazity.com +# Optional commit-author override. Both must be set together. +# - GitHub: leave unset to author commits as the App's bot user (the GitHub UI +# then renders them with the App's avatar and the `[bot]` badge). +# - GitLab: defaults to ai-workflow-blazity / ai-workflow@blazity.com. +# COMMIT_AUTHOR= +# COMMIT_EMAIL= # Upstash Redis (run registry). # On Vercel: install the Upstash for Redis Marketplace integration with the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7c621b..9518151 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} @@ -93,7 +95,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} @@ -144,7 +148,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4b7719c..e7c4883 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,7 +36,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} @@ -90,7 +92,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} @@ -144,7 +148,9 @@ jobs: COLUMN_AI: ${{ secrets.COLUMN_AI }} COLUMN_AI_REVIEW: ${{ secrets.COLUMN_AI_REVIEW }} COLUMN_BACKLOG: ${{ secrets.COLUMN_BACKLOG }} - E2E_GITHUB_TOKEN: ${{ secrets.E2E_GITHUB_TOKEN }} + E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }} + E2E_GITHUB_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_APP_PRIVATE_KEY }} + E2E_GITHUB_INSTALLATION_ID: ${{ secrets.E2E_GITHUB_INSTALLATION_ID }} E2E_GITHUB_OWNER: ${{ secrets.E2E_GITHUB_OWNER }} E2E_GITHUB_REPO: ${{ secrets.E2E_GITHUB_REPO }} CRON_SECRET: ${{ secrets.CRON_SECRET }} diff --git a/README.md b/README.md index c805177..8e8abde 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,12 @@ Operators can drive workflows directly from Slack with `/ai-workflow list | stat ```bash ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx # Anthropic API key CLAUDE_MODEL=claude-opus-4-6 # Model to use (default: claude-opus-4-6) -COMMIT_AUTHOR=ai-workflow-blazity # Git commit author name -COMMIT_EMAIL=ai-workflow@blazity.com # Git commit author email +# COMMIT_AUTHOR= # Optional override (set with COMMIT_EMAIL). +# COMMIT_EMAIL= # On GitHub, leave unset to author commits as the App's bot. ``` +**GitHub App bot identity** — when `VCS_KIND=github` and both `COMMIT_AUTHOR` / `COMMIT_EMAIL` are unset, the workflow derives the identity from the configured GitHub App (`[bot]` + the `+[bot]@users.noreply.github.com` noreply address). GitHub then renders commits with the App's avatar and the `[bot]` badge in the UI. + **Switching agents** — ai workflow supports two CLI runtimes. Set `AGENT_KIND` once per deployment: ```bash @@ -275,15 +277,14 @@ curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/cron/poll | **Agent** | | | | | `AGENT_KIND` | No | `claude` | Runtime: `claude` or `codex` | | `ANTHROPIC_API_KEY` | Yes‡ | — | Anthropic API key (required when `AGENT_KIND=claude`) | -| `CLAUDE_CODE_OAUTH_TOKEN` | No | — | Alternative to `ANTHROPIC_API_KEY` | | `CLAUDE_MODEL` | No | `claude-opus-4-6` | Claude model ID | | `CODEX_API_KEY` | Yes‡ | — | OpenAI Codex API key (required when `AGENT_KIND=codex`) | | `CODEX_CHATGPT_OAUTH_TOKEN` | No | — | Alternative to `CODEX_API_KEY` | | `CODEX_MODEL` | No | `gpt-5-codex` | Codex model ID | | `CODEX_PRICING_URL` | No | LiteLLM JSON | Pricing source for Codex cost reporting | | `CODEX_PRICING_TTL_MS` | No | `3600000` | Pricing cache TTL (ms) | -| `COMMIT_AUTHOR` | No | `ai-workflow-blazity` | Git author name | -| `COMMIT_EMAIL` | No | `ai-workflow@blazity.com` | Git author email | +| `COMMIT_AUTHOR` | No | _GitHub: App bot / GitLab: `ai-workflow-blazity`_ | Git author name (override; pair with `COMMIT_EMAIL`) | +| `COMMIT_EMAIL` | No | _GitHub: App bot / GitLab: `ai-workflow@blazity.com`_ | Git author email (override; pair with `COMMIT_AUTHOR`) | | **Sandbox** | | | | | `MAX_CONCURRENT_AGENTS` | No | `3` | Max parallel sandboxes | | `JOB_TIMEOUT_MS` | No | `1800000` | Agent timeout (ms) | @@ -399,11 +400,11 @@ Each agent run gets a fresh, isolated [Vercel Sandbox](https://vercel.com/docs/s | Input | How it's provided | |-------|-------------------| | Repository source code | Cloned via `git` source at the feature branch (shallow `depth=1`); unshallowed before push if needed | -| Auth env vars | `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` (Claude) or `CODEX_API_KEY` / `CODEX_CHATGPT_OAUTH_TOKEN` (Codex) — written to `/tmp/agent-env.sh` (mode 0600) and sourced by each phase script | +| Auth env vars | `ANTHROPIC_API_KEY` (Claude) or `CODEX_API_KEY` / `CODEX_CHATGPT_OAUTH_TOKEN` (Codex) — written to `/tmp/agent-env.sh` (mode 0600) and sourced by each phase script | | Model | `CLAUDE_MODEL` or `CODEX_MODEL` baked into the phase wrapper script | | Per-phase input | `/tmp/research-requirements.md` and `/tmp/impl-requirements.md` — assembled by `assembleResearchPlanContext` / `assembleImplementationContext` | | Attachments | Written to `/tmp/attachments/` | -| Git identity | `git config user.name` / `user.email` from `COMMIT_AUTHOR` / `COMMIT_EMAIL` | +| Git identity | `git config user.name` / `user.email` from `COMMIT_AUTHOR` / `COMMIT_EMAIL` (or auto-derived from the GitHub App when unset) | | Agent CLI | `@anthropic-ai/claude-code` (Claude) or `@openai/codex` (Codex), installed globally | | Skills | Installed via `npx skills add ... -g --agent claude-code codex --copy` to **both** `~/.claude/skills/` and `~/.agents/skills/`. Currently only [`frontend-design`](https://github.com/anthropics/skills) is in `GLOBAL_SKILLS` | | Arthur tracer (optional) | Python tracer + `~/.claude/arthur_config.json` + hook entries in `~/.claude/settings.json` | diff --git a/SETUP.md b/SETUP.md index 9a50714..34085c4 100644 --- a/SETUP.md +++ b/SETUP.md @@ -123,7 +123,7 @@ ai-workflow uses Upstash Redis as its run registry (atomic claim/release for con 1. Open https://vercel.com/marketplace/upstash and click **Install**. 2. Pick the team and project you just linked. -3. **Critical:** when prompted for the env-var prefix, set it to `AI_WORKFLOW_KV`. The code reads `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` — wrong prefix means ai-workflow can't find the registry. +3. **Critical:** when prompted for the env-var prefix, set it to `AI_WORKFLOW` (not `AI_WORKFLOW_KV`). Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`, so the resulting vars are `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` — which is what the code reads. Wrong prefix means ai-workflow can't find the registry. 4. Vercel auto-injects both vars into Production, Preview, and Development environments. Verify: @@ -160,28 +160,27 @@ vercel env add JIRA_API_TOKEN production | `VCS_KIND` | `github` or `gitlab` | | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO` | If `VCS_KIND=github` | | `GITLAB_TOKEN`, `GITLAB_PROJECT_ID` | If `VCS_KIND=gitlab` | -| `CHAT_SDK_SLACK_TOKEN`, `CHAT_SDK_CHANNEL_ID` | Slack bot | -| `SLACK_SIGNING_SECRET` | Verifies `/ai-workflow` slash commands | -| `AGENT_KIND` | `claude` (default) or `codex` | -| `ANTHROPIC_API_KEY` | If `AGENT_KIND=claude` | -| `CODEX_API_KEY` | If `AGENT_KIND=codex` | +| `ANTHROPIC_API_KEY` | If `AGENT_KIND=claude` (default) | +| `CODEX_API_KEY` (or `CODEX_CHATGPT_OAUTH_TOKEN`) | If `AGENT_KIND=codex` | | `AI_WORKFLOW_KV_REST_API_URL`, `AI_WORKFLOW_KV_REST_API_TOKEN` | Auto-injected by Upstash integration | -| `CRON_SECRET` | Generate: `openssl rand -hex 32`. Required so `/cron/poll` rejects unauthenticated callers. | -| `JIRA_WEBHOOK_SECRET` | Generate: `openssl rand -hex 32`. Strongly recommended — without it, dispatch is cron-bound. | ### Optional / has defaults | Variable | Default | Purpose | |----------|---------|---------| | `GITHUB_BASE_BRANCH` | `main` | PR target branch | +| `CHAT_SDK_SLACK_TOKEN`, `CHAT_SDK_CHANNEL_ID` | unset | Slack bot. When unset, runs proceed silently (no notifications). | | `CHAT_SDK_BOT_NAME` | `blazebot` | Slack display name | +| `SLACK_SIGNING_SECRET` | unset | Required only if you register the `/ai-workflow` slash command. When unset, `/webhooks/slack` rejects all requests. | | `SLACK_ALLOWED_USER_IDS` | empty (anyone) | Comma-separated user IDs allowed to run slash commands | +| `CRON_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, `/cron/poll` accepts unauthenticated callers — strongly recommended in production. | +| `JIRA_WEBHOOK_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, dispatch is cron-bound (1-min latency). | | `CLAUDE_MODEL` | `claude-opus-4-6` | Anthropic model | | `CODEX_MODEL` | `gpt-5-codex` | Codex model | | `MAX_CONCURRENT_AGENTS` | `3` | Parallel sandbox cap | | `JOB_TIMEOUT_MS` | `1800000` (30 min) | Per-run timeout | | `POLL_INTERVAL_MS` | `300000` (5 min) | Internal poll cadence | -| `COMMIT_AUTHOR`, `COMMIT_EMAIL` | `ai-workflow-blazity`, `ai-workflow@blazity.com` | Git identity inside sandboxes | +| `COMMIT_AUTHOR`, `COMMIT_EMAIL` | _unset_ on GitHub → auto-derived from the App (commits author as `[bot]`); GitLab falls back to `ai-workflow-blazity` / `ai-workflow@blazity.com` | Optional override; set both or neither | `env.ts` cross-validates at startup — missing required vars or wrong combinations (e.g. `VCS_KIND=github` without `GITHUB_OWNER`) crash the process with a precise error. @@ -327,7 +326,7 @@ Two workflows ship in `.github/workflows/`: - **capacity** — concurrency, claim/release, reconciler (30 min, gated on orchestration). - **agent** — full ticket → PR run against real Jira + GitHub (120 min, gated on capacity). -The E2E jobs need the production env vars exposed as GitHub Actions secrets in the `e2e` environment (Repo Settings → Environments → e2e → Secrets). They additionally require `E2E_BASE_URL`, `E2E_GITHUB_TOKEN`, `E2E_GITHUB_OWNER`, `E2E_GITHUB_REPO`, and `VERCEL_AUTOMATION_BYPASS_SECRET`. +The E2E jobs need the production env vars exposed as GitHub Actions secrets in the `e2e` environment (Repo Settings → Environments → e2e → Secrets). They additionally require `E2E_BASE_URL`, `E2E_GITHUB_APP_ID`, `E2E_GITHUB_APP_PRIVATE_KEY` (base64-encoded PEM), `E2E_GITHUB_INSTALLATION_ID`, `E2E_GITHUB_OWNER`, `E2E_GITHUB_REPO`, and `VERCEL_AUTOMATION_BYPASS_SECRET`. --- @@ -364,7 +363,7 @@ Flip `VCS_KIND=gitlab` and provide `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`. For sel | `/cron/poll` returns 401 from Vercel Cron | `CRON_SECRET` mismatch | Ensure the var is set in Production environment. Redeploy after changing. | | Tickets in AI column never get picked up | Cron disabled / webhook misregistered | Check **Vercel → Project → Cron Jobs** is enabled. Curl `/cron/poll` with the secret to test manually. | | Workflow starts but sandbox fails to provision | Missing Vercel OIDC / Sandbox quota | On Vercel, OIDC is automatic. Check the project has Sandbox enabled (Pro plan). For local dev, set `VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`. | -| Run registry: `AI_WORKFLOW_KV_REST_API_URL undefined` | Upstash integration installed with wrong prefix | Reinstall with prefix `AI_WORKFLOW_KV`. | +| Run registry: `AI_WORKFLOW_KV_REST_API_URL undefined` | Upstash integration installed with wrong prefix | Reinstall with prefix `AI_WORKFLOW` (Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`). | | Agent runs but PR isn't created | `GITHUB_TOKEN` lacks `repo` scope, or wrong owner/repo | Re-create the PAT with `repo` scope. Verify `GITHUB_OWNER`/`GITHUB_REPO` point at the *target* repo, not this repo. | | Slack messages don't arrive | Bot not in channel, or wrong `CHAT_SDK_CHANNEL_ID` | Invite bot to the channel. Re-copy the channel ID. | | Slash command returns `dispatch_failed` | Signing secret wrong, or app not reinstalled | Verify `SLACK_SIGNING_SECRET`. Reinstall the Slack app after adding the slash command. | diff --git a/docs/GITHUB-APP-SETUP.md b/docs/GITHUB-APP-SETUP.md new file mode 100644 index 0000000..35f5dca --- /dev/null +++ b/docs/GITHUB-APP-SETUP.md @@ -0,0 +1,214 @@ +# GitHub App setup + +Step-by-step guide for registering a GitHub App for the ai-workflow bot and collecting the four env vars the deployment needs. + +This replaces the previous "personal access token" setup. The App is **organization-owned** so it survives the creator leaving the org. + +--- + +## What you'll end up with + +Six values to set on the Vercel deployment: + +``` +VCS_KIND=github +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= +GITHUB_INSTALLATION_ID= +GITHUB_OWNER= +GITHUB_REPO= +``` + +--- + +## 1. Open the org's developer settings + +Navigate to: + +``` +https://github.com/organizations//settings/apps +``` + +Or click through: **org page → Settings → Developer settings → GitHub Apps**. + +Click **"New GitHub App"**. + +## 2. Fill in basics + +| Field | Value | +|---|---| +| GitHub App name | `blazity-ai-workflow` (must be globally unique on github.com) | +| Homepage URL | Your Vercel deployment URL, or any valid URL | +| Description | Optional | + +## 3. Disable webhooks + +The bot does not receive webhooks. + +- **Webhook → Active** → **uncheck** + +The webhook URL field can be left blank once Active is unchecked. + +## 4. Set permissions + +### Repository permissions + +| Permission | Level | Why | +|---|---|---| +| Contents | Read & write | Clone the repo, push commits | +| Pull requests | Read & write | Create PRs, fetch PR data | +| Issues | Read & write | PR review comments live on the issues API | +| Checks | Read-only | Read CI check results | +| Actions | Read-only | Read workflow run status | +| Metadata | Read-only | Mandatory, auto-included | + +Leave all other repository permissions on **No access**. + +### Organization permissions + +Leave everything on **No access**. + +### Account permissions + +Leave everything on **No access**. + +## 5. Choose installation scope + +**Where can this GitHub App be installed?** + +- **Only on this account** — restricts to your org. Pick this for the single-tenant-per-deployment model (one Vercel deployment serves one org). +- **Any account** — only needed if external clients self-install. Not the default. + +Click **"Create GitHub App"**. + +## 6. Generate a private key + +On the new App's page, scroll down to **"Private keys"** → click **"Generate a private key"**. + +GitHub downloads a file like `blazity-ai-workflow.2026-05-07.private-key.pem`. + +> Save this file. GitHub never shows it again. If lost, generate a new key from this page and revoke the old one. + +## 7. Note the App ID + +At the top of the App settings page: + +``` +App ID: 1234567 +``` + +That number is your `GITHUB_APP_ID`. + +## 8. Install the App on the target repo + +On the App's page, click **"Install App"** in the left sidebar. + +1. Click **"Install"** next to the org that owns the target repo. +2. Choose **"Only select repositories"**. +3. Pick the repo(s) the bot will operate on. +4. Click **"Install"**. + +## 9. Get the Installation ID + +The Installation ID is a numeric identifier that scopes the App to one specific +install (org-or-user × selected repos). It's distinct from the App ID. + +Pick whichever of the three paths below matches your situation. + +### Path A — right after installing (easiest) + +Immediately after step 8, GitHub redirects you to the configuration page. Copy +the trailing number from the browser URL: + +``` +# Org install: +https://github.com/organizations//settings/installations/ + ^^^^^^^^^^^^^^^^^ + +# Personal-account install: +https://github.com/settings/installations/ + ^^^^^^^^^^^^^^^^^ +``` + +### Path B — finding it later (org install) + +1. Go to your org's page on github.com. +2. Click **Settings** (top tab). +3. In the left sidebar: **Integrations → GitHub Apps**. +4. Find your App in the list and click **Configure**. +5. The browser URL is now the same as Path A — grab the trailing number. + +You need org-owner or app-manager permissions to see this page. + +### Path C — finding it later (personal-account install) + +1. Click your avatar (top right) → **Settings**. +2. Left sidebar: **Applications → Installed GitHub Apps**. +3. Click **Configure** next to your App. +4. The trailing number in the URL is your Installation ID. + +### Path D — programmatically (sanity check) + +If you have the App ID and private key but want to confirm the Installation ID, +list installations from the App's identity: + +```bash +# Generate an App JWT (10-min lifetime), then: +curl -H "Authorization: Bearer $APP_JWT" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/app/installations +``` + +Each entry has an `id` field — that's the Installation ID. If your App is +installed in multiple places (e.g. several orgs), you'll see one entry per +install; pick the one whose `account.login` matches `GITHUB_OWNER`. + +> Each org/user that installs the App gets its **own** Installation ID. If you +> later install the App on a second org, that's a new ID — don't reuse the old +> one. + +## 10. Base64-encode the private key + +The deployment expects the PEM as a single-line base64 string (multi-line PEM does not round-trip cleanly through Vercel's env UI): + +```bash +base64 -i blazity-ai-workflow.2026-05-07.private-key.pem | tr -d '\n' | pbcopy +``` + +The clipboard now holds your `GITHUB_APP_PRIVATE_KEY`. + +> **macOS note:** `base64 -i` works on macOS. On Linux use `base64 -w 0 < `. + +## 11. Set the env vars on Vercel + +``` +GITHUB_APP_ID=1234567 +GITHUB_APP_PRIVATE_KEY= +GITHUB_INSTALLATION_ID=98765432 +GITHUB_OWNER= +GITHUB_REPO= +VCS_KIND=github +``` + +Set them in **Vercel → project → Settings → Environment Variables** for the appropriate environments (Production / Preview / Development as needed). + +## 12. Redeploy + +The bot validates env vars at startup. After setting the values, trigger a redeploy so the new env is loaded. + +--- + +## Rotating the private key + +1. Generate a new key from the App settings page (step 6). +2. Update `GITHUB_APP_PRIVATE_KEY` on Vercel with the base64 of the new `.pem`. +3. Redeploy. +4. **Then** revoke the old key from the App settings page. + +Order matters — revoke last, otherwise the running deployment loses auth before the new key is live. + +## Removing the App from a repo + +Org **Settings → Integrations → GitHub Apps → Configure** → uncheck the repo or click **Uninstall**. + +The deployment will start failing on the next workflow run because installation tokens are scoped to installed repos. diff --git a/docs/ON-PREM-AWS.md b/docs/ON-PREM-AWS.md index 8645fd9..e376737 100644 --- a/docs/ON-PREM-AWS.md +++ b/docs/ON-PREM-AWS.md @@ -90,7 +90,7 @@ Rebuild the image when Claude Code or skills are updated. Skills installation (` ### Container Lifecycle -Each agent run is one Fargate task. Configuration (branch, requirements, model) passed as ECS task environment variables at launch time. Secrets (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `GITHUB_TOKEN`) are stored in AWS Secrets Manager and injected via `secrets`/`valueFrom` in the task definition — never as plaintext `environment` entries. +Each agent run is one Fargate task. Configuration (branch, requirements, model) passed as ECS task environment variables at launch time. Secrets (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`) are stored in AWS Secrets Manager and injected via `secrets`/`valueFrom` in the task definition — never as plaintext `environment` entries. The container only runs Claude Code — it does not push to GitHub, move Jira tickets, or communicate results. The Nitro server handles all of that after the container finishes. @@ -346,7 +346,7 @@ Agent containers are immutable — new tasks always pull the latest image tag. | `ISSUE_TRACKER_KIND`, `JIRA_*` | Jira connection | | `VCS_KIND`, `GITHUB_*` | GitHub connection | | `CHAT_SDK_*` | Slack messaging | -| `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code auth (via Secrets Manager `valueFrom`) | +| `ANTHROPIC_API_KEY` | Claude Code auth (via Secrets Manager `valueFrom`) | | `CLAUDE_MODEL` | Model selection | | `COMMIT_AUTHOR`, `COMMIT_EMAIL` | Git identity for agent commits | | `MAX_CONCURRENT_AGENTS` | Concurrency limit (default: 100) | diff --git a/e2e/env.ts b/e2e/env.ts index ec4ab86..fd7f841 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -12,7 +12,14 @@ const schema = z.object({ COLUMN_AI_REVIEW: z.string().min(1), COLUMN_BACKLOG: z.string().min(1), - E2E_GITHUB_TOKEN: z.string().min(1), + /** + * GitHub App credentials used by the e2e helpers (and us02 sandbox clone). + * Mirrors the deployed app's GITHUB_APP_* shape so the same auth code path + * is exercised end-to-end. Private key is base64-encoded PEM. + */ + E2E_GITHUB_APP_ID: z.coerce.number().int().positive(), + E2E_GITHUB_APP_PRIVATE_KEY: z.string().min(1), + E2E_GITHUB_INSTALLATION_ID: z.coerce.number().int().positive(), E2E_GITHUB_OWNER: z.string().min(1), E2E_GITHUB_REPO: z.string().min(1), diff --git a/e2e/helpers/github.ts b/e2e/helpers/github.ts index d10410d..845c0fd 100644 --- a/e2e/helpers/github.ts +++ b/e2e/helpers/github.ts @@ -1,8 +1,14 @@ +import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; import { e2eEnv } from "../env.js"; const octokit = new Octokit({ - auth: e2eEnv.E2E_GITHUB_TOKEN, + authStrategy: createAppAuth, + auth: { + appId: e2eEnv.E2E_GITHUB_APP_ID, + privateKey: Buffer.from(e2eEnv.E2E_GITHUB_APP_PRIVATE_KEY, "base64").toString("utf8"), + installationId: e2eEnv.E2E_GITHUB_INSTALLATION_ID, + }, log: { debug: () => {}, info: () => {}, diff --git a/e2e/tier2/us02-attachments.test.ts b/e2e/tier2/us02-attachments.test.ts index b21bf68..0c0ea96 100644 --- a/e2e/tier2/us02-attachments.test.ts +++ b/e2e/tier2/us02-attachments.test.ts @@ -108,13 +108,19 @@ describe("US-02: Ticket with attachments (real pipeline)", () => { const { getSandboxCredentials } = await import( "../../src/sandbox/credentials.js" ); + const { mintInstallationToken } = await import("../../src/lib/github-auth.js"); + const installationToken = await mintInstallationToken({ + appId: e2eEnv.E2E_GITHUB_APP_ID, + privateKeyBase64: e2eEnv.E2E_GITHUB_APP_PRIVATE_KEY, + installationId: e2eEnv.E2E_GITHUB_INSTALLATION_ID, + }); const sbx = await Sandbox.create({ ...getSandboxCredentials(), source: { type: "git", url: `https://github.com/${e2eEnv.E2E_GITHUB_OWNER}/${e2eEnv.E2E_GITHUB_REPO}.git`, username: "x-access-token", - password: e2eEnv.E2E_GITHUB_TOKEN, + password: installationToken, revision: "main", depth: 1, }, diff --git a/env.test.ts b/env.test.ts index 7768261..824a7bd 100644 --- a/env.test.ts +++ b/env.test.ts @@ -11,7 +11,10 @@ describe("env", () => { COLUMN_AI_REVIEW: "AI Review", COLUMN_BACKLOG: "Backlog", VCS_KIND: "github", - GITHUB_TOKEN: "ghp_test", + GITHUB_APP_ID: "123456", + // base64 of: -----BEGIN PRIVATE KEY-----\nFAKE\n-----END PRIVATE KEY-----\n + GITHUB_APP_PRIVATE_KEY: "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCkZBS0UKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=", + GITHUB_INSTALLATION_ID: "789012", GITHUB_OWNER: "test-org", GITHUB_REPO: "test-repo", GITHUB_BASE_BRANCH: "main", @@ -21,8 +24,6 @@ describe("env", () => { SLACK_SIGNING_SECRET: "fake-signing-secret", ANTHROPIC_API_KEY: "sk-ant-test", CLAUDE_MODEL: "claude-opus-4-6", - COMMIT_AUTHOR: "ai-workflow-blazity", - COMMIT_EMAIL: "bot@blazity.com", MAX_CONCURRENT_AGENTS: "3", JOB_TIMEOUT_MS: "1800000", AI_WORKFLOW_KV_REST_API_URL: "https://fake.upstash.io", @@ -50,12 +51,21 @@ describe("env", () => { it("uses defaults for optional fields", async () => { const partial = { ...VALID_ENV }; - delete (partial as any).COMMIT_AUTHOR; delete (partial as any).MAX_CONCURRENT_AGENTS; Object.assign(process.env, partial); const { env } = await import("./env.js"); - expect(env.COMMIT_AUTHOR).toBe("ai-workflow-blazity"); expect(env.MAX_CONCURRENT_AGENTS).toBe(3); + // COMMIT_AUTHOR/EMAIL are optional with no defaults — provisionSandbox + // derives the bot identity from the GitHub App when both are unset. + expect(env.COMMIT_AUTHOR).toBeUndefined(); + expect(env.COMMIT_EMAIL).toBeUndefined(); + }); + + it("throws when only one of COMMIT_AUTHOR / COMMIT_EMAIL is set", async () => { + Object.assign(process.env, { ...VALID_ENV, COMMIT_AUTHOR: "custom-bot" }); + await expect(async () => { + await import("./env.js"); + }).rejects.toThrow("COMMIT_AUTHOR and COMMIT_EMAIL must be set together"); }); it("throws on missing required field", async () => { @@ -70,7 +80,9 @@ describe("env", () => { it("parses valid GitLab env", async () => { const gitlabEnv = { ...VALID_ENV }; gitlabEnv.VCS_KIND = "gitlab"; - delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_APP_ID; + delete (gitlabEnv as any).GITHUB_APP_PRIVATE_KEY; + delete (gitlabEnv as any).GITHUB_INSTALLATION_ID; delete (gitlabEnv as any).GITHUB_OWNER; delete (gitlabEnv as any).GITHUB_REPO; delete (gitlabEnv as any).GITHUB_BASE_BRANCH; @@ -91,9 +103,12 @@ describe("env", () => { it("honors GITLAB_HOST for self-hosted instances", async () => { const gitlabEnv = { ...VALID_ENV }; gitlabEnv.VCS_KIND = "gitlab"; - delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_APP_ID; + delete (gitlabEnv as any).GITHUB_APP_PRIVATE_KEY; + delete (gitlabEnv as any).GITHUB_INSTALLATION_ID; delete (gitlabEnv as any).GITHUB_OWNER; delete (gitlabEnv as any).GITHUB_REPO; + delete (gitlabEnv as any).GITHUB_BASE_BRANCH; (gitlabEnv as any).GITLAB_TOKEN = "glpat-test"; (gitlabEnv as any).GITLAB_PROJECT_ID = "group/repo"; (gitlabEnv as any).GITLAB_HOST = "https://gitlab.example.com"; @@ -106,9 +121,12 @@ describe("env", () => { it("throws at startup when VCS_KIND=gitlab but GitLab vars missing", async () => { const gitlabEnv = { ...VALID_ENV }; gitlabEnv.VCS_KIND = "gitlab"; - delete (gitlabEnv as any).GITHUB_TOKEN; + delete (gitlabEnv as any).GITHUB_APP_ID; + delete (gitlabEnv as any).GITHUB_APP_PRIVATE_KEY; + delete (gitlabEnv as any).GITHUB_INSTALLATION_ID; delete (gitlabEnv as any).GITHUB_OWNER; delete (gitlabEnv as any).GITHUB_REPO; + delete (gitlabEnv as any).GITHUB_BASE_BRANCH; Object.assign(process.env, gitlabEnv); // Fail-fast: module import itself must throw before any workflow runs. @@ -117,22 +135,29 @@ describe("env", () => { }).rejects.toThrow("VCS_KIND=gitlab requires GITLAB_TOKEN and GITLAB_PROJECT_ID"); }); - it("throws at startup when VCS_KIND=github but GitHub vars missing", async () => { + it("throws at startup when VCS_KIND=github but GitHub App vars missing", async () => { const partial = { ...VALID_ENV }; - delete (partial as any).GITHUB_TOKEN; + delete (partial as any).GITHUB_APP_ID; Object.assign(process.env, partial); await expect(async () => { await import("./env.js"); - }).rejects.toThrow("VCS_KIND=github requires GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO"); + }).rejects.toThrow( + "VCS_KIND=github requires GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_INSTALLATION_ID, GITHUB_OWNER, and GITHUB_REPO", + ); }); - it("getVcsConfig returns GitHub config", async () => { + it("getVcsConfig returns GitHub App config", async () => { Object.assign(process.env, VALID_ENV); const { getVcsConfig } = await import("./env.js"); const vcs = getVcsConfig(); expect(vcs.kind).toBe("github"); - expect(vcs.token).toBe("ghp_test"); + if (vcs.kind !== "github") throw new Error("expected github"); + expect(vcs.auth.appId).toBe(123456); + expect(vcs.auth.installationId).toBe(789012); + expect(vcs.auth.privateKeyBase64).toBe( + "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCkZBS0UKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=", + ); expect(vcs.repoPath).toBe("test-org/test-repo"); expect(vcs.baseBranch).toBe("main"); expect(vcs.host).toBe("https://github.com"); diff --git a/env.ts b/env.ts index 288faa8..51516cd 100644 --- a/env.ts +++ b/env.ts @@ -1,5 +1,6 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; +import type { GitHubAppAuth } from "./src/lib/github-auth.js"; export const env = createEnv({ onValidationError: (issues) => { @@ -22,7 +23,11 @@ export const env = createEnv({ // VCS VCS_KIND: z.enum(["github", "gitlab"]), - GITHUB_TOKEN: z.string().min(1).optional(), + // GitHub VCS — App auth (no PAT). Private key is base64-encoded PEM so it + // round-trips cleanly through the Vercel env UI without newline-escaping. + GITHUB_APP_ID: z.coerce.number().int().positive().optional(), + GITHUB_APP_PRIVATE_KEY: z.string().min(1).optional(), + GITHUB_INSTALLATION_ID: z.coerce.number().int().positive().optional(), GITHUB_OWNER: z.string().min(1).optional(), GITHUB_REPO: z.string().min(1).optional(), GITHUB_BASE_BRANCH: z.string().default("main"), @@ -34,22 +39,28 @@ export const env = createEnv({ /** Base URL for self-hosted GitLab. Defaults to https://gitlab.com. */ GITLAB_HOST: z.string().url().default("https://gitlab.com"), - // Messaging - CHAT_SDK_SLACK_TOKEN: z.string().min(1), - CHAT_SDK_CHANNEL_ID: z.string().min(1), + // Messaging — Slack is optional. When token+channel are unset, a no-op + // messaging adapter is used and workflow runs proceed silently. + CHAT_SDK_SLACK_TOKEN: z.string().min(1).optional(), + CHAT_SDK_CHANNEL_ID: z.string().min(1).optional(), CHAT_SDK_BOT_NAME: z.string().default("blazebot"), - // Slack slash commands - SLACK_SIGNING_SECRET: z.string().min(1), + // Slack slash commands — required only if you register the /ai-workflow + // slash command. When unset, /webhooks/slack rejects all requests. + SLACK_SIGNING_SECRET: z.string().min(1).optional(), /** Comma-separated list of Slack user IDs allowed to invoke slash commands. Empty = anyone. */ SLACK_ALLOWED_USER_IDS: z.string().optional(), // Agent ANTHROPIC_API_KEY: z.string().min(1).optional(), - CLAUDE_CODE_OAUTH_TOKEN: z.string().min(1).optional(), CLAUDE_MODEL: z.string().default("claude-opus-4-6"), - COMMIT_AUTHOR: z.string().default("ai-workflow-blazity"), - COMMIT_EMAIL: z.string().default("ai-workflow@blazity.com"), + // Optional overrides for the git identity used inside the sandbox. + // - GitHub: when both are unset, the identity is derived from the App so + // commits render with the App's avatar and the `[bot]` badge in the UI. + // - GitLab: defaults to `ai-workflow-blazity` / `ai-workflow@blazity.com`. + // Both must be set together to take effect; setting only one is an error. + COMMIT_AUTHOR: z.string().min(1).optional(), + COMMIT_EMAIL: z.string().min(1).optional(), // Agent kind selection (claude | codex). Defaults to claude for back-compat. AGENT_KIND: z.enum(["claude", "codex"]).default("claude"), @@ -87,6 +98,13 @@ export const env = createEnv({ // Polling POLL_INTERVAL_MS: z.coerce.number().int().positive().default(300_000), + // Phase 3 (Review) — agent self-reviews diff and fixes issues before push. + // Off by default so existing deployments keep current two-phase behavior. + ENABLE_REVIEW_PHASE: z + .enum(["true", "false"]) + .default("false") + .transform((v) => v === "true"), + // Vercel (optional — auto via OIDC on Vercel) VERCEL_TOKEN: z.string().min(1).optional(), VERCEL_TEAM_ID: z.string().min(1).optional(), @@ -119,39 +137,66 @@ export const env = createEnv({ ); } } else if (env.VCS_KIND === "github") { - if (!env.GITHUB_TOKEN || !env.GITHUB_OWNER || !env.GITHUB_REPO) { + if ( + !env.GITHUB_APP_ID || + !env.GITHUB_APP_PRIVATE_KEY || + !env.GITHUB_INSTALLATION_ID || + !env.GITHUB_OWNER || + !env.GITHUB_REPO + ) { throw new Error( "Invalid environment variables:\n" + - " VCS_KIND=github requires GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO", + " VCS_KIND=github requires GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_INSTALLATION_ID, GITHUB_OWNER, and GITHUB_REPO", ); } } + if ( + (env.COMMIT_AUTHOR && !env.COMMIT_EMAIL) || + (!env.COMMIT_AUTHOR && env.COMMIT_EMAIL) + ) { + throw new Error( + "Invalid environment variables:\n" + + " COMMIT_AUTHOR and COMMIT_EMAIL must be set together (or both omitted to auto-derive on GitHub)", + ); + } if (env.AGENT_KIND === "codex" && !env.CODEX_API_KEY && !env.CODEX_CHATGPT_OAUTH_TOKEN) { throw new Error( "Invalid environment variables:\n" + " AGENT_KIND=codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN", ); } - if (env.AGENT_KIND === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + if (env.AGENT_KIND === "claude" && !env.ANTHROPIC_API_KEY) { throw new Error( "Invalid environment variables:\n" + - " AGENT_KIND=claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", + " AGENT_KIND=claude requires ANTHROPIC_API_KEY", ); } } export type Env = typeof env; -export interface VcsConfig { - kind: "github" | "gitlab"; - token: string; - repoPath: string; - baseBranch: string; - /** Base URL for the VCS host (e.g. https://gitlab.example.com or https://github.com). */ - host: string; -} +/** + * VCS config — discriminated on `kind`. + * GitHub auth is App-based (mints short-lived installation tokens on demand). + * GitLab auth is a static PAT (no App equivalent in this codebase). + */ +export type VcsConfig = + | { + kind: "github"; + auth: GitHubAppAuth; + repoPath: string; + baseBranch: string; + host: string; + } + | { + kind: "gitlab"; + token: string; + repoPath: string; + baseBranch: string; + host: string; + }; -/** Resolve VCS credentials from env. Throws if required vars are missing for the active VCS_KIND. */ +/** Resolve VCS config from env. Throws if required vars are missing for the active VCS_KIND. */ export function getVcsConfig(): VcsConfig { if (env.VCS_KIND === "gitlab") { if (!env.GITLAB_TOKEN || !env.GITLAB_PROJECT_ID) { @@ -165,14 +210,48 @@ export function getVcsConfig(): VcsConfig { host: env.GITLAB_HOST, }; } - if (!env.GITHUB_TOKEN || !env.GITHUB_OWNER || !env.GITHUB_REPO) { - throw new Error("GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO are required when VCS_KIND=github"); + if ( + !env.GITHUB_APP_ID || + !env.GITHUB_APP_PRIVATE_KEY || + !env.GITHUB_INSTALLATION_ID || + !env.GITHUB_OWNER || + !env.GITHUB_REPO + ) { + throw new Error( + "GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_INSTALLATION_ID, GITHUB_OWNER, and GITHUB_REPO are required when VCS_KIND=github", + ); } return { kind: "github", - token: env.GITHUB_TOKEN, + auth: { + appId: env.GITHUB_APP_ID, + // Pass the base64 string through unchanged. The workflow body calls + // getVcsConfig() to read baseBranch, and that runtime doesn't expose + // Buffer or atob — so the decode happens at the use site (always inside + // a Node step) in src/lib/github-auth.ts. + privateKeyBase64: env.GITHUB_APP_PRIVATE_KEY, + installationId: env.GITHUB_INSTALLATION_ID, + }, repoPath: `${env.GITHUB_OWNER}/${env.GITHUB_REPO}`, baseBranch: env.GITHUB_BASE_BRANCH ?? "main", host: "https://github.com", }; } + +/** + * Resolve a fresh git-credential-shaped token for the configured VCS. + * - GitLab: returns the static PAT. + * - GitHub: mints a fresh ~1h installation access token via the App's JWT. + * + * Call this immediately before any operation that needs the raw token (git + * push, Sandbox.create source.password). Do not cache the result outside the + * operation that needs it. + */ +export async function getVcsToken(config: VcsConfig): Promise { + if (config.kind === "gitlab") return config.token; + // Dynamic import keeps @octokit/* off the env-validation cold path. Modules + // that only need env (e.g. Slack webhook handler) shouldn't transitively + // load the GitHub App auth deps. + const { mintInstallationToken } = await import("./src/lib/github-auth.js"); + return mintInstallationToken(config.auth); +} diff --git a/package.json b/package.json index 8e73233..f5b9337 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@chat-adapter/slack": "^4.20.2", "@gitbeaker/rest": "^43.8.0", + "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", "@t3-oss/env-core": "^0.13.10", "@upstash/redis": "^1.37.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fa6e2d..68ab9eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 + '@octokit/auth-app': + specifier: ^8.2.0 + version: 8.2.0 '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 @@ -583,6 +586,22 @@ packages: resolution: {integrity: sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==} engines: {node: '>=18.0.0'} + '@octokit/auth-app@8.2.0': + resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.3': + resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.3': + resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.2': + resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==} + engines: {node: '>= 20'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -599,6 +618,14 @@ packages: resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} engines: {node: '>= 20'} + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} @@ -3861,6 +3888,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3975,6 +4006,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -4836,6 +4870,40 @@ snapshots: dependencies: '@oclif/core': 4.8.1 + '@octokit/auth-app@8.2.0': + dependencies: + '@octokit/auth-oauth-app': 9.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + toad-cache: 3.7.0 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-app@9.0.3': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.3': + dependencies: + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-user@6.0.2': + dependencies: + '@octokit/auth-oauth-device': 8.0.3 + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -4859,6 +4927,15 @@ snapshots: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + '@octokit/openapi-types@27.0.0': {} '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': @@ -8634,6 +8711,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.1: {} token-types@6.1.2: @@ -8758,6 +8837,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-github-app-jwt@2.2.2: {} + universal-user-agent@7.0.3: {} universalify@2.0.1: {} diff --git a/src/adapters/messaging/format.ts b/src/adapters/messaging/format.ts index e9606e2..a3ed59b 100644 --- a/src/adapters/messaging/format.ts +++ b/src/adapters/messaging/format.ts @@ -103,7 +103,7 @@ function jiraLink(ticketKey: string, jiraBaseUrl: string): string { } function formatFailedBody( - phase: "research" | "impl" | "push" | undefined, + phase: "research" | "impl" | "review" | "push" | undefined, reason: string | undefined, ): string { if (phase && reason) return `: ${phase} — ${reason}`; diff --git a/src/adapters/messaging/noop.ts b/src/adapters/messaging/noop.ts new file mode 100644 index 0000000..1f5b1ba --- /dev/null +++ b/src/adapters/messaging/noop.ts @@ -0,0 +1,12 @@ +import { logger } from "../../lib/logger.js"; +import type { MessagingAdapter, TicketEvent } from "./types.js"; + +/** + * Used when Slack credentials aren't configured. Swallows all events so the + * workflow can run end-to-end without a messaging integration. + */ +export class NoopMessagingAdapter implements MessagingAdapter { + async notifyForTicket(ticketKey: string, event: TicketEvent): Promise { + logger.debug({ ticketKey, kind: event.kind }, "messaging disabled — skipping notification"); + } +} diff --git a/src/adapters/messaging/types.ts b/src/adapters/messaging/types.ts index 57170cc..aa5bcc1 100644 --- a/src/adapters/messaging/types.ts +++ b/src/adapters/messaging/types.ts @@ -17,7 +17,7 @@ export type TicketEvent = } | { kind: "failed"; - phase?: "research" | "impl" | "push"; + phase?: "research" | "impl" | "review" | "push"; reason?: string; usageReport?: string; } diff --git a/src/adapters/vcs/github.test.ts b/src/adapters/vcs/github.test.ts index dc922ae..5815604 100644 --- a/src/adapters/vcs/github.test.ts +++ b/src/adapters/vcs/github.test.ts @@ -20,13 +20,13 @@ const mockOctokit = { }, }; -vi.mock("@octokit/rest", () => ({ - Octokit: vi.fn(() => mockOctokit), +vi.mock("../../lib/github-auth.js", () => ({ + buildOctokit: vi.fn(() => mockOctokit), })); function ghAdapter() { return new GitHubAdapter({ - token: "ghp_test", + auth: { appId: 1, privateKeyBase64: "a2V5", installationId: 2 }, owner: "test-org", repo: "test-repo", baseBranch: "main", diff --git a/src/adapters/vcs/github.ts b/src/adapters/vcs/github.ts index e5c4dfe..90dcf8b 100644 --- a/src/adapters/vcs/github.ts +++ b/src/adapters/vcs/github.ts @@ -1,9 +1,10 @@ -import { Octokit } from "@octokit/rest"; import { FatalError } from "workflow"; +import type { Octokit } from "@octokit/rest"; +import { buildOctokit, type GitHubAppAuth } from "../../lib/github-auth.js"; import type { VCSAdapter, PullRequest, PRComment, CheckRunResult } from "./types.js"; export interface GitHubConfig { - token: string; + auth: GitHubAppAuth; owner: string; repo: string; baseBranch: string; @@ -13,7 +14,7 @@ export class GitHubAdapter implements VCSAdapter { private octokit: Octokit; constructor(private config: GitHubConfig) { - this.octokit = new Octokit({ auth: config.token }); + this.octokit = buildOctokit(config.auth); } private get ownerRepo() { diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index e061646..73d9a75 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -1,6 +1,7 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; +import { NoopMessagingAdapter } from "../adapters/messaging/noop.js"; import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; @@ -23,6 +24,16 @@ export function createAdapters(): Adapters { url: env.AI_WORKFLOW_KV_REST_API_URL, token: env.AI_WORKFLOW_KV_REST_API_TOKEN, }); + const messaging: MessagingAdapter = + env.CHAT_SDK_SLACK_TOKEN && env.CHAT_SDK_CHANNEL_ID + ? new ChatSDKAdapter({ + slackToken: env.CHAT_SDK_SLACK_TOKEN, + channelId: env.CHAT_SDK_CHANNEL_ID, + botName: env.CHAT_SDK_BOT_NAME, + jiraBaseUrl: env.JIRA_BASE_URL, + threadStore: runRegistry, + }) + : new NoopMessagingAdapter(); return { issueTracker: new JiraAdapter({ baseUrl: env.JIRA_BASE_URL, @@ -31,13 +42,7 @@ export function createAdapters(): Adapters { projectKey: env.JIRA_PROJECT_KEY, }), vcs: createVCS(), - messaging: new ChatSDKAdapter({ - slackToken: env.CHAT_SDK_SLACK_TOKEN, - channelId: env.CHAT_SDK_CHANNEL_ID, - botName: env.CHAT_SDK_BOT_NAME, - jiraBaseUrl: env.JIRA_BASE_URL, - threadStore: runRegistry, - }), + messaging, runRegistry, }; } diff --git a/src/lib/create-vcs.ts b/src/lib/create-vcs.ts index 0922f33..d9e0110 100644 --- a/src/lib/create-vcs.ts +++ b/src/lib/create-vcs.ts @@ -1,4 +1,4 @@ -import { getVcsConfig } from "../../env.js"; +import { getVcsConfig, type VcsConfig } from "../../env.js"; import { GitHubAdapter } from "../adapters/vcs/github.js"; import { GitLabAdapter } from "../adapters/vcs/gitlab.js"; import type { VCSAdapter } from "../adapters/vcs/types.js"; @@ -13,13 +13,16 @@ export function createVCS(): VCSAdapter { host: vcs.host, }); } + if (vcs.kind !== "github") { + throw new Error(`Unreachable: VCS kind ${(vcs as VcsConfig).kind} fell through GitHub branch`); + } const parts = vcs.repoPath.split("/"); if (parts.length !== 2 || !parts[0] || !parts[1]) { throw new Error(`Invalid repoPath for GitHub: expected exactly "owner/repo", got "${vcs.repoPath}"`); } const [owner, repo] = parts; return new GitHubAdapter({ - token: vcs.token, + auth: vcs.auth, owner, repo, baseBranch: vcs.baseBranch, diff --git a/src/lib/github-auth.test.ts b/src/lib/github-auth.test.ts new file mode 100644 index 0000000..e913aec --- /dev/null +++ b/src/lib/github-auth.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockHook = vi.fn(async () => ({ token: "ghs_minted-token", type: "token" as const, tokenType: "installation" as const })); + +vi.mock("@octokit/auth-app", () => ({ + createAppAuth: vi.fn(() => mockHook), +})); + +const mockGetAuthenticated = vi.fn(); +const mockGetByUsername = vi.fn(); + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn(function (this: any, opts: any) { + this.opts = opts; + this.apps = { getAuthenticated: mockGetAuthenticated }; + this.users = { getByUsername: mockGetByUsername }; + }), +})); + +import { buildOctokit, mintInstallationToken, getBotIdentity } from "./github-auth.js"; + +const FAKE_PEM = "-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----"; +const fakeAuth = { + appId: 123, + privateKeyBase64: Buffer.from(FAKE_PEM).toString("base64"), + installationId: 456, +}; + +describe("github-auth", () => { + beforeEach(async () => { + mockHook.mockClear(); + mockGetAuthenticated.mockReset(); + mockGetByUsername.mockReset(); + const { createAppAuth } = await import("@octokit/auth-app"); + const { Octokit } = await import("@octokit/rest"); + vi.mocked(createAppAuth).mockClear(); + vi.mocked(Octokit).mockClear(); + }); + + it("mintInstallationToken returns the token string from createAppAuth", async () => { + const { createAppAuth } = await import("@octokit/auth-app"); + const token = await mintInstallationToken(fakeAuth); + expect(token).toBe("ghs_minted-token"); + expect(createAppAuth).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 123, + privateKey: FAKE_PEM, + installationId: 456, + }), + ); + expect(mockHook).toHaveBeenCalledWith({ type: "installation" }); + }); + + it("getBotIdentity returns App slug + numeric-id noreply email", async () => { + mockGetAuthenticated.mockResolvedValueOnce({ data: { slug: "ai-workflow-blazity" } }); + mockGetByUsername.mockResolvedValueOnce({ data: { id: 9876543 } }); + const identity = await getBotIdentity(fakeAuth); + expect(identity).toEqual({ + name: "ai-workflow-blazity[bot]", + email: "9876543+ai-workflow-blazity[bot]@users.noreply.github.com", + }); + expect(mockGetByUsername).toHaveBeenCalledWith({ username: "ai-workflow-blazity[bot]" }); + }); + + it("getBotIdentity throws if /app response has no slug", async () => { + mockGetAuthenticated.mockResolvedValueOnce({ data: {} }); + await expect(getBotIdentity(fakeAuth)).rejects.toThrow("missing `slug`"); + }); + + it("buildOctokit constructs an Octokit with the App auth strategy and credentials", async () => { + const { Octokit } = await import("@octokit/rest"); + const { createAppAuth } = await import("@octokit/auth-app"); + buildOctokit(fakeAuth); + expect(Octokit).toHaveBeenCalledWith( + expect.objectContaining({ + authStrategy: createAppAuth, + auth: expect.objectContaining({ + appId: 123, + privateKey: FAKE_PEM, + installationId: 456, + }), + }), + ); + }); +}); diff --git a/src/lib/github-auth.ts b/src/lib/github-auth.ts new file mode 100644 index 0000000..5b6e848 --- /dev/null +++ b/src/lib/github-auth.ts @@ -0,0 +1,77 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; + +export interface GitHubAppAuth { + appId: number; + /** + * Base64-encoded PEM private key. Held encoded so this struct can be passed + * through the workflow runtime, which lacks Node's `Buffer` and Web's `atob` + * globals. Decoded to PEM only inside the functions below, which always run + * in Node-runtime steps. + */ + privateKeyBase64: string; + installationId: number; +} + +function decodePem(privateKeyBase64: string): string { + return Buffer.from(privateKeyBase64, "base64").toString("utf8"); +} + +/** + * Octokit instance pre-wired with the App auth strategy. Octokit handles + * installation-token minting and refresh internally per request — use this + * for all GitHub REST API calls from the adapter. + */ +export function buildOctokit(auth: GitHubAppAuth): Octokit { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: auth.appId, + privateKey: decodePem(auth.privateKeyBase64), + installationId: auth.installationId, + }, + }); +} + +/** + * Mint a fresh installation access token explicitly. Used at the git push + * site (and Sandbox.create source.password) where we need a raw token string + * to inject into git remote URLs. Each call hits GitHub's API to mint a new + * ~1h-lived token; do not cache the result outside the operation that needs it. + */ +export async function mintInstallationToken(auth: GitHubAppAuth): Promise { + const appAuth = createAppAuth({ + appId: auth.appId, + privateKey: decodePem(auth.privateKeyBase64), + installationId: auth.installationId, + }); + const result = await appAuth({ type: "installation" }); + return result.token; +} + +/** + * Resolve the GitHub App's bot commit identity. Authoring commits with this + * `name`/`email` pair makes the GitHub UI render them with the App's avatar + * and the `[bot]` badge, instead of the previous human owner who registered + * the App. Format follows GitHub's noreply convention: + * `+[bot]@users.noreply.github.com` + * + * Two API calls (`GET /app` for the slug, `GET /users/{slug}[bot]` for the + * numeric user id), both using the App JWT — no extra installation tokens. + */ +export async function getBotIdentity( + auth: GitHubAppAuth, +): Promise<{ name: string; email: string }> { + const octokit = buildOctokit(auth); + const { data: app } = await octokit.apps.getAuthenticated(); + const slug = app?.slug; + if (!slug) { + throw new Error("GitHub App response missing `slug` — cannot derive bot identity"); + } + const username = `${slug}[bot]`; + const { data: user } = await octokit.users.getByUsername({ username }); + return { + name: username, + email: `${user.id}+${username}@users.noreply.github.com`, + }; +} diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 894159a..6528b70 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -185,7 +185,7 @@ You are an AI code review agent. Your job is to review the implementation diff a 1. Read the plan from the "Research & Plan" section above. 2. Read the acceptance criteria. -3. Review the git diff against the plan — did the implementation agent follow it? +3. Explore the current changes on this branch and check whether they align with the plan and acceptance criteria. 4. Check code quality, test coverage, edge cases. 5. **Fix any issues found** — apply code changes directly. This is the final phase, there is no re-implementation loop. 6. If you made changes, run tests and quality checks to verify the fixes. diff --git a/src/lib/step-adapters.ts b/src/lib/step-adapters.ts index 212d88b..9643779 100644 --- a/src/lib/step-adapters.ts +++ b/src/lib/step-adapters.ts @@ -1,6 +1,7 @@ import { env } from "../../env.js"; import { JiraAdapter } from "../adapters/issue-tracker/jira.js"; import { ChatSDKAdapter } from "../adapters/messaging/chatsdk.js"; +import { NoopMessagingAdapter } from "../adapters/messaging/noop.js"; import { UpstashRunRegistry } from "../adapters/run-registry/upstash.js"; import { createVCS } from "./create-vcs.js"; import type { IssueTrackerAdapter } from "../adapters/issue-tracker/types.js"; @@ -20,6 +21,16 @@ export function createStepAdapters(): StepAdapters { url: env.AI_WORKFLOW_KV_REST_API_URL, token: env.AI_WORKFLOW_KV_REST_API_TOKEN, }); + const messaging: MessagingAdapter = + env.CHAT_SDK_SLACK_TOKEN && env.CHAT_SDK_CHANNEL_ID + ? new ChatSDKAdapter({ + slackToken: env.CHAT_SDK_SLACK_TOKEN, + channelId: env.CHAT_SDK_CHANNEL_ID, + botName: env.CHAT_SDK_BOT_NAME, + jiraBaseUrl: env.JIRA_BASE_URL, + threadStore: runRegistry, + }) + : new NoopMessagingAdapter(); return { issueTracker: new JiraAdapter({ baseUrl: env.JIRA_BASE_URL, @@ -28,13 +39,7 @@ export function createStepAdapters(): StepAdapters { projectKey: env.JIRA_PROJECT_KEY, }), vcs: createVCS(), - messaging: new ChatSDKAdapter({ - slackToken: env.CHAT_SDK_SLACK_TOKEN, - channelId: env.CHAT_SDK_CHANNEL_ID, - botName: env.CHAT_SDK_BOT_NAME, - jiraBaseUrl: env.JIRA_BASE_URL, - threadStore: runRegistry, - }), + messaging, runRegistry, }; } diff --git a/src/routes/webhooks/slack.post.ts b/src/routes/webhooks/slack.post.ts index 11c935e..5fb6d15 100644 --- a/src/routes/webhooks/slack.post.ts +++ b/src/routes/webhooks/slack.post.ts @@ -76,6 +76,9 @@ export default defineEventHandler(async (event) => { // --------------------------------------------------------------------------- function verifyWebhookAuth(event: H3Event, rawBody: string): void { + if (!env.SLACK_SIGNING_SECRET) { + throw createError({ statusCode: 503, statusMessage: "Slack integration not configured" }); + } const signature = getHeader(event, "x-slack-signature"); const timestamp = getHeader(event, "x-slack-request-timestamp"); if (!signature || !timestamp) { diff --git a/src/sandbox/agents/claude.ts b/src/sandbox/agents/claude.ts index f0bd077..c8aa7f3 100644 --- a/src/sandbox/agents/claude.ts +++ b/src/sandbox/agents/claude.ts @@ -27,15 +27,20 @@ export class ClaudeAgentAdapter implements AgentAdapter { } async configure(sandbox: RunnableSandbox, opts: ConfigureOpts): Promise { - if (!opts.anthropicApiKey && !opts.claudeCodeOauthToken) { - throw new Error("ClaudeAgentAdapter.configure requires anthropicApiKey or claudeCodeOauthToken"); - } - const envLines: string[] = []; - if (opts.claudeCodeOauthToken) { - envLines.push(`export CLAUDE_CODE_OAUTH_TOKEN=${shellQuote(opts.claudeCodeOauthToken)}`); - } else if (opts.anthropicApiKey) { - envLines.push(`export ANTHROPIC_API_KEY=${shellQuote(opts.anthropicApiKey)}`); + if (!opts.anthropicApiKey) { + throw new Error("ClaudeAgentAdapter.configure requires anthropicApiKey"); } + // Claude Code CLI accepts standard API keys (`sk-ant-api...`) via + // ANTHROPIC_API_KEY and OAuth tokens (`sk-ant-oat...`, issued by + // `claude setup-token`) via CLAUDE_CODE_OAUTH_TOKEN. Operators paste + // either flavor into ANTHROPIC_API_KEY; route to the right sandbox var + // by prefix so OAuth tokens don't get rejected as invalid API keys. + const isOauthToken = opts.anthropicApiKey.startsWith("sk-ant-oat"); + const envLines: string[] = [ + isOauthToken + ? `export CLAUDE_CODE_OAUTH_TOKEN=${shellQuote(opts.anthropicApiKey)}` + : `export ANTHROPIC_API_KEY=${shellQuote(opts.anthropicApiKey)}`, + ]; await sandbox.writeFiles([ { path: "/tmp/agent-env.sh", content: Buffer.from(envLines.join("\n") + "\n") }, ]); diff --git a/src/sandbox/agents/types.ts b/src/sandbox/agents/types.ts index 2fd14d8..c518260 100644 --- a/src/sandbox/agents/types.ts +++ b/src/sandbox/agents/types.ts @@ -102,7 +102,6 @@ export interface ArthurConfig { export interface ConfigureOpts { anthropicApiKey?: string; - claudeCodeOauthToken?: string; codexApiKey?: string; codexChatGptOauthToken?: string; model: string; diff --git a/src/sandbox/context.test.ts b/src/sandbox/context.test.ts index 9360bbc..a9b547c 100644 --- a/src/sandbox/context.test.ts +++ b/src/sandbox/context.test.ts @@ -215,7 +215,7 @@ describe("assembleImplementationContext (new)", () => { }); describe("assembleReviewContext", () => { - it("includes plan and git diff", () => { + it("includes plan and prompt", () => { const result = assembleReviewContext({ ticket: { identifier: "TEST-1", @@ -226,12 +226,10 @@ describe("assembleReviewContext", () => { }, prompt: "You are a review agent...", researchPlanMarkdown: "# Plan\n1. Create LoginForm", - gitDiff: "diff --git a/src/LoginForm.tsx b/src/LoginForm.tsx\n+export function LoginForm() {}", }); expect(result).toContain("## Research & Plan"); - expect(result).toContain("## Git Diff"); - expect(result).toContain("+export function LoginForm()"); + expect(result).toContain("1. Create LoginForm"); expect(result).toContain("You are a review agent..."); }); @@ -246,7 +244,6 @@ describe("assembleReviewContext", () => { }, prompt: "prompt", researchPlanMarkdown: "plan", - gitDiff: "diff", attachments: [ { filename: "mockup.png", @@ -271,7 +268,6 @@ describe("assembleReviewContext", () => { ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, prompt: "p", researchPlanMarkdown: "plan", - gitDiff: "diff", }); expect(withoutField).not.toContain("## Attachments"); @@ -279,7 +275,6 @@ describe("assembleReviewContext", () => { ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, prompt: "p", researchPlanMarkdown: "plan", - gitDiff: "diff", attachments: [], }); expect(withEmpty).not.toContain("## Attachments"); @@ -290,7 +285,6 @@ describe("assembleReviewContext", () => { ticket: { identifier: "X", title: "t", description: "d", acceptanceCriteria: "a", comments: [] }, prompt: "p", researchPlanMarkdown: "plan", - gitDiff: "diff", attachments: [ { filename: "spec.pdf", diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 558e897..0d5c6d1 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -31,7 +31,6 @@ export interface ReviewContextInput { ticket: TicketData; prompt: string; researchPlanMarkdown: string; - gitDiff: string; attachments?: DownloadedAttachment[]; } @@ -110,7 +109,7 @@ ${prompt} } export function assembleReviewContext(input: ReviewContextInput): string { - const { ticket, prompt, researchPlanMarkdown, gitDiff, attachments } = input; + const { ticket, prompt, researchPlanMarkdown, attachments } = input; const attachmentsSection = renderAttachmentsSection(attachments); return `# Requirements @@ -130,12 +129,6 @@ ${ticket.acceptanceCriteria || "None specified."} ${researchPlanMarkdown} -## Git Diff - -\`\`\`diff -${gitDiff} -\`\`\` - --- ${prompt} diff --git a/src/sandbox/manager.test.ts b/src/sandbox/manager.test.ts index a42babd..ba0ef14 100644 --- a/src/sandbox/manager.test.ts +++ b/src/sandbox/manager.test.ts @@ -46,7 +46,7 @@ describe("SandboxManager.provision", () => { const baseConfig = { kind: "github" as const, - token: "ghp_test", + getToken: () => Promise.resolve("ghs_test"), repoPath: "test-org/test-repo", host: "https://github.com", jobTimeoutMs: 1_800_000, diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index a2a3f24..e96d605 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -4,7 +4,8 @@ import type { AgentAdapter, ConfigureOpts } from "./agents/types.js"; export interface SandboxConfig { kind: "github" | "gitlab"; - token: string; + /** Resolves a fresh, short-lived token at the moment of use. */ + getToken: () => Promise; repoPath: string; host: string; jobTimeoutMs: number; @@ -12,15 +13,28 @@ export interface SandboxConfig { commitEmail: string; } -/** Build clone/push URLs for the configured VCS. Unchanged from previous behaviour. */ -export function buildVcsUrls(config: { kind: "github" | "gitlab"; token: string; repoPath: string; host: string }) { +/** Bare clone URL with no auth — host normalization shared with `buildVcsUrls`. */ +export function buildCloneUrl(config: { host: string; repoPath: string }): string { + const host = config.host.replace(/\/+$/, ""); + return `${host}/${config.repoPath}.git`; +} + +/** + * Build clone/push URLs for the configured VCS. The caller resolves the token + * just-in-time and passes it as the second arg, so this function stays pure + * and does not capture credentials. + */ +export function buildVcsUrls( + config: { kind: "github" | "gitlab"; repoPath: string; host: string }, + token: string, +) { const host = config.host.replace(/\/+$/, ""); const scheme = host.match(/^https?:\/\//)?.[0] ?? "https://"; const hostNoScheme = host.replace(/^https?:\/\//, ""); const authUser = config.kind === "gitlab" ? "oauth2" : "x-access-token"; return { - cloneUrl: `${host}/${config.repoPath}.git`, - authUrl: `${scheme}${authUser}:${config.token}@${hostNoScheme}/${config.repoPath}.git`, + cloneUrl: buildCloneUrl(config), + authUrl: `${scheme}${authUser}:${token}@${hostNoScheme}/${config.repoPath}.git`, authUser, }; } @@ -37,7 +51,8 @@ export class SandboxManager { mergeBase?: string, ): Promise { const { Sandbox } = await import("@vercel/sandbox"); - const urls = buildVcsUrls(this.config); + const token = await this.config.getToken(); + const urls = buildVcsUrls(this.config, token); const sandbox = await Sandbox.create({ ...getSandboxCredentials(), @@ -45,7 +60,7 @@ export class SandboxManager { type: "git", url: urls.cloneUrl, username: urls.authUser, - password: this.config.token, + password: token, revision: branch, }, runtime: "node24", diff --git a/src/sandbox/poll-agent.test.ts b/src/sandbox/poll-agent.test.ts index e85846c..10e0bbe 100644 --- a/src/sandbox/poll-agent.test.ts +++ b/src/sandbox/poll-agent.test.ts @@ -26,45 +26,51 @@ vi.mock("./credentials.js", () => ({ // VCS config is swapped per-test by reassigning currentVcsConfig before the // step under test calls getVcsConfig(). Default is GitHub; GitLab tests set // it to a GitLab config to exercise the oauth2 auth user and gitlab host. -let currentVcsConfig: { - kind: "github" | "gitlab"; - token: string; - repoPath: string; - baseBranch: string; - host: string; -} = { +// Shape mirrors the discriminated union in env.ts (GitHub uses App auth, not a PAT). +type TestVcsConfig = + | { + kind: "github"; + auth: { appId: number; privateKeyBase64: string; installationId: number }; + repoPath: string; + baseBranch: string; + host: string; + } + | { + kind: "gitlab"; + token: string; + repoPath: string; + baseBranch: string; + host: string; + }; + +const githubVcsConfig: TestVcsConfig = { kind: "github", - token: "ghp_test_token", + auth: { appId: 123, privateKeyBase64: "ZmFrZS1wZW0=", installationId: 456 }, repoPath: "test-owner/test-repo", baseBranch: "main", host: "https://github.com", }; -const githubVcsConfig = { - kind: "github" as const, - token: "ghp_test_token", - repoPath: "test-owner/test-repo", - baseBranch: "main", - host: "https://github.com", -}; - -const gitlabVcsConfig = { - kind: "gitlab" as const, +const gitlabVcsConfig: TestVcsConfig = { + kind: "gitlab", token: "glpat_test_token", repoPath: "test-group/test-repo", baseBranch: "main", host: "https://gitlab.example.com", }; +let currentVcsConfig: TestVcsConfig = githubVcsConfig; + vi.mock("../../env.js", () => ({ env: { VCS_KIND: "github", - GITHUB_TOKEN: "ghp_test_token", GITHUB_OWNER: "test-owner", GITHUB_REPO: "test-repo", CLAUDE_MODEL: "claude-sonnet-4-20250514", }, getVcsConfig: () => currentVcsConfig, + getVcsToken: async (config: TestVcsConfig) => + config.kind === "gitlab" ? config.token : "ghs_test_minted_token", })); import { pushFromSandbox, fixAndRetryPush, teardownSandbox, checkPhaseDone, collectPhaseOutput, collectPhase } from "./poll-agent.js"; diff --git a/src/sandbox/poll-agent.ts b/src/sandbox/poll-agent.ts index caebdf7..d0ebf0f 100644 --- a/src/sandbox/poll-agent.ts +++ b/src/sandbox/poll-agent.ts @@ -1,5 +1,5 @@ import { getSandboxCredentials } from "./credentials.js"; -import { buildVcsUrls } from "./manager.js"; +import { buildCloneUrl, buildVcsUrls } from "./manager.js"; /** * After the agent exits, injects the VCS token and pushes commits. @@ -11,9 +11,11 @@ export async function pushFromSandbox( ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); - const { getVcsConfig } = await import("../../env.js"); + const { getVcsConfig, getVcsToken } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - const urls = buildVcsUrls(getVcsConfig()); + const config = getVcsConfig(); + const token = await getVcsToken(config); + const urls = buildVcsUrls(config, token); // Check if agent made any commits. // If the sentinel file is missing (provisioning issue), skip the check and push anyway. @@ -70,13 +72,13 @@ export async function fixAndRetryPush( ): Promise<{ pushed: boolean; error?: string }> { "use step"; const { Sandbox } = await import("@vercel/sandbox"); - const { getVcsConfig } = await import("../../env.js"); + const { getVcsConfig, getVcsToken } = await import("../../env.js"); const sandbox = await Sandbox.get({ sandboxId, ...getSandboxCredentials() }); - const urls = buildVcsUrls(getVcsConfig()); + const config = getVcsConfig(); // Strip token from origin before the fix agent runs — agent only commits, never pushes. await sandbox.runCommand("git", [ - "remote", "set-url", "origin", urls.cloneUrl, + "remote", "set-url", "origin", buildCloneUrl(config), ]); // Write prompt to a file to avoid shell injection via pushError content @@ -108,7 +110,10 @@ export async function fixAndRetryPush( logger.info({ output: fixLog.slice(0, 500) }, "fix_and_retry_push_output"); } - // Re-inject token and push — server pushes, not the agent. + // Re-inject token and push — server pushes, not the agent. Mint fresh token + // here (after the fix agent runs) so we never have a stale token in scope. + const token = await getVcsToken(config); + const urls = buildVcsUrls(config, token); await sandbox.runCommand("git", ["remote", "set-url", "origin", urls.authUrl]); const result = await sandbox.runCommand("git", ["push", "--force", "origin", `HEAD:refs/heads/${branch}`]); diff --git a/src/workflows/agent.ts b/src/workflows/agent.ts index 8b94287..ea49356 100644 --- a/src/workflows/agent.ts +++ b/src/workflows/agent.ts @@ -1,6 +1,6 @@ import { sleep } from "workflow"; import type { - AgentOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, + AgentOutput, PhaseUsage, PhaseKind, PhaseArtifactPaths, ResearchResult, ReviewOutput, } from "../sandbox/agents/types.js"; import type { AgentKind } from "../sandbox/agents/index.js"; import type { PRComment, CheckRunResult } from "../adapters/vcs/types.js"; @@ -200,26 +200,42 @@ async function provisionSandbox( "agent override agent:codex requires CODEX_API_KEY or CODEX_CHATGPT_OAUTH_TOKEN in the deployed environment", ); } - if (agentKind === "claude" && !env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) { + if (agentKind === "claude" && !env.ANTHROPIC_API_KEY) { throw new Error( - "agent override agent:claude requires ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in the deployed environment", + "agent override agent:claude requires ANTHROPIC_API_KEY in the deployed environment", ); } const agent = createAgentAdapter(agentKind); + const { getVcsToken } = await import("../../env.js"); + + // Resolve the git identity used inside the sandbox. + // Explicit COMMIT_AUTHOR/EMAIL always wins. Otherwise, on GitHub we derive + // the App's bot identity (`[bot]` + numeric-id noreply email) so the + // commits render with the App's avatar and `[bot]` badge. On GitLab there + // is no equivalent App identity, so we fall back to a static default. + let commitIdentity: { name: string; email: string }; + if (env.COMMIT_AUTHOR && env.COMMIT_EMAIL) { + commitIdentity = { name: env.COMMIT_AUTHOR, email: env.COMMIT_EMAIL }; + } else if (vcs.kind === "github") { + const { getBotIdentity } = await import("../lib/github-auth.js"); + commitIdentity = await getBotIdentity(vcs.auth); + } else { + commitIdentity = { name: "ai-workflow-blazity", email: "ai-workflow@blazity.com" }; + } + const manager = new SandboxManager({ kind: vcs.kind, - token: vcs.token, + getToken: () => getVcsToken(vcs), repoPath: vcs.repoPath, host: vcs.host, jobTimeoutMs: env.JOB_TIMEOUT_MS, - commitAuthor: env.COMMIT_AUTHOR, - commitEmail: env.COMMIT_EMAIL, + commitAuthor: commitIdentity.name, + commitEmail: commitIdentity.email, }); const sandbox = await manager.provision(branchName, agent, { anthropicApiKey: env.ANTHROPIC_API_KEY, - claudeCodeOauthToken: env.CLAUDE_CODE_OAUTH_TOKEN, codexApiKey: env.CODEX_API_KEY, codexChatGptOauthToken: env.CODEX_CHATGPT_OAUTH_TOKEN, model: agentKind === "codex" ? env.CODEX_MODEL : env.CLAUDE_MODEL, @@ -322,6 +338,17 @@ async function parseAgentOutputStep( return { output: a.parseAgentOutput(raw, structured), usage: a.extractUsage(raw, structured) }; } +async function parseReviewStep( + agentKind: AgentKind, + raw: string, + structured: string | null, +): Promise<{ output: ReviewOutput; usage: PhaseUsage | null }> { + "use step"; + const { createAgentAdapter } = await import("../sandbox/agents/index.js"); + const a = createAgentAdapter(agentKind); + return { output: a.parseReviewOutput(raw, structured), usage: a.extractUsage(raw, structured) }; +} + async function createPullRequest(branchName: string, title: string, summary: string) { "use step"; const { createStepAdapters } = await import("../lib/step-adapters.js"); @@ -434,12 +461,12 @@ export async function agentWorkflow(ticketId: string) { "use workflow"; const { env, getVcsConfig } = await import("../../env.js"); - const { assembleResearchPlanContext, assembleImplementationContext } = + const { assembleResearchPlanContext, assembleImplementationContext, assembleReviewContext } = await import("../sandbox/context.js"); const { collectPhase, pushFromSandbox, fixAndRetryPush, teardownSandbox } = await import("../sandbox/poll-agent.js"); const { formatUsageReport } = await import("../sandbox/usage.js"); - const { AGENT_SCHEMA } = await import("../sandbox/agents/types.js"); + const { AGENT_SCHEMA, REVIEW_SCHEMA } = await import("../sandbox/agents/types.js"); const ticket = await fetchAndValidateTicket(ticketId, env.COLUMN_AI); if (!ticket) return; @@ -646,50 +673,48 @@ export async function agentWorkflow(ticketId: string) { } // ========== PHASE 3: Review ========== - // Temporarily disabled. - // await setCommitGuardStep(sandboxId, agentKind, true); - // - // const gitDiff = await captureGitDiff(sandboxId); - // - // const reviewPaths = agent.artifactPaths("review"); - // const reviewInput = assembleReviewContext({ - // ticket: ticketData, - // prompt: prompts.review, - // researchPlanMarkdown, - // gitDiff, - // attachments: downloadedAttachments, - // }); - // - // const reviewScript = agent.buildPhaseScript({ - // phase: "review", - // model: activeModel, - // paths: reviewPaths, - // jsonSchema: REVIEW_SCHEMA, - // }); - // - // await writeAndStartPhase( - // sandboxId, - // reviewPaths.input, reviewInput, - // reviewPaths.wrapper, reviewScript, - // ); - // - // const reviewDone = await pollUntilDone(sandboxId, reviewPaths.sentinel, 15); - // let reviewOutput: ReviewOutput; - // - // if (reviewDone) { - // const { raw: reviewRaw, structured: reviewStructured } = await collectPhase(sandboxId, reviewPaths); - // phaseUsages["Review"] = agent.extractUsage(reviewRaw, reviewStructured); - // reviewOutput = agent.parseReviewOutput(reviewRaw, reviewStructured); - // } else { - // reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; - // } - // - // if (reviewOutput.result === "failed") { - // await moveTicket(ticketId, env.COLUMN_BACKLOG); - // await notifySlack(`Task ${ticket.identifier} failed: review — ${reviewOutput.error ?? "unknown"}${usageSuffix()}`); - // await unregisterRun(ticket.identifier); - // return; - // } + // Gated by ENABLE_REVIEW_PHASE so deployments can opt in without code + // changes. Commit guard stays enabled (review fixes its own findings). + if (env.ENABLE_REVIEW_PHASE) { + const { paths: reviewPaths, script: reviewScript } = + await planPhaseStep(agentKind, "review", activeModel, REVIEW_SCHEMA); + const reviewInput = assembleReviewContext({ + ticket: ticketData, + prompt: prompts.review, + researchPlanMarkdown, + attachments: downloadedAttachments, + }); + + await writeAndStartPhase( + sandboxId, + reviewPaths.input, reviewInput, + reviewPaths.wrapper, reviewScript, + ); + + const reviewDone = await pollUntilDone(sandboxId, reviewPaths.sentinel, 15); + let reviewOutput: ReviewOutput; + + if (reviewDone) { + const { raw: reviewRaw, structured: reviewStructured } = await collectPhase(sandboxId, reviewPaths); + const { output, usage: reviewUsage } = await parseReviewStep(agentKind, reviewRaw, reviewStructured); + phaseUsages["Review"] = reviewUsage; + reviewOutput = output; + } else { + reviewOutput = { result: "failed", feedback: "", issues: [], error: "Review phase timed out" }; + } + + if (reviewOutput.result === "failed") { + await unregisterRun(ticket.identifier); + await moveTicket(ticketId, env.COLUMN_BACKLOG); + await notifyTicket(ticket.identifier, { + kind: "failed", + phase: "review", + reason: reviewOutput.error ?? "unknown", + usageReport: usageReportOrUndefined(), + }); + return; + } + } // ========== POST-PHASES: Push & PR ========== let pushResult = await pushFromSandbox(sandboxId, branchName);