From fd54e8fd65e9a44023dc326d417367c9ad15176e Mon Sep 17 00:00:00 2001 From: kasin-it Date: Fri, 1 May 2026 10:03:29 +0200 Subject: [PATCH 1/4] docs: add init skill --- .claude/skills/init-agent/SKILL.md | 79 +++++ .../references/oauth-alternative.md | 41 +++ .claude/skills/init-env/SKILL.md | 309 ++++++++++++++++++ .claude/skills/init-jira/SKILL.md | 169 ++++++++++ .../init-jira/references/column-statuses.md | 47 +++ .../references/description-format.md | 41 +++ .../init-jira/references/transitions.md | 46 +++ .../init-jira/references/troubleshooting.md | 22 ++ .../init-jira/references/webhook-setup.md | 52 +++ .claude/skills/init-slack/SKILL.md | 64 ++++ .../init-slack/references/bot-app-setup.md | 68 ++++ .claude/skills/init-upstash/SKILL.md | 62 ++++ .claude/skills/init-vcs/SKILL.md | 82 +++++ .../skills/init-vcs/references/github-pat.md | 43 +++ .../skills/init-vcs/references/gitlab-pat.md | 41 +++ .env.example | 46 ++- env.ts | 2 +- 17 files changed, 1197 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/init-agent/SKILL.md create mode 100644 .claude/skills/init-agent/references/oauth-alternative.md create mode 100644 .claude/skills/init-env/SKILL.md create mode 100644 .claude/skills/init-jira/SKILL.md create mode 100644 .claude/skills/init-jira/references/column-statuses.md create mode 100644 .claude/skills/init-jira/references/description-format.md create mode 100644 .claude/skills/init-jira/references/transitions.md create mode 100644 .claude/skills/init-jira/references/troubleshooting.md create mode 100644 .claude/skills/init-jira/references/webhook-setup.md create mode 100644 .claude/skills/init-slack/SKILL.md create mode 100644 .claude/skills/init-slack/references/bot-app-setup.md create mode 100644 .claude/skills/init-upstash/SKILL.md create mode 100644 .claude/skills/init-vcs/SKILL.md create mode 100644 .claude/skills/init-vcs/references/github-pat.md create mode 100644 .claude/skills/init-vcs/references/gitlab-pat.md diff --git a/.claude/skills/init-agent/SKILL.md b/.claude/skills/init-agent/SKILL.md new file mode 100644 index 0000000..15f237f --- /dev/null +++ b/.claude/skills/init-agent/SKILL.md @@ -0,0 +1,79 @@ +--- +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". +--- + +# 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. + +> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles the agent runtime. + +## Precondition + +`.vercel/project.json` must exist. If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. + +## Step 1 — Pick runtime + +Ask: *"Claude or Codex?"* + +If switching from a previously-configured runtime, the user should also remove the old runtime's keys from Vercel. Print a one-line warning. + +## Step 2 — Emit paste-template + +### Claude branch (default API key, OAuth alternative) + +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`. + +Collect: +- `ANTHROPIC_API_KEY` (starts with `sk-ant-`) +- `CLAUDE_MODEL` (default `claude-opus-4-6`; only override if requested) + +Emit: + +``` +AGENT_KIND=claude +ANTHROPIC_API_KEY= +``` + +If the user asked for a non-default model, also append: +``` +CLAUDE_MODEL= +``` + +### Codex branch (default API key, OAuth alternative) + +Walk the user through https://platform.openai.com/api-keys to create an API key. OAuth via `CODEX_CHATGPT_OAUTH_TOKEN` is documented in `references/oauth-alternative.md`. + +Collect: +- `CODEX_API_KEY` +- `CODEX_MODEL` (default `gpt-5-codex`; only override if requested) + +Emit: + +``` +AGENT_KIND=codex +CODEX_API_KEY= +``` + +If non-default model: +``` +CODEX_MODEL= +``` + +## Step 3 — Done + +Tell the user to paste into Vercel → Project Settings → Environment Variables (all three environments), save, and reply when done. No verification — `init-env`'s end-of-flow validator catches missing/malformed values. + +## Don'ts + +- **Don't emit both API key and OAuth token.** Pick one. The runbook explains the swap if the user wants OAuth. +- **Don't print the key after collecting it.** Reference by name only. +- **Don't change `CLAUDE_MODEL` / `CODEX_MODEL` defaults without being asked.** They're set in `env.ts`; only emit them when the user requests an override. diff --git a/.claude/skills/init-agent/references/oauth-alternative.md b/.claude/skills/init-agent/references/oauth-alternative.md new file mode 100644 index 0000000..6488342 --- /dev/null +++ b/.claude/skills/init-agent/references/oauth-alternative.md @@ -0,0 +1,41 @@ +# 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: + +- 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. + +## Codex — `CODEX_CHATGPT_OAUTH_TOKEN` + +Replace the API_KEY line in the paste-template with: + +``` +CODEX_CHATGPT_OAUTH_TOKEN= +``` + +The validator in `env.ts:124` accepts either `CODEX_API_KEY` *or* `CODEX_CHATGPT_OAUTH_TOKEN`. Don't paste both — pick one. + +OAuth tokens are obtained via the Codex CLI's login flow. + +## Rotation + +Rotate either kind quarterly. For OAuth, also track expiry — refresh before expiration to avoid runtime failures. + +```bash +vercel env rm production +vercel env add production +vercel --prod # redeploy so the new value takes effect +``` diff --git a/.claude/skills/init-env/SKILL.md b/.claude/skills/init-env/SKILL.md new file mode 100644 index 0000000..ea3cedd --- /dev/null +++ b/.claude/skills/init-env/SKILL.md @@ -0,0 +1,309 @@ +--- +name: init-env +description: First-time setup orchestrator for the Blazebot ai-workflow repo. Coordinates project linking, env var population across Jira / VCS / Agent / Slack / Upstash, deployment, and webhook registration in a single guided flow. Use when starting fresh on this repo for the first time — "init project", "first-time setup", "bootstrap this repo", "onboard me", "set up env from scratch". +--- + +# Initialize Project Environment (Cold Start) + +Cold-start orchestrator. Coordinates project linking, paste-template-driven env population across 5 domains, a single production deploy, webhook registration, and a manual smoke handoff. Self-contained — does not invoke other plugins. + +## What this skill does NOT do + +- **Partial re-runs.** This is a cold start only. To rotate one integration later, invoke that subskill standalone (e.g. `init-jira`, `init-vcs`). +- **Local-only setup.** Vercel-only deployment track. `.env.local` is a mirror produced by `vercel env pull`, never the source of truth. +- **Auto-write secrets.** The user pastes values into the Vercel dashboard; this skill never sees them. The skill emits `.env`-format paste-templates and runbooks. + +## Execution rules — read first + +**Step-by-step. One step per turn. Stop and wait at every irreversible boundary.** Don't bulk through. Don't preview the next step's questions. + +For each step: +1. **Announce** the step in one sentence. +2. **Run** the step's logic (subskill invocation, command, or paste-template). +3. **Pause** at irreversible boundaries (~10–12 hard pauses total). Trivial steps (printing a checklist, advancing past a confirmation) chain. +4. **End-of-turn:** at every hard pause, ask *"Ready for the next step: \?"* and wait for a yes/next/go signal. + +If the user replies with anything other than a clear go-signal, do not advance — answer them, fix what they flagged, then re-ask. + +## Sequence + +``` +0. Pre-flight → vercel whoami, existing-link check, team scope +1. vercel link → only if not already linked +2. init-jira (phase 1) → credentials + columns + JIRA_WEBHOOK_SECRET +3. init-vcs → branch on github | gitlab +4. init-agent → branch on claude | codex +5. init-slack +6. init-upstash → Marketplace install runbook +7. Inline: CRON_SECRET → auto-generate, paste-template +8. vercel env pull → produces .env.local +9. Validate → pnpm tsx --env-file=.env.local env.ts +10. vercel --prod → single production deploy +11. init-jira (phase 2) → webhook registration with deploy URL +12. Manual smoke → user drags a ticket, reports result +13. Final summary +``` + +--- + +## Step 0 — Pre-flight + +Run these in order. Halt with a clear message on any failure; never invoke `vercel login` from this skill. + +### 0a. Authentication + +```bash +vercel whoami +``` + +- **Fails:** HALT. Tell the user: *"Vercel CLI not authenticated. Run `vercel login`, then re-invoke `init-env`."* +- **OK:** record the current scope (team or personal) for step 0c. + +### 0b. Existing link + +```bash +test -f .vercel/project.json && cat .vercel/project.json +``` + +- **No link:** continue to step 0c. +- **Link present:** read its `orgId` / `projectId`. Print: *"Existing link found: scope=\ project=\. Use this link or relink?"* + - **Use:** skip step 1 entirely; carry this link forward. + - **Relink:** HALT. Tell the user: *"Remove `.vercel/project.json` (`rm .vercel/project.json`) and re-invoke `init-env`."* + +### 0c. Team-scope confirmation + +Compare the existing link's scope (if any) with `vercel whoami` output. If they differ, surface the mismatch explicitly. Otherwise: + +Print: *"Will link to team scope: \. Correct?"* + +- **No:** HALT. Tell the user: *"Run `vercel switch ` to change scope, then re-invoke `init-env`."* +- **Yes:** continue. + +→ **Stop. Ask:** *"Pre-flight passed. Ready for Step 1: `vercel link`?"* + +--- + +## Step 1 — `vercel link` + +Skip if step 0b found a usable existing link. + +```bash +vercel link +``` + +The CLI is interactive — let the user complete it. On success, `.vercel/project.json` is written. + +**Failure handling:** +- **Permission denied:** HALT. *"Account lacks access to project \. Ask an owner to grant access, or pick a different project."* +- **Project not found and user declined to create:** HALT. *"Re-invoke `init-env` and accept Vercel's offer to create the project."* +- **Network:** HALT. *"Vercel API unreachable. Check connection and re-invoke."* + +→ **Stop. Ask:** *"Linked. Ready for Step 2: Jira credentials and webhook secret?"* + +--- + +## Step 2 — Invoke `init-jira` (phase 1) + +Invoke the `init-jira` subskill via the Skill tool. It detects state and runs phase 1 because `JIRA_BASE_URL` is not yet set in Vercel: + +- Asks for `JIRA_BASE_URL` / `JIRA_EMAIL` / `JIRA_API_TOKEN` / `JIRA_PROJECT_KEY` / `COLUMN_AI` / `COLUMN_AI_REVIEW` / `COLUMN_BACKLOG`. +- **Pre-generates `JIRA_WEBHOOK_SECRET`** via `openssl rand -hex 32`. +- Emits a single `.env`-format paste-template. +- Walks the user through pasting into the Vercel dashboard (Project Settings → Environment Variables). +- Returns when phase 1 is complete (no verification — Decision 11 amended). + +→ **Stop. Ask:** *"Jira phase 1 done. Ready for Step 3: VCS provider?"* + +--- + +## Step 3 — Invoke `init-vcs` + +Invoke `init-vcs`. It asks **github or gitlab** and emits a single paste-template for the chosen provider. Cross-field rule (`env.ts`) enforced by construction — only the chosen branch's keys are emitted. + +→ **Stop. Ask:** *"VCS configured. Ready for Step 4: agent runtime?"* + +--- + +## Step 4 — Invoke `init-agent` + +Invoke `init-agent`. It asks **claude or codex** and emits a single paste-template for the chosen runtime. Defaults to API key; OAuth alternative is a documented swap in the runbook. + +→ **Stop. Ask:** *"Agent runtime configured. Ready for Step 5: Slack?"* + +--- + +## Step 5 — Invoke `init-slack` + +Invoke `init-slack`. It walks the user through creating the Slack app (or finding an existing bot token), the bot's `chat:write` scope, and the channel ID format. + +→ **Stop. Ask:** *"Slack configured. Ready for Step 6: Upstash Redis?"* + +--- + +## Step 6 — Invoke `init-upstash` + +Invoke `init-upstash`. It walks the user through the Vercel Marketplace install of Upstash for Redis, with the env-var prefix set to `AI_WORKFLOW_KV` so Vercel auto-injects the two keys `env.ts` expects. + +→ **Stop. Ask:** *"Upstash installed. Ready for Step 7: cron secret?"* + +--- + +## Step 7 — `CRON_SECRET` (inline) + +Generate locally and emit a one-line paste-template: + +```bash +openssl rand -hex 32 +``` + +Tell the user: +*"Paste this into Vercel → Project Settings → Environment Variables for all three environments (Production, Preview, Development):"* + +``` +CRON_SECRET= +``` + +Without `CRON_SECRET`, the cron endpoint at `/cron/poll` accepts unauthenticated callers (`src/routes/cron/poll.get.ts:40` returns early when unset). Vercel's auto-injected `Authorization: Bearer $CRON_SECRET` only protects the endpoint when the env var is set. + +→ **Stop. Ask:** *"`CRON_SECRET` set. Ready for Step 8: pull and validate?"* + +--- + +## Step 8 — `vercel env pull` and validate + +```bash +vercel env pull .env.local +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.). + +**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. + +→ **Stop. Ask:** *"Validator passed. Ready for Step 9: production deploy?"* + +--- + +## Step 9 — `vercel --prod` + +```bash +vercel --prod +``` + +This is the first production deploy. It will: +- Build the project against the env vars now in Vercel. +- Assign the stable production URL `.vercel.app`. +- Print the deploy URL on success. + +**On failure:** the orchestrator pauses (does not abort). Print the deploy error verbatim, and offer: +- *"Fix the issue (commonly a missing env var the validator can't detect, or a build error) and reply `redeploy` to re-run `vercel --prod`."* + +Do not auto-retry. Build errors usually need human intervention. + +→ **Stop. Ask:** *"Deployed. Ready for Step 10: register the Jira webhook?"* + +--- + +## Step 10 — Invoke `init-jira` (phase 2) + +Invoke `init-jira` again. State detection now sees: +- `JIRA_*` env vars present in Vercel. +- `.vercel/project.json` exists with project name. +- A successful production deploy (this step's outcome). + +Phase 2 derives the webhook URL from `.vercel/project.json` (`https://.vercel.app/webhooks/jira`) and walks the user through Jira's webhook admin UI. It uses the `JIRA_WEBHOOK_SECRET` already pasted in phase 1 — no redeploy needed because the handler reads the secret at request time. + +If the user opts to defer webhook registration (custom domain coming, admin permission missing, etc.), record it as a TODO for the final summary and continue. + +→ **Stop. Ask:** *"Webhook registered (or deferred). Ready for Step 11: smoke test?"* + +--- + +## Step 11 — Manual smoke + +Print: + +``` +Last step. Drop a test ticket in Jira to verify the bot end-to-end. + + 1. Open ${JIRA_BASE_URL}/jira/your-projects + 2. Create a small issue: + - Title: "Hello from Blazebot" + - Description: include an "Acceptance Criteria" block, e.g. + ## Acceptance Criteria + - The repo has a HELLO.md file + 3. Drag the ticket from the Backlog column to the AI column. + +Within ~5s (with webhook) or ~60s (cron fallback), expect: + - A bot comment on the ticket + - A branch `blazebot/` and a PR opened in your VCS + - A Slack message in your channel + - The ticket transitions to "AI Review" + +Reply when you've seen the PR (or "stuck on X" if a step is missing). +``` + +Wait for the user's response. If they report a failure, capture which milestone was missing and include it in the final summary. + +→ **Stop. Ask:** *"Smoke passed?"* + +--- + +## Step 12 — Final summary + +Print the summary template below, populated with the values gathered during the flow. Use the actual project name from `.vercel/project.json` and the user-reported smoke result. + +``` +Cold start complete. + +Linked Vercel project: / +Production URL: https://.vercel.app +Webhook URL: https://.vercel.app/webhooks/jira + +Configured: + Jira webhook + VCS / + Agent model + Slack channel bot @ + Upstash AI_WORKFLOW_KV prefix via Marketplace + Cron CRON_SECRET set schedule * * * * * + +Skipped (you can add these later): + - Arthur AI tracing — see https://www.arthur.ai/ for setup; both + GENAI_ENGINE_API_KEY and GENAI_ENGINE_TRACE_ENDPOINT, then run + `pnpm setup:arthur-prompts`. + - Custom domain — point a domain at the Vercel project for a stable + webhook URL (replace .vercel.app in Jira's webhook config). + - WORKFLOW_POSTGRES_URL — local dev only. + - VERCEL_TOKEN local PAT — local dev only; Vercel uses OIDC. + +Smoke test: + + +Maintenance: + Rotate one integration later by invoking that subskill standalone: + init-jira | init-vcs | init-agent | init-slack | init-upstash + + Inspect the deployment: + vercel logs --prod + https://vercel.com///observability + +No git changes were made. .env.local and .vercel/project.json are gitignored. +``` + +→ **Done.** Do not auto-commit, auto-push, or open a PR. The user owns git. + +--- + +## Don'ts + +- **Don't invoke `vercel login`.** It's an interactive, browser-launching flow the orchestrator can't observe. If pre-flight detects no auth, halt and tell the user. +- **Don't print or log secret values.** Reference them by name only. Pre-generated secrets (`JIRA_WEBHOOK_SECRET`, `CRON_SECRET`) appear once in the paste-template the user copies; never repeat them in summaries or logs. +- **Don't auto-retry failed deploys.** Pause, surface the error, let the user fix and reply `redeploy`. +- **Don't bulk through subskill invocations.** Each subskill is its own step; pause for the user's confirmation between them. +- **Don't auto-`vercel link` to a team without confirming.** Linking writes `.vercel/project.json` and binds future deploys. +- **Don't write `.env`.** Decision 12: skip `.env` entirely. `.env.local` (from `vercel env pull`) is the only local file. `.env.example` is committed reference. +- **Don't invent variables that aren't in `env.ts`.** If you need a new key, propose adding it to `env.ts` first. diff --git a/.claude/skills/init-jira/SKILL.md b/.claude/skills/init-jira/SKILL.md new file mode 100644 index 0000000..09e68ab --- /dev/null +++ b/.claude/skills/init-jira/SKILL.md @@ -0,0 +1,169 @@ +--- +name: init-jira +description: Set up or modify Jira configuration for the Blazebot workflow — credentials, project key, column statuses, workflow transitions, and webhook registration. State-aware: detects what's already in Vercel env and runs only the missing pieces. Use for "set up jira", "configure jira board", "rotate jira token", "register jira webhook", "fix jira transitions", "jira columns setup". +--- + +# Initialize Jira + +State-aware skill for the Jira side of Blazebot. Two phases triggered by detected state: + +- **Phase 1 — Credentials, columns, secret pre-gen.** Runs when `JIRA_BASE_URL` is not yet in Vercel env. +- **Phase 2 — Webhook registration.** Runs when phase 1 is done and a production deploy exists. + +> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Jira. + +## Precondition + +`.vercel/project.json` must exist (project must be linked). If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. Do not proceed. + +## State detection + +On entry, run: + +```bash +test -f .vercel/project.json && cat .vercel/project.json # project name +vercel env ls | grep -E "^(JIRA_BASE_URL|JIRA_API_TOKEN)" # phase 1 done? +vercel ls --prod # production deploy? +``` + +| `JIRA_*` set | Prod deploy | Action | +|---|---|---| +| no | — | Phase 1 | +| yes | no | Phase 1 already done; print "Webhook registration needs a production deploy first. Run `vercel --prod` then re-invoke." | +| yes | yes | Phase 2 | + +--- + +## Phase 1 — Credentials, columns, secret pre-gen + +### 1a. Has the Jira project been set up for Blazebot? + +Ask: *"Has your Jira board, statuses, and workflow transitions already been configured for Blazebot?"* + +- **No / unsure:** walk the user through these references in order, one per turn: + - `references/column-statuses.md` — statuses must exist in Jira and match `COLUMN_AI` / `COLUMN_AI_REVIEW` / `COLUMN_BACKLOG`. + - `references/transitions.md` — workflow transitions must be named exactly the same as the target statuses (the most-missed step). + - `references/description-format.md` — the "Acceptance Criteria" block in the description. +- **Yes:** continue. + +### 1b. Generate the webhook secret + +```bash +openssl rand -hex 32 +``` + +Hold the value for the paste-template below. Even if the user later defers webhook registration, having the secret in Vercel env now means no redeploy is needed when phase 2 runs. + +### 1c. Collect values + +Ask in one prompt (single credential bundle): + +- `JIRA_BASE_URL` — e.g. `https://acme.atlassian.net` (no trailing slash, no `/jira`) +- `JIRA_EMAIL` — the bot account's email +- `JIRA_API_TOKEN` — created at https://id.atlassian.com/manage-profile/security/api-tokens +- `JIRA_PROJECT_KEY` — e.g. `AWT` + +Then ask: + +- `COLUMN_AI` (default `AI`) +- `COLUMN_AI_REVIEW` (default `AI Review`) +- `COLUMN_BACKLOG` (default `Backlog`) + +If the user wants the defaults, fine; otherwise they must match Jira status names exactly (case-insensitive). + +### 1d. Emit paste-template + +Print this single block for the user to copy into Vercel → Project Settings → Environment Variables (set for **all three environments**: Production, Preview, Development): + +``` +JIRA_BASE_URL= +JIRA_EMAIL= +JIRA_API_TOKEN= +JIRA_PROJECT_KEY= +COLUMN_AI= +COLUMN_AI_REVIEW= +COLUMN_BACKLOG= +JIRA_WEBHOOK_SECRET= +``` + +`ISSUE_TRACKER_KIND` is omitted — `env.ts` defaults it to `jira`. + +Tell the user to paste, save, and reply when done. + +### 1e. Done + +Phase 1 complete. Tell the user: + +> Phase 1 done. Webhook registration will run after the first production deploy. + +If invoked from `init-env`, return control. If invoked standalone, end the turn. + +--- + +## Phase 2 — Webhook registration + +### 2a. Derive the webhook URL + +Read `.vercel/project.json` to get the project name. Construct: + +``` +https://.vercel.app/webhooks/jira +``` + +If the user has a custom domain configured for production traffic, they should swap the host themselves after registration. Note this in the runbook output but don't try to detect domains automatically. + +### 2b. Walk the registration runbook + +Hand the user `references/webhook-setup.md`. The TL;DR: + +1. Open `${JIRA_BASE_URL}/plugins/servlet/webhooks` (e.g. `https://acme.atlassian.net/plugins/servlet/webhooks`). +2. Click **Create a WebHook**. +3. Fill: + - Name: `Blazebot dispatch` + - Status: `Enabled` + - URL: the webhook URL from 2a + - Secret: the `JIRA_WEBHOOK_SECRET` already in Vercel env (re-fetch with `vercel env ls` if the user needs to confirm it's set) + - JQL filter: `project = ""` + - Events: check **Issue → Issue updated** (only this one) + - Exclude body: leave **unchecked** +4. Save. + +The handler at `src/routes/webhooks/jira.post.ts` verifies the `X-Hub-Signature` HMAC. If `JIRA_WEBHOOK_SECRET` is unset in env, the handler skips signature verification — which is wrong for production. + +### 2c. Verify + +Tell the user to drag any ticket into the AI column. They should see (in `vercel logs --prod`): + +``` +webhook_received ticketKey=... +webhook_payload_parsed webhookEvent=jira:issue_updated payloadStatus=AI +webhook_dispatch_started +webhook_dispatch_result started=true runId=... +``` + +If they get `401 Invalid webhook signature`, the secret in Jira and in Vercel env don't match — copy from `vercel env ls` again. + +### 2d. Defer path + +If the user cannot register the webhook now (admin permission missing, custom domain pending, etc.), record this as a TODO. The bot still works via cron poll fallback (~60s lag per ticket). + +If invoked from `init-env`, return control with the TODO flag set. If standalone, print the deferred message and end. + +--- + +## Troubleshooting + +For diagnostic flows after phase 2 (signature failures, transition errors, missing PR), see `references/troubleshooting.md`. + +## Don'ts + +- Don't print the webhook secret value back to chat after generating it. Reference by name. +- Don't try to detect custom domains from `.vercel/project.json` — that file doesn't carry domain info reliably. Default to `.vercel.app` and tell the user to swap if they have a custom domain. +- Don't skip the **Issue updated** event filtering — subscribing to all events floods the handler with noise that gets filtered away anyway. diff --git a/.claude/skills/init-jira/references/column-statuses.md b/.claude/skills/init-jira/references/column-statuses.md new file mode 100644 index 0000000..9425463 --- /dev/null +++ b/.claude/skills/init-jira/references/column-statuses.md @@ -0,0 +1,47 @@ +# Column statuses — the most common silent failure + +Blazebot polls Jira with this JQL every minute: + +```jql +project = "$JIRA_PROJECT_KEY" AND status = "$COLUMN_AI" +``` + +That means **`COLUMN_AI`, `COLUMN_AI_REVIEW`, and `COLUMN_BACKLOG` must be Jira *status* names, exact case-insensitive match — not just board column labels.** A board column whose underlying status differs will silently never match. + +## Defaults + +| Env var | Status name | Purpose | +|---|---|---| +| `COLUMN_AI` | `AI` | Tickets in this status get picked up by the agent | +| `COLUMN_AI_REVIEW` | `AI Review` | Tickets the agent has finished and pushed a PR for | +| `COLUMN_BACKLOG` | `Backlog` | Where the agent parks tickets needing clarification | + +You can rename these — just keep Vercel env in sync. + +## Create or rename the three statuses + +**Team-managed project:** Project settings → Board → Columns. Click **Add status** or rename inline. Each board column maps 1:1 to a status by default. + +**Company-managed project:** statuses live globally. Jira Settings → Issues → Statuses. Create the three names (or reuse existing — `Backlog` usually exists). Then in Project settings → Workflows, edit the workflow used by your issue type and add the new statuses. + +## Map them to board columns + +Open the board → **... → Configure board → Columns**: + +``` +┌──────────┬────────┬───────────┬──────────┐ +│ Backlog │ AI │ AI Review │ Done │ +└──────────┴────────┴───────────┴──────────┘ +``` + +Each column should contain exactly one status with the matching name. Don't put `AI` and `In Progress` in the same column — Blazebot's JQL hits status, not column, but humans seeing the board will be confused. + +## Verify the status spelling + +```bash +curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \ + "$JIRA_BASE_URL/rest/api/3/project/$JIRA_PROJECT_KEY/statuses" | \ + jq '.[].statuses[].name' +``` + +Output must include the exact strings used in `COLUMN_AI` / `COLUMN_AI_REVIEW` / `COLUMN_BACKLOG` (case-insensitive). Trailing spaces in either side will silently break JQL — strip them. diff --git a/.claude/skills/init-jira/references/description-format.md b/.claude/skills/init-jira/references/description-format.md new file mode 100644 index 0000000..6d70217 --- /dev/null +++ b/.claude/skills/init-jira/references/description-format.md @@ -0,0 +1,41 @@ +# Description format — the Acceptance Criteria block + +The agent reads `summary`, `description`, `comments`, and `attachments`. The description has one special section. + +## Acceptance Criteria block + +`extractAcceptanceCriteria` (`src/adapters/issue-tracker/jira.ts:202`) runs this regex on the description text: + +```regex +/acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i +``` + +Anything between the words "Acceptance Criteria" and the next blank line or `# heading` becomes the AC block in the agent prompt. Outside that block, description text is still available to the agent — but AC is what gets pulled into a structured field. + +## Recommended description template + +```markdown +## Context +Why this work matters, links to related tickets / Slack threads. + +## Acceptance Criteria +- User can do X +- Endpoint returns 4xx when Y +- Existing test `foo.test.ts` still passes + +## Notes +Implementation hints, files to look at, gotchas. +``` + +The agent will see the whole description; the AC list just gets a slot at the top of `requirements.md`. + +## What the agent does NOT see + +- **Custom fields** (Story Points, Epic Link, Sprint, etc.) — only `summary`, `description`, `comment`, `labels`, `status`, `project`, `attachment` are fetched. Put implementation-relevant info in the description. +- **Linked issues** — not followed. Inline relevant content. +- **Sub-tasks** — not fetched. Either inline or merge before sending. +- **Confluence pages** — not fetched. Paste relevant excerpts into the description. + +## Attachments + +Images, text files, and binaries are downloaded into the sandbox up to the env-configured limits (`ATTACHMENT_MAX_FILE_SIZE_MB`, `ATTACHMENT_MAX_TOTAL_SIZE_MB`, `ATTACHMENT_MAX_COUNT`, `ATTACHMENT_DOWNLOAD_TIMEOUT_MS`). Defaults: per-file 25 MB, total 100 MB, max 20 files, 30s timeout. Useful for handing the agent design mocks, error screenshots, or sample CSVs. diff --git a/.claude/skills/init-jira/references/transitions.md b/.claude/skills/init-jira/references/transitions.md new file mode 100644 index 0000000..5a30559 --- /dev/null +++ b/.claude/skills/init-jira/references/transitions.md @@ -0,0 +1,46 @@ +# Transitions — the second silent failure + +Single most-missed step in Jira setup. The adapter at `src/adapters/issue-tracker/jira.ts:86` finds a transition by **transition name** equal to the target column name: + +```ts +data.transitions.find(t => t.name.toLowerCase() === column.toLowerCase()) +``` + +So your workflow must have transitions whose **names** are exactly `AI`, `AI Review`, and `Backlog` (or whatever you renamed `COLUMN_*` to). + +## Required transitions + +| From | To | Transition name | Triggered by | +|------|-----|-----------------|--------------| +| `Backlog` (or any) | `AI` | `AI` | Human (drags ticket to start agent) | +| `AI` | `AI Review` | `AI Review` | Blazebot (agent finished, PR pushed) | +| `AI` | `Backlog` | `Backlog` | Blazebot (agent needs clarification) | +| `AI Review` | `Backlog` or `AI` | n/a | Human (re-loop after review) | + +The "Human" rows just need to exist in the UI — the bot doesn't trigger them. The "Blazebot" rows must exist **and** be named exactly per the table. Rename existing transitions if needed. + +## Edit the workflow + +**Team-managed:** Project settings → Workflow → click the arrow between two statuses → rename via the field at the top of the side panel. Save. + +**Company-managed:** Project settings → Workflows → Edit (or Jira Settings → Workflows for shared workflows). Switch to **Diagram** view → click the transition arrow → rename → **Publish Draft**. + +If the source status doesn't have an outbound transition to the target, draw a new one first, then rename it. + +## Verify transitions are present + +For an issue currently in `AI`: + +```bash +curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \ + "$JIRA_BASE_URL/rest/api/3/issue/$JIRA_PROJECT_KEY-1/transitions" | \ + jq '.transitions[] | {name, to: .to.name}' +``` + +You should see entries with `"name": "AI Review"` (to → `AI Review`) and `"name": "Backlog"` (to → `Backlog`). If a transition's `name` is something like `"Move to AI Review"`, the bot fails with `No transition to "AI Review" found for issue …`. Rename to fix. + +## Common workflow pitfalls + +- **Transition has conditions** (e.g. "only assignee can transition") — Blazebot's account will be blocked. Remove the condition, or assign every ticket to the bot account before the AI status. +- **Transition has a screen** (post-function asking for input) — the API call succeeds but the screen pops for the next human; harmless. Remove the screen if you want clean tickets. +- **Validators on transition** (e.g. "resolution required") — API call fails 400. Disable the validator or pre-populate the field via Automation. diff --git a/.claude/skills/init-jira/references/troubleshooting.md b/.claude/skills/init-jira/references/troubleshooting.md new file mode 100644 index 0000000..ae162f5 --- /dev/null +++ b/.claude/skills/init-jira/references/troubleshooting.md @@ -0,0 +1,22 @@ +# Jira troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| Bot never picks up a ticket | Status name ≠ `COLUMN_AI` env value | Run the `/statuses` curl from `column-statuses.md` and reconcile. | +| `No transition to "AI Review" found` | Transition name in workflow is e.g. `Move to AI Review` | Rename transition. See `transitions.md`. | +| `401 Invalid webhook signature` | Secret mismatch | Re-copy `JIRA_WEBHOOK_SECRET` to both Vercel env and Jira webhook config. | +| Agent produces empty AC | Description has no `Acceptance Criteria:` block | Edit ticket description. See `description-format.md`. | +| 403 on transition | Permission scheme blocks bot account | Grant `Transition issues` to the bot's role. | +| 400 on transition with `errorMessages: ["Resolution required"]` | Transition has a validator | Disable validator or pre-set Resolution via Automation. | +| Ticket stuck in `AI` after PR exists | Reconcile thinks run is orphaned | Check Vercel logs for `reconcile_orphan_run`; usually self-heals on next cron tick. | + +## Lock down the API token + +Atlassian tokens are bearer credentials — anyone holding token + email can act as the user. + +- **Use a dedicated bot account**, not a human's, so revocation doesn't lock anyone out. +- **Rotate quarterly** (`/manage-profile/security/api-tokens` → revoke + recreate; redeploy with new env). +- **Restrict the bot account's project access** to just the Blazebot project (Project settings → People → remove from other projects). +- **Audit comments**: every AI-driven comment is authored by the bot account — easy to filter in Jira's activity view by user. + +For higher-trust setups, switch to Atlassian OAuth 2.0 (3LO) — but that's not currently supported by `src/adapters/issue-tracker/jira.ts` (uses Basic auth). diff --git a/.claude/skills/init-jira/references/webhook-setup.md b/.claude/skills/init-jira/references/webhook-setup.md new file mode 100644 index 0000000..a5935be --- /dev/null +++ b/.claude/skills/init-jira/references/webhook-setup.md @@ -0,0 +1,52 @@ +# Webhook setup — phase 2 detail + +**Don't skip this if you can avoid it.** Without webhooks, Blazebot only polls every minute via cron, so every ticket waits up to ~60 seconds before the agent even *starts*. With webhooks, dispatch is sub-second. + +## Why "Issue updated" only + +The handler dispatches when the ticket lands in `COLUMN_AI` and cancels in-flight runs when it leaves. Both cases are detected on `jira:issue_updated`. Subscribing to `created`, `deleted`, etc. just adds noise that gets filtered away — the handler ignores anything without a project-key match or without an issue key. + +## Open the webhook admin page + +``` +${JIRA_BASE_URL}/plugins/servlet/webhooks +``` + +Concrete: `https://acme.atlassian.net/plugins/servlet/webhooks`. + +If you land on a "you don't have permission" page, you need **site admin** or **Jira admin** rights — grab someone with admin or have admin grant you the role. + +Manual menu fallback: gear icon (⚙) at top-right → System → WebHooks (under "Advanced"). + +## Fields + +| Field | Value | +|---|---| +| Name | `Blazebot dispatch` | +| Status | `Enabled` | +| URL | `https://.vercel.app/webhooks/jira` (use your custom domain if you have one) | +| Secret | the value already in Vercel env as `JIRA_WEBHOOK_SECRET` | +| JQL filter | `project = ""` | +| Events | check **Issue → Issue updated** (only this one) | +| Exclude body | leave **unchecked** | + +Save. + +## Local testing without exposing your laptop + +Use `vercel dev` + a tunnel like `cloudflared tunnel --url http://localhost:3000` or `ngrok http 3000`, then point the Jira webhook URL at the public tunnel. Or skip webhooks locally — the cron poller picks tickets up within ~1 minute (`POLL_INTERVAL_MS`). + +## Verify + +In Jira, drag any ticket into the AI column. In `vercel logs --prod`: + +``` +webhook_received ticketKey=AWT-42 +webhook_payload_parsed webhookEvent=jira:issue_updated payloadStatus=AI +webhook_dispatch_started +webhook_dispatch_result started=true runId=... +``` + +If you see `401 Invalid webhook signature`, the secret in Jira and Vercel env don't match. Re-copy. + +If you see no webhook log at all, the JQL filter or events checkbox is wrong. Check Webhooks → [your hook] → Last delivery in Jira admin. diff --git a/.claude/skills/init-slack/SKILL.md b/.claude/skills/init-slack/SKILL.md new file mode 100644 index 0000000..c5e8c60 --- /dev/null +++ b/.claude/skills/init-slack/SKILL.md @@ -0,0 +1,64 @@ +--- +name: init-slack +description: Configure or rotate the Slack bot integration for Blazebot notifications — bot token, channel ID, bot name. Use for "set up slack bot", "rotate slack token", "change slack channel", "configure blazebot slack". +--- + +# Initialize Slack + +Configures the Slack bot Blazebot uses to post status updates (run started, PR opened, run failed, etc.) to a single channel. + +> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Slack. + +## Precondition + +`.vercel/project.json` must exist. If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. + +## Step 1 — Bot app and token + +If a Blazebot Slack app already exists in the workspace, the user just needs the bot token and a channel ID — skip to step 2. + +Otherwise, walk the user through `references/bot-app-setup.md` to create the Slack app with the right scopes. + +## Step 2 — Collect values + +Ask: + +- `CHAT_SDK_SLACK_TOKEN` — bot token, starts with `xoxb-` +- `CHAT_SDK_CHANNEL_ID` — channel ID like `C0123456789` (not `#channel-name`) +- `CHAT_SDK_BOT_NAME` — defaults to `blazebot`; only ask if the user wants to override + +### Finding the channel ID + +The user-friendly `#channel-name` doesn't work — Blazebot needs the `C…` ID. Two ways to find it: + +- Open the channel in Slack web → URL ends in `/C0123456789`. That's the ID. +- Right-click channel in Slack desktop → "View channel details" → bottom of the modal shows the ID. + +The bot must be invited to the channel: `/invite @blazebot` from inside the channel. Otherwise messages 403. + +## Step 3 — Emit paste-template + +``` +CHAT_SDK_SLACK_TOKEN= +CHAT_SDK_CHANNEL_ID= +``` + +If non-default bot name: +``` +CHAT_SDK_BOT_NAME= +``` + +Tell the user to paste into Vercel → Project Settings → Environment Variables (all three environments), save, and reply when done. + +## Don'ts + +- **Don't accept a `xoxp-` user token.** Blazebot needs a bot token (`xoxb-`). User tokens have different permission semantics and will silently fail in some adapter paths. +- **Don't accept a channel name (`#whatever`) as the channel ID.** The Slack API requires the ID. Save the user the silent-failure debug session. +- **Don't print the token after collecting it.** Reference by name only. diff --git a/.claude/skills/init-slack/references/bot-app-setup.md b/.claude/skills/init-slack/references/bot-app-setup.md new file mode 100644 index 0000000..d0e9fd0 --- /dev/null +++ b/.claude/skills/init-slack/references/bot-app-setup.md @@ -0,0 +1,68 @@ +# Slack bot app setup + +If your workspace already has a Blazebot app, skip this — get the existing token and channel from your Slack admin. + +## Create the app + +1. Open https://api.slack.com/apps → **Create New App** → **From scratch**. +2. App Name: `Blazebot` (or whatever you like). +3. Pick the workspace. +4. Click **Create App**. + +## Add bot scopes + +In the app settings sidebar: + +1. **OAuth & Permissions** → **Scopes** → **Bot Token Scopes** → **Add an OAuth Scope**. +2. Add these scopes: + - `chat:write` — required. Lets the bot post messages. + - `chat:write.public` — optional. Lets the bot post in public channels it isn't a member of. Skip if you'll always invite the bot. + - `users:read` — optional. For mentioning specific users in messages. + +Only `chat:write` is hard-required. + +## Install the app to the workspace + +1. Still on **OAuth & Permissions** → **Install to Workspace** → **Allow**. +2. After install, copy the **Bot User OAuth Token** at the top of the page. It starts with `xoxb-`. This is `CHAT_SDK_SLACK_TOKEN`. + +## Invite the bot to the channel + +In the Slack channel you want bot messages in: + +``` +/invite @blazebot +``` + +Without this, messages 403. (Workaround: add `chat:write.public` scope and skip the invite, but cleaner to just invite.) + +## Find the channel ID + +- Open the channel in Slack web → URL is `https://app.slack.com/client/T.../C0123456789`. That `C…` is the ID. +- Or: right-click channel → **View channel details** → bottom of modal shows the ID. + +This is `CHAT_SDK_CHANNEL_ID`. + +## Verify + +```bash +curl -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $CHAT_SDK_SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "{\"channel\":\"$CHAT_SDK_CHANNEL_ID\",\"text\":\"hello from blazebot setup\"}" +``` + +Should return `{"ok":true,...}` and a message appears in the channel. + +Common errors: +- `not_in_channel` — invite the bot. +- `channel_not_found` — wrong ID format (used `#name` instead of `C…`). +- `invalid_auth` — wrong token. +- `missing_scope` — `chat:write` not added. + +## Rotation + +Slack bot tokens don't expire but can be revoked. To rotate: + +1. App settings → **OAuth & Permissions** → **Reissue token** (under Bot User OAuth Token). +2. Update Vercel env, redeploy. diff --git a/.claude/skills/init-upstash/SKILL.md b/.claude/skills/init-upstash/SKILL.md new file mode 100644 index 0000000..66da1da --- /dev/null +++ b/.claude/skills/init-upstash/SKILL.md @@ -0,0 +1,62 @@ +--- +name: init-upstash +description: Configure the Upstash Redis run registry for Blazebot via the Vercel Marketplace. Verifies the AI_WORKFLOW_KV prefix and the auto-injected env vars. Use for "set up redis", "configure upstash", "install upstash marketplace", "fix run registry". +--- + +# Initialize Upstash Redis + +Walks the user through installing **Upstash for Redis** from the Vercel Marketplace with the env-var prefix set to `AI_WORKFLOW_KV` so Vercel auto-injects the two keys `env.ts` expects: + +- `AI_WORKFLOW_KV_REST_API_URL` +- `AI_WORKFLOW_KV_REST_API_TOKEN` + +Blazebot uses Redis as a run registry — a small key-value store tracking active workflow runs per ticket, used by reconcile and webhook cancellation. + +> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles Upstash. + +## Precondition + +`.vercel/project.json` must exist. If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. + +## Step 1 — Marketplace install + +Walk the user through these clicks (Vercel dashboard, dashboard install is faster than CLI): + +1. Open https://vercel.com/dashboard → pick the linked project. +2. Click **Storage** in the project sidebar. +3. **Browse Marketplace** → search "Upstash for Redis" → **Open**. +4. **Add to project** → pick the linked project. +5. **Choose plan** — Free tier is fine for getting started. +6. **Connect Project** → on the connection screen, look for **"Environment Variables Prefix"** (or similar wording) and **set it to `AI_WORKFLOW_KV`**. This is the critical step — without the right prefix, Vercel injects keys named `KV_REST_API_URL` etc. which `env.ts` doesn't recognize. +7. Confirm the install. Vercel auto-injects `AI_WORKFLOW_KV_REST_API_URL` and `AI_WORKFLOW_KV_REST_API_TOKEN` for all three environments. + +## Step 2 — Confirm the keys landed + +Tell the user to confirm in Vercel → Project Settings → Environment Variables that they see: + +- `AI_WORKFLOW_KV_REST_API_URL` (value: `https://.upstash.io`) +- `AI_WORKFLOW_KV_REST_API_TOKEN` + +If the keys are named differently (e.g. `KV_REST_API_URL` without the `AI_WORKFLOW_KV` prefix), the prefix wasn't set correctly during install. Two recovery paths: + +- **Easier:** uninstall the Upstash integration (Storage → Upstash → Disconnect), reinstall with the correct prefix. +- **Manual rename:** rename the env vars in Vercel from `KV_*` to `AI_WORKFLOW_KV_*`. Works but the integration won't keep them in sync if Upstash later rotates the underlying values. + +## Step 3 — Done + +No paste-template needed — keys are auto-injected by Vercel. The end-of-flow validator (in `init-env`) confirms they made it. + +If invoked from `init-env`, return control. If standalone, end. + +## Don'ts + +- **Don't manually create an Upstash database outside the Marketplace.** You'd lose the auto-injection benefit and have to manage env vars by hand. The Marketplace integration is preferred per Decision 6. +- **Don't change the prefix after install.** Vercel rewrites the env keys on the integration's behalf; if you rename them manually, Upstash's update flow gets confused. +- **Don't try to use Upstash REST URL/token from a non-Vercel deployment.** They work — but you'd be bypassing the Marketplace integration's billing and quota limits. diff --git a/.claude/skills/init-vcs/SKILL.md b/.claude/skills/init-vcs/SKILL.md new file mode 100644 index 0000000..460eb3b --- /dev/null +++ b/.claude/skills/init-vcs/SKILL.md @@ -0,0 +1,82 @@ +--- +name: init-vcs +description: Configure or rotate the VCS provider (GitHub or GitLab) for the Blazebot workflow. Branches on provider choice and emits a single paste-template for that provider only. Use for "set up github", "set up gitlab", "rotate github token", "switch vcs provider", "configure vcs". +--- + +# Initialize VCS provider + +Branch-on-choice skill. Asks **GitHub or GitLab**, then emits a single paste-template for the chosen provider. The cross-field rule in `env.ts` (`VCS_KIND=github` requires `GITHUB_TOKEN` + `GITHUB_OWNER` + `GITHUB_REPO`; `VCS_KIND=gitlab` requires `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`) is enforced by construction — only the chosen branch's keys are emitted. + +> If you want full project setup (Jira + VCS + Agent + Slack + Upstash + deploy), invoke `init-env` instead. This skill only handles VCS. + +## Precondition + +`.vercel/project.json` must exist. If missing: + +``` +ERROR: no Vercel project linked. Run `vercel link` first, or invoke `init-env` +for the full first-time setup. +``` + +Halt. + +## Step 1 — Pick provider + +Ask: *"GitHub or GitLab?"* + +If switching from a previously-configured provider, the user should also remove the old branch's keys from Vercel (`GITHUB_*` if switching to GitLab, vice versa). Print a one-line warning and let them handle it. + +## Step 2 — Emit paste-template + +### GitHub branch + +Walk the user through `references/github-pat.md` to mint a token, find owner/repo. Then collect: + +- `GITHUB_TOKEN` (PAT with `repo` scope) +- `GITHUB_OWNER` (org or user) +- `GITHUB_REPO` (just the repo name) +- `GITHUB_BASE_BRANCH` (default `main`) + +Emit (paste into Vercel → Project Settings → Environment Variables, all three environments): + +``` +VCS_KIND=github +GITHUB_TOKEN= +GITHUB_OWNER= +GITHUB_REPO= +GITHUB_BASE_BRANCH=main +``` + +### GitLab branch + +Walk the user through `references/gitlab-pat.md` to mint a token. Then collect: + +- `GITLAB_TOKEN` (`glpat-...`) +- `GITLAB_PROJECT_ID` (e.g. `your-group/your-repo`, or numeric ID — both work) +- `GITLAB_BASE_BRANCH` (default `main`) +- `GITLAB_HOST` (skip for `gitlab.com`; set for self-hosted) + +Emit: + +``` +VCS_KIND=gitlab +GITLAB_TOKEN= +GITLAB_PROJECT_ID= +GITLAB_BASE_BRANCH=main +``` + +If self-hosted, append: +``` +GITLAB_HOST=https://gitlab.example.com +``` + +## Step 3 — Done + +Tell the user to paste, save, and reply when done. No verification — `init-env`'s end-of-flow validator catches missing/malformed values. + +If invoked from `init-env`, return control. If standalone, end. + +## Don'ts + +- **Don't emit both branches.** Cross-field validation in `env.ts` will fail at validate time, but emitting both invites the user to paste both, leaving stale keys in Vercel even if validation passes (it does — only one set is *required*, the other is harmless until it's not). +- **Don't print the token after collecting it.** Reference by name only. diff --git a/.claude/skills/init-vcs/references/github-pat.md b/.claude/skills/init-vcs/references/github-pat.md new file mode 100644 index 0000000..2a18f47 --- /dev/null +++ b/.claude/skills/init-vcs/references/github-pat.md @@ -0,0 +1,43 @@ +# GitHub Personal Access Token + +## Mint the token + +1. Open https://github.com/settings/tokens/new (classic) or https://github.com/settings/personal-access-tokens/new (fine-grained). +2. **Classic PAT** — simpler: + - Note: `blazebot` + - Expiration: 90 days (set a calendar reminder) + - Scopes: check **`repo`** (full control of private repositories). That single scope grants everything Blazebot needs: read/write code, branches, PRs, issues. +3. **Fine-grained PAT** — narrower blast radius: + - Resource owner: the org or user that owns the repo + - Repository access: **Only select repositories** → pick your repo + - Permissions: + - Contents: **Read and write** + - Pull requests: **Read and write** + - Metadata: **Read-only** (auto-required) +4. Click **Generate token** and copy it immediately — GitHub won't show it again. + +## Find owner and repo + +If your repo URL is `https://github.com/acme-corp/blazebot-target`: +- `GITHUB_OWNER=acme-corp` +- `GITHUB_REPO=blazebot-target` + +Don't put the slash or the full URL — just the two halves separately. + +## Verify + +```bash +curl -H "Authorization: Bearer $GITHUB_TOKEN" \ + https://api.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO | \ + jq '.full_name, .permissions' +``` + +Should print the repo's `full_name` and a permissions object showing `push: true`. A 401 means the token is wrong; a 404 means the token works but lacks access to that repo (fine-grained PAT scoped to a different repo, etc.). + +## Rotation + +PATs are bearer credentials. Rotate quarterly: +1. Mint a new token. +2. Update Vercel env (`vercel env rm GITHUB_TOKEN production && vercel env add GITHUB_TOKEN production`). +3. Redeploy: `vercel --prod`. +4. Revoke the old token in GitHub settings. diff --git a/.claude/skills/init-vcs/references/gitlab-pat.md b/.claude/skills/init-vcs/references/gitlab-pat.md new file mode 100644 index 0000000..f9c1526 --- /dev/null +++ b/.claude/skills/init-vcs/references/gitlab-pat.md @@ -0,0 +1,41 @@ +# GitLab Personal Access Token + +## Mint the token + +1. Open https://gitlab.com/-/user_settings/personal_access_tokens (or `/-/user_settings/personal_access_tokens` for self-hosted). +2. Token name: `blazebot` +3. Expiration: 90 days (set a calendar reminder) +4. Scopes: check **`api`** — this grants read/write to repository, MRs, issues. (`read_api` + `write_repository` is narrower but Blazebot's adapter currently expects full `api`.) +5. Click **Create personal access token** and copy it immediately. The token starts with `glpat-`. + +## Find `GITLAB_PROJECT_ID` + +Two formats both work: + +- **Path with namespace:** `acme-corp/blazebot-target` (mirrors the URL `https://gitlab.com/acme-corp/blazebot-target`) +- **Numeric ID:** find at Settings → General → Project ID (top of page) + +Path format is more readable; numeric ID is stable across renames. + +## `GITLAB_HOST` + +- **gitlab.com:** skip the var (defaults to `https://gitlab.com`). +- **Self-hosted:** set `GITLAB_HOST=https://gitlab.example.com` (no trailing slash, no `/api/v4`). + +## Verify + +```bash +curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \ + "$GITLAB_HOST/api/v4/projects/$(printf '%s' "$GITLAB_PROJECT_ID" | jq -sRr @uri)" | \ + jq '.path_with_namespace, .permissions' +``` + +Should print the project path and permissions. A 401 → bad token. A 404 → token works but no access to that project. + +## Rotation + +`glpat-` tokens are bearer credentials. Rotate quarterly: +1. Mint a new token in GitLab. +2. Update Vercel env (`vercel env rm GITLAB_TOKEN production && vercel env add GITLAB_TOKEN production`). +3. Redeploy: `vercel --prod`. +4. Revoke the old token in GitLab settings. diff --git a/.env.example b/.env.example index 3f3fb91..f11af98 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Issue Tracker (Jira) -ISSUE_TRACKER_KIND=jira +# ISSUE_TRACKER_KIND defaults to "jira" (only supported tracker today). JIRA_BASE_URL=https://your-domain.atlassian.net JIRA_EMAIL=your-email@example.com JIRA_API_TOKEN=your-jira-api-token @@ -9,6 +9,10 @@ COLUMN_AI=AI COLUMN_AI_REVIEW=AI Review COLUMN_BACKLOG=Backlog +# Jira Webhook (strongly recommended — generate with `openssl rand -hex 32`). +# Without this, dispatch falls back to ~60s cron polling on every ticket. +JIRA_WEBHOOK_SECRET= + # VCS — choose one provider by setting VCS_KIND to "github" or "gitlab". # Only ONE VCS_KIND line should be active in this file. VCS_KIND=github @@ -30,25 +34,38 @@ CHAT_SDK_SLACK_TOKEN=xoxb-xxxxxxxxxxxx CHAT_SDK_CHANNEL_ID=C0123456789 CHAT_SDK_BOT_NAME=blazebot -# Agent -ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx -CLAUDE_MODEL=claude-opus-4-6 -COMMIT_AUTHOR=ai-workflow-blazity -COMMIT_EMAIL=ai-workflow@blazity.com - # Agent — choose runtime (claude | codex). Defaults to claude. AGENT_KIND=claude -# Codex (only when AGENT_KIND=codex) +# 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) # CODEX_API_KEY= -# CODEX_CHATGPT_OAUTH_TOKEN= # alternative to CODEX_API_KEY +# CODEX_CHATGPT_OAUTH_TOKEN= # CODEX_MODEL=gpt-5-codex # CODEX_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json # CODEX_PRICING_TTL_MS=3600000 -# Arthur AI Engine (optional — tracing + hosted prompts) +COMMIT_AUTHOR=ai-workflow-blazity +COMMIT_EMAIL=ai-workflow@blazity.com + +# Upstash Redis (run registry). +# On Vercel: install the Upstash for Redis Marketplace integration with the +# env-var prefix AI_WORKFLOW_KV — these two keys are auto-injected by Vercel. +AI_WORKFLOW_KV_REST_API_URL= +AI_WORKFLOW_KV_REST_API_TOKEN= + +# Cron auth — required in production so /cron/poll rejects unauthenticated callers. +# Generate with `openssl rand -hex 32`. +CRON_SECRET= + +# Arthur AI Engine (optional — tracing + hosted prompts). # Set both API_KEY and TRACE_ENDPOINT to install the tracer into every sandbox. # Set PROMPT_TASK_ID after running `npx tsx scripts/setup-arthur-prompts.ts`. +# See https://www.arthur.ai/ for setup. # GENAI_ENGINE_API_KEY= # GENAI_ENGINE_TRACE_ENDPOINT=https://your-arthur-host/api/v1/traces # GENAI_ENGINE_PROMPT_TASK_ID= @@ -66,13 +83,10 @@ JOB_TIMEOUT_MS=1800000 # Polling POLL_INTERVAL_MS=300000 -# Vercel Sandbox (local dev only — on Vercel, OIDC authenticates automatically) +# Vercel Sandbox (LOCAL DEV ONLY — on Vercel, OIDC authenticates automatically) # VERCEL_TOKEN= # VERCEL_TEAM_ID= # VERCEL_PROJECT_ID= -# Cron auth -CRON_SECRET= - -# Workflow (local dev only) -WORKFLOW_POSTGRES_URL=postgresql://localhost:5432/ai_workflow +# Workflow (LOCAL DEV ONLY — Vercel deployments use managed state) +# WORKFLOW_POSTGRES_URL=postgresql://localhost:5432/ai_workflow diff --git a/env.ts b/env.ts index 8623e6b..c52b804 100644 --- a/env.ts +++ b/env.ts @@ -10,7 +10,7 @@ export const env = createEnv({ }, server: { // Issue Tracker - ISSUE_TRACKER_KIND: z.enum(["jira"]), + ISSUE_TRACKER_KIND: z.literal("jira").default("jira"), JIRA_BASE_URL: z.string().url(), JIRA_EMAIL: z.string().email(), JIRA_API_TOKEN: z.string().min(1), From c9cb3099d9dfd144ed74af8ac6b50d70519446d0 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 5 May 2026 11:46:03 +0200 Subject: [PATCH 2/4] feat: add bidirectional slack integration --- .claude/skills/init-slack/SKILL.md | 18 ++ .../init-slack/references/slash-commands.md | 74 ++++++ README.md | 8 +- .../plans/2026-05-01-slack-slash-commands.md | 141 ++++++++++ env.test.ts | 1 + env.ts | 5 + src/lib/slack/commands.test.ts | 81 ++++++ src/lib/slack/commands.ts | 29 +++ src/lib/slack/format.test.ts | 67 +++++ src/lib/slack/format.ts | 39 +++ src/lib/slack/handlers.test.ts | 150 +++++++++++ src/lib/slack/handlers.ts | 60 +++++ src/lib/slack/respond.ts | 43 ++++ src/lib/slack/verify.test.ts | 121 +++++++++ src/lib/slack/verify.ts | 39 +++ src/routes/webhooks/slack.post.test.ts | 240 ++++++++++++++++++ src/routes/webhooks/slack.post.ts | 162 ++++++++++++ 17 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/init-slack/references/slash-commands.md create mode 100644 docs/superpowers/plans/2026-05-01-slack-slash-commands.md create mode 100644 src/lib/slack/commands.test.ts create mode 100644 src/lib/slack/commands.ts create mode 100644 src/lib/slack/format.test.ts create mode 100644 src/lib/slack/format.ts create mode 100644 src/lib/slack/handlers.test.ts create mode 100644 src/lib/slack/handlers.ts create mode 100644 src/lib/slack/respond.ts create mode 100644 src/lib/slack/verify.test.ts create mode 100644 src/lib/slack/verify.ts create mode 100644 src/routes/webhooks/slack.post.test.ts create mode 100644 src/routes/webhooks/slack.post.ts diff --git a/.claude/skills/init-slack/SKILL.md b/.claude/skills/init-slack/SKILL.md index c5e8c60..4d9a677 100644 --- a/.claude/skills/init-slack/SKILL.md +++ b/.claude/skills/init-slack/SKILL.md @@ -33,6 +33,8 @@ Ask: - `CHAT_SDK_SLACK_TOKEN` — bot token, starts with `xoxb-` - `CHAT_SDK_CHANNEL_ID` — channel ID like `C0123456789` (not `#channel-name`) - `CHAT_SDK_BOT_NAME` — defaults to `blazebot`; only ask if the user wants to override +- `SLACK_SIGNING_SECRET` — required. App settings → **Basic Information** → **App Credentials** → **Signing Secret**. Used to verify inbound `/ai-workflow` slash command requests. See `references/slash-commands.md` for the full slash-command setup. +- `SLACK_ALLOWED_USER_IDS` — optional. Comma-separated Slack user IDs (`U…`) allowed to run `/ai-workflow`. Empty = anyone in the workspace. ### Finding the channel ID @@ -48,6 +50,7 @@ The bot must be invited to the channel: `/invite @blazebot` from inside the chan ``` CHAT_SDK_SLACK_TOKEN= CHAT_SDK_CHANNEL_ID= +SLACK_SIGNING_SECRET= ``` If non-default bot name: @@ -55,8 +58,23 @@ If non-default bot name: CHAT_SDK_BOT_NAME= ``` +If restricting slash commands to specific users: +``` +SLACK_ALLOWED_USER_IDS=U0123,U4567 +``` + Tell the user to paste into Vercel → Project Settings → Environment Variables (all three environments), save, and reply when done. +## Step 4 — Register the slash command + +After the env vars are saved and the project has been deployed at least once, the operator must register the slash command in Slack: + +- Slash Commands → **Create New Command** → `/ai-workflow` +- Request URL: `https:///webhooks/slack` +- Reinstall the app so Slack picks up the new command + +Full walkthrough in `references/slash-commands.md`. + ## Don'ts - **Don't accept a `xoxp-` user token.** Blazebot needs a bot token (`xoxb-`). User tokens have different permission semantics and will silently fail in some adapter paths. diff --git a/.claude/skills/init-slack/references/slash-commands.md b/.claude/skills/init-slack/references/slash-commands.md new file mode 100644 index 0000000..592be02 --- /dev/null +++ b/.claude/skills/init-slack/references/slash-commands.md @@ -0,0 +1,74 @@ +# Slack slash commands setup + +The `/ai-workflow` slash command lets operators inspect and control workflow runs from inside Slack. Three subcommands today: + +- `/ai-workflow list` — show every tracked workflow run +- `/ai-workflow status ` — show the run + sandbox tied to a Jira ticket (e.g. `AWT-42`) +- `/ai-workflow cancel ` — cancel the workflow run for a ticket + +The command is gated by Slack's request signature (HMAC over the raw body) and an optional user allowlist. + +## Prereqs + +- The Blazebot Slack app already exists and has a bot token configured (see `bot-app-setup.md`). +- The repo is deployed at least once to Vercel — the slash command needs a public Request URL. + +## Step 1 — Get the Signing Secret + +In api.slack.com → your Blazebot app → **Basic Information** → **App Credentials** → **Signing Secret** → **Show** → copy. + +This is `SLACK_SIGNING_SECRET`. Store it in Vercel for **all three environments** (Production, Preview, Development). Without it the route 401s every request — slash commands won't work. + +## Step 2 — (Optional) Restrict who can run /ai-workflow + +If you don't want any random workspace member to be able to cancel runs, set `SLACK_ALLOWED_USER_IDS` to a comma-separated list of Slack user IDs. + +How to find a user ID: in Slack, click the person → **View full profile** → **More** (`⋮`) → **Copy member ID** (looks like `U0123ABCD`). + +``` +SLACK_ALLOWED_USER_IDS=U0123ABCD,U4567WXYZ +``` + +Leave unset for "anyone in the workspace". + +## Step 3 — Register the slash command in Slack + +App settings → **Slash Commands** → **Create New Command**: + +| Field | Value | +| --- | --- | +| Command | `/ai-workflow` | +| Request URL | `https:///webhooks/slack` | +| Short description | `Inspect and control AI workflow runs` | +| Usage hint | `list \| status \| cancel ` | + +Save. If the app is already installed, Slack will prompt you to **Reinstall** so the new command is registered with the workspace. + +## Step 4 — Smoke test + +In any channel the bot can see: + +``` +/ai-workflow list +``` + +Expect: + +1. An ephemeral "Working on `/ai-workflow list`…" message (within ~1s). +2. A second message visible in the channel with either the list of active runs or "No active workflows." + +If you instead see Slack's "operation_timeout" error, the function probably can't reach Upstash — check Vercel runtime logs for the `slack_command_dispatching` log line. + +## Troubleshooting + +- **`/ai-workflow` returns "command failed with the error 'dispatch_failed'"** — Slack thinks the URL didn't 200. Check Vercel logs; usually a missing `SLACK_SIGNING_SECRET` (route 401s) or a 5xx from Nitro startup. +- **`Not authorized.`** — your user ID isn't in `SLACK_ALLOWED_USER_IDS`. Add it or unset the variable. +- **Cancel says "is mid-dispatch"** — a workflow was just claimed but not yet started. Wait a moment, then re-run the cancel. + +## Rotation + +Slack signing secrets don't expire but can be rotated. To rotate: + +1. App settings → **Basic Information** → **Signing Secret** → **Regenerate**. +2. Update `SLACK_SIGNING_SECRET` in Vercel for all three environments. +3. Redeploy. Until the new deployment is live, every slash command will 401. diff --git a/README.md b/README.md index b696bf9..6376e77 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,17 @@ GITHUB_REPO=your-repo # Target repository name GITHUB_BASE_BRANCH=main # Branch PRs will target ``` -**Slack** — Bot notifications: +**Slack** — Bot notifications and slash commands: ```bash CHAT_SDK_SLACK_TOKEN=xoxb-xxxxxxxxxxxx # Slack bot token (chat:write scope) CHAT_SDK_CHANNEL_ID=C0123456789 # Channel ID for notifications CHAT_SDK_BOT_NAME=blazebot # Display name for the bot +SLACK_SIGNING_SECRET=xxxxxxxxxxxxxxxx # Required for /ai-workflow slash commands +SLACK_ALLOWED_USER_IDS=U0123,U4567 # Optional: comma-separated allowlist ``` +Operators can drive workflows directly from Slack with `/ai-workflow list | status | cancel ` once `SLACK_SIGNING_SECRET` is set and the slash command is registered (Request URL: `https:///webhooks/slack`). See `.claude/skills/init-slack/references/slash-commands.md` for the full setup walkthrough. + **Agent** — AI model configuration: ```bash ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxx # Anthropic API key @@ -238,6 +242,8 @@ curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/cron/poll | `CHAT_SDK_SLACK_TOKEN` | Yes | — | Slack bot token | | `CHAT_SDK_CHANNEL_ID` | Yes | — | Notification channel ID | | `CHAT_SDK_BOT_NAME` | No | `blazebot` | Bot display name | +| `SLACK_SIGNING_SECRET` | Yes | — | Slack app signing secret; verifies `/ai-workflow` slash commands | +| `SLACK_ALLOWED_USER_IDS` | No | — | Comma-separated Slack user IDs allowed to run `/ai-workflow`; empty = anyone | | **Agent** | | | | | `AGENT_KIND` | No | `claude` | Runtime: `claude` or `codex` | | `ANTHROPIC_API_KEY` | Yes* | — | Anthropic API key (required when `AGENT_KIND=claude`) | diff --git a/docs/superpowers/plans/2026-05-01-slack-slash-commands.md b/docs/superpowers/plans/2026-05-01-slack-slash-commands.md new file mode 100644 index 0000000..cc1abd2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-slack-slash-commands.md @@ -0,0 +1,141 @@ +# Slack Slash Commands Implementation Plan + +**Goal:** Add inbound Slack slash commands (`/ai-workflow list | status | cancel `) so operators can inspect and control workflow runs from Slack. + +**Architecture:** One Nitro POST route at `/webhooks/slack` verifies Slack's HMAC signature, parses the slash command payload, ack's within 3s, and dispatches the subcommand to async handlers that read the existing `RunRegistryAdapter` and reuse `cancelRun()`. Results are posted back via Slack's `response_url`. + +**Tech Stack:** Nitro (h3), `@chat-adapter/slack` (already wired for outbound), Node `crypto` for HMAC, existing Upstash run registry, `workflow/api` for run cancel. + +**Out of scope (deferred):** interactive buttons, Events API / `app_mention`, multi-tenant routing, audit log persistence. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `src/routes/webhooks/slack.post.ts` | Route entry: verify signature, parse payload, ack, dispatch | +| `src/lib/slack/verify.ts` | HMAC-SHA256 signature verification + timestamp drift check | +| `src/lib/slack/commands.ts` | Subcommand parser + dispatcher (`list`, `status`, `cancel`) | +| `src/lib/slack/respond.ts` | Helper to POST formatted text to Slack `response_url` | +| `src/lib/slack/format.ts` | Format registry rows + status into Slack mrkdwn | +| `env.ts` | Add `SLACK_SIGNING_SECRET` (required); optional `SLACK_ALLOWED_USER_IDS` | +| `*.test.ts` siblings | Unit tests for verify, commands, format | + +`cancelRun` (`src/lib/cancel-run.ts`) and `RunRegistryAdapter.listAll/getRunId/getSandboxId` are reused as-is — no changes. + +--- + +## Phase 1 — Signature verification (foundational, pure function) + +**Task 1.1:** Add `SLACK_SIGNING_SECRET: z.string().min(1)` to `env.ts` server schema. Add optional `SLACK_ALLOWED_USER_IDS: z.string().optional()` (comma-separated). + +**Task 1.2:** Implement `src/lib/slack/verify.ts`: +- `verifySlackSignature({ rawBody, timestamp, signature, signingSecret })` → boolean +- Compute `v0=` + HMAC-SHA256 over `v0:${timestamp}:${rawBody}` with `signingSecret` +- Compare with `timingSafeEqual` +- Reject if `Math.abs(now - timestamp) > 300` (5 min replay window) + +**Task 1.3:** TDD: cover (a) valid sig passes, (b) tampered body fails, (c) old timestamp fails, (d) length-mismatched signature fails without throwing. + +--- + +## Phase 2 — Command parsing (pure) + +**Task 2.1:** Implement `src/lib/slack/commands.ts` with: +```ts +type ParsedCommand = + | { kind: "list" } + | { kind: "status"; ticketKey: string } + | { kind: "cancel"; ticketKey: string } + | { kind: "help" } + | { kind: "unknown"; raw: string }; + +export function parseCommand(text: string): ParsedCommand; +``` +- Trim, split on whitespace, lowercase verb +- Validate ticket key matches `/^[A-Z][A-Z0-9]+-\d+$/` (uppercase first), else return `unknown` +- Empty / `help` → help + +**Task 2.2:** TDD each branch including malformed keys (`abc`, `AWT`, `AWT-`, `awt-1` lowercased before validation). + +--- + +## Phase 3 — Subcommand handlers + +Each returns a `string` (Slack mrkdwn) — no Slack I/O inside, so they're trivial to test. + +**Task 3.1:** `handleList(runRegistry)`: +- `runRegistry.listAll()` → filter out claiming sentinels (use existing `isClaimingSentinel`) +- Format each row: `• — runId: \`xxx\`` (link via `JIRA_BASE_URL`) +- Empty list → "No active workflows." + +**Task 3.2:** `handleStatus(runRegistry, ticketKey)`: +- Look up `getRunId` + `getSandboxId` +- Return `Not tracked.` / `TICKET → runId, sandbox: yes/no` +- Out of scope: live workflow status from `workflow/api` (add only if registry-only is insufficient in practice) + +**Task 3.3:** `handleCancel(runRegistry, ticketKey)`: +- `getRunId(ticketKey)` — if null, return `No active run for TICKET.` +- If runId is a claiming sentinel, return `TICKET is mid-dispatch; try again in a moment.` +- Otherwise call `cancelRun(ticketKey, runId, runRegistry)` and return result message. + +**Task 3.4:** TDD with stubbed `RunRegistryAdapter` — assert exact return strings. Don't mock `workflow/api`; instead inject a fake `cancelRun` via parameter so the handler stays a pure function over its dependencies. + +--- + +## Phase 4 — Route wiring (the only place with side effects) + +**Task 4.1:** `src/routes/webhooks/slack.post.ts`: +1. `readRawBody` (mirrors Jira webhook). +2. Read headers `x-slack-request-timestamp`, `x-slack-signature`. Verify via Phase 1; on failure `throw createError({ statusCode: 401 })`. +3. Parse `application/x-www-form-urlencoded` → `{ command, text, response_url, user_id, channel_id }`. +4. Optional allowlist: if `SLACK_ALLOWED_USER_IDS` set and `user_id` not in it, reply 200 with ephemeral "Not authorized." +5. `parseCommand(text)`. For `unknown`/`help`, respond synchronously with usage. +6. For real commands: respond **immediately** with `{ response_type: "ephemeral", text: "Working on \`${command} ${text}\`…" }` (Slack's 3s budget). +7. Schedule the handler in the background: + ```ts + event.waitUntil(runHandler(parsed, response_url, adapters)); + ``` + `runHandler` calls the appropriate `handle*`, then POSTs `{ response_type: "in_channel", text: result }` to `response_url`. +8. `createAdapters()` is reused — same shape as Jira webhook. + +**Task 4.2:** `src/lib/slack/respond.ts`: +- `postToResponseUrl(url, payload)` — `fetch(url, { method: "POST", body: JSON.stringify(payload), headers: { "content-type": "application/json" } })` +- Log + swallow on failure (matches existing messaging adapter philosophy: notifications never break flows). + +**Task 4.3:** Integration test (vitest) that boots the route via Nitro test util or by calling the handler directly with a hand-crafted h3 event: +- Valid signature + `list` → 200, ack body shape correct, `response_url` POSTed with formatted list. +- Invalid signature → 401. +- Disallowed user → 200 + "Not authorized." +- `cancel AWT-42` with no entry → "No active run." +- `cancel AWT-42` with entry → `cancelRun` invoked once with the right args. + +--- + +## Phase 5 — Slack app config + docs (no code, but blocks shipping) + +**Task 5.1:** In api.slack.com app settings: +- Slash Commands → add `/ai-workflow` → request URL `https:///webhooks/slack`. +- Reinstall app (`commands` scope is already granted on most chat-adapter installs; verify). +- Copy the Signing Secret into Vercel env (`SLACK_SIGNING_SECRET`) for Production + Preview. + +**Task 5.2:** Update `init-slack` skill (`/Users/kacper/.claude/skills/init-slack`) to also prompt for `SLACK_SIGNING_SECRET` and mention the slash-command URL. Add a one-paragraph operator note in `README.md` under the existing Slack section. + +--- + +## Verification checklist + +- [ ] `pnpm test` passes (new unit + integration tests) +- [ ] Locally: `vercel dev` + `ngrok` → run `/ai-workflow list` from Slack, see ack <3s and final list message +- [ ] Bad-signature curl returns 401 +- [ ] `/ai-workflow cancel AWT-` cancels the run and posts confirmation +- [ ] Workflow-side: registry entry gone, sandbox stopped, Jira thread shows the existing cancel notification (already handled by `cancelRun`'s downstream) + +--- + +## Risks / open questions + +1. **`event.waitUntil` on Nitro/Vercel preset** — confirm h3 exposes it (or use Nitro's `event.context.waitUntil` if applicable). Fallback: do the work synchronously and rely on Slack's 3s being usually achievable for a single Redis read; cancel uses two extra ops which is borderline. Safer to confirm waitUntil first. +2. **Multi-channel installs** — current outbound adapter is single-channel via `CHAT_SDK_CHANNEL_ID`. Slash commands can come from any channel the bot is in; `response_url` makes that fine for replies, but if you want to *restrict* commands to one channel, add a channel allowlist alongside the user one. +3. **Concurrency** — `cancel` racing the dispatch claim is already handled by `dispatch.ts`'s post-claim verification, so no new logic needed. diff --git a/env.test.ts b/env.test.ts index 4522535..7768261 100644 --- a/env.test.ts +++ b/env.test.ts @@ -18,6 +18,7 @@ describe("env", () => { CHAT_SDK_SLACK_TOKEN: "xoxb-test", CHAT_SDK_CHANNEL_ID: "C123", CHAT_SDK_BOT_NAME: "blazebot", + SLACK_SIGNING_SECRET: "fake-signing-secret", ANTHROPIC_API_KEY: "sk-ant-test", CLAUDE_MODEL: "claude-opus-4-6", COMMIT_AUTHOR: "ai-workflow-blazity", diff --git a/env.ts b/env.ts index c52b804..288faa8 100644 --- a/env.ts +++ b/env.ts @@ -39,6 +39,11 @@ export const env = createEnv({ CHAT_SDK_CHANNEL_ID: z.string().min(1), CHAT_SDK_BOT_NAME: z.string().default("blazebot"), + // Slack slash commands + SLACK_SIGNING_SECRET: z.string().min(1), + /** 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(), diff --git a/src/lib/slack/commands.test.ts b/src/lib/slack/commands.test.ts new file mode 100644 index 0000000..3964501 --- /dev/null +++ b/src/lib/slack/commands.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { parseCommand } from "./commands.js"; + +describe("parseCommand", () => { + it("returns help for empty input", () => { + expect(parseCommand("")).toEqual({ kind: "help" }); + expect(parseCommand(" ")).toEqual({ kind: "help" }); + }); + + it("returns help for explicit help", () => { + expect(parseCommand("help")).toEqual({ kind: "help" }); + expect(parseCommand("HELP")).toEqual({ kind: "help" }); + }); + + it("parses list", () => { + expect(parseCommand("list")).toEqual({ kind: "list" }); + expect(parseCommand(" LIST ")).toEqual({ kind: "list" }); + }); + + it("parses status with a valid ticket key", () => { + expect(parseCommand("status AWT-42")).toEqual({ + kind: "status", + ticketKey: "AWT-42", + }); + }); + + it("uppercases the ticket key on status", () => { + expect(parseCommand("status awt-42")).toEqual({ + kind: "status", + ticketKey: "AWT-42", + }); + }); + + it("parses cancel with a valid ticket key", () => { + expect(parseCommand("cancel AWT-42")).toEqual({ + kind: "cancel", + ticketKey: "AWT-42", + }); + }); + + it("returns unknown for status without a ticket key", () => { + expect(parseCommand("status")).toEqual({ kind: "unknown", raw: "status" }); + }); + + it("returns unknown for cancel without a ticket key", () => { + expect(parseCommand("cancel")).toEqual({ kind: "unknown", raw: "cancel" }); + }); + + it("returns unknown for malformed ticket keys", () => { + expect(parseCommand("status abc")).toEqual({ + kind: "unknown", + raw: "status abc", + }); + expect(parseCommand("status AWT")).toEqual({ + kind: "unknown", + raw: "status AWT", + }); + expect(parseCommand("status AWT-")).toEqual({ + kind: "unknown", + raw: "status AWT-", + }); + expect(parseCommand("status 42-AWT")).toEqual({ + kind: "unknown", + raw: "status 42-AWT", + }); + }); + + it("returns unknown for an unrecognised verb", () => { + expect(parseCommand("delete AWT-42")).toEqual({ + kind: "unknown", + raw: "delete AWT-42", + }); + }); + + it("ignores extra trailing whitespace and tokens after the ticket key", () => { + expect(parseCommand("status AWT-42 ")).toEqual({ + kind: "status", + ticketKey: "AWT-42", + }); + }); +}); diff --git a/src/lib/slack/commands.ts b/src/lib/slack/commands.ts new file mode 100644 index 0000000..aa0dfcb --- /dev/null +++ b/src/lib/slack/commands.ts @@ -0,0 +1,29 @@ +const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/; + +export type ParsedCommand = + | { kind: "list" } + | { kind: "status"; ticketKey: string } + | { kind: "cancel"; ticketKey: string } + | { kind: "help" } + | { kind: "unknown"; raw: string }; + +export function parseCommand(text: string): ParsedCommand { + const trimmed = text.trim(); + if (trimmed === "") return { kind: "help" }; + + const tokens = trimmed.split(/\s+/); + const verb = tokens[0]!.toLowerCase(); + const arg = tokens[1]?.toUpperCase(); + + if (verb === "help") return { kind: "help" }; + if (verb === "list") return { kind: "list" }; + + if (verb === "status" || verb === "cancel") { + if (arg && TICKET_KEY_RE.test(arg)) { + return { kind: verb, ticketKey: arg }; + } + return { kind: "unknown", raw: trimmed }; + } + + return { kind: "unknown", raw: trimmed }; +} diff --git a/src/lib/slack/format.test.ts b/src/lib/slack/format.test.ts new file mode 100644 index 0000000..8395286 --- /dev/null +++ b/src/lib/slack/format.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { formatRunList, formatRunStatus } from "./format.js"; + +const JIRA_BASE_URL = "https://example.atlassian.net"; + +describe("formatRunList", () => { + it("returns empty-state copy when there are no rows", () => { + expect(formatRunList([], JIRA_BASE_URL)).toBe("No active workflows."); + }); + + it("renders one bullet per ticket with a Jira link and runId", () => { + const out = formatRunList( + [ + { ticketKey: "AWT-1", runId: "run_a" }, + { ticketKey: "AWT-2", runId: "run_b" }, + ], + JIRA_BASE_URL, + ); + expect(out).toContain( + "• — runId: `run_a`", + ); + expect(out).toContain( + "• — runId: `run_b`", + ); + }); + + it("strips a trailing slash on the Jira base URL", () => { + const out = formatRunList( + [{ ticketKey: "AWT-1", runId: "run_a" }], + "https://example.atlassian.net/", + ); + expect(out).toContain("https://example.atlassian.net/browse/AWT-1|AWT-1"); + expect(out).not.toContain(".net//browse"); + }); +}); + +describe("formatRunStatus", () => { + it("renders untracked when runId is null", () => { + expect( + formatRunStatus("AWT-1", { runId: null, sandboxId: null }, JIRA_BASE_URL), + ).toBe(": not tracked."); + }); + + it("renders runId and sandbox presence", () => { + expect( + formatRunStatus( + "AWT-1", + { runId: "run_a", sandboxId: "sbx_x" }, + JIRA_BASE_URL, + ), + ).toBe( + ": runId `run_a`, sandbox: yes", + ); + }); + + it("renders sandbox: no when sandboxId is null", () => { + expect( + formatRunStatus( + "AWT-1", + { runId: "run_a", sandboxId: null }, + JIRA_BASE_URL, + ), + ).toBe( + ": runId `run_a`, sandbox: no", + ); + }); +}); diff --git a/src/lib/slack/format.ts b/src/lib/slack/format.ts new file mode 100644 index 0000000..817ee84 --- /dev/null +++ b/src/lib/slack/format.ts @@ -0,0 +1,39 @@ +export interface RunRow { + ticketKey: string; + runId: string; +} + +export interface RunStatusSnapshot { + runId: string | null; + sandboxId: string | null; +} + +export function formatRunList(rows: RunRow[], jiraBaseUrl: string): string { + if (rows.length === 0) return "No active workflows."; + return rows + .map(({ ticketKey, runId }) => `• ${jiraLink(ticketKey, jiraBaseUrl)} — runId: \`${runId}\``) + .join("\n"); +} + +export function formatRunStatus( + ticketKey: string, + snapshot: RunStatusSnapshot, + jiraBaseUrl: string, +): string { + const link = jiraLink(ticketKey, jiraBaseUrl); + if (!snapshot.runId) return `${link}: not tracked.`; + const sandbox = snapshot.sandboxId ? "yes" : "no"; + return `${link}: runId \`${snapshot.runId}\`, sandbox: ${sandbox}`; +} + +export const HELP_TEXT = [ + "*Blazebot commands*", + "• `/ai-workflow list` — show every tracked workflow", + "• `/ai-workflow status ` — show the run + sandbox tied to a ticket", + "• `/ai-workflow cancel ` — cancel the workflow run for a ticket", +].join("\n"); + +function jiraLink(ticketKey: string, jiraBaseUrl: string): string { + const base = jiraBaseUrl.replace(/\/$/, ""); + return `<${base}/browse/${ticketKey}|${ticketKey}>`; +} diff --git a/src/lib/slack/handlers.test.ts b/src/lib/slack/handlers.test.ts new file mode 100644 index 0000000..6ccd4db --- /dev/null +++ b/src/lib/slack/handlers.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from "vitest"; +import type { RunRegistryAdapter } from "../../adapters/run-registry/types.js"; + +vi.mock("../../../env.js", () => ({ env: {} })); + +const { handleCancel, handleList, handleStatus } = await import("./handlers.js"); + +const JIRA_BASE_URL = "https://example.atlassian.net"; + +function makeRegistry(overrides: Partial = {}): RunRegistryAdapter { + return { + claim: vi.fn(), + register: vi.fn(), + getRunId: overrides.getRunId ?? vi.fn().mockResolvedValue(null), + unregister: vi.fn().mockResolvedValue(undefined), + listAll: overrides.listAll ?? vi.fn().mockResolvedValue([]), + registerSandbox: vi.fn().mockResolvedValue(undefined), + getSandboxId: overrides.getSandboxId ?? vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn().mockResolvedValue(null), + markFailed: vi.fn().mockResolvedValue(undefined), + isTicketFailed: vi.fn().mockResolvedValue(false), + listAllFailed: vi.fn().mockResolvedValue([]), + clearFailedMark: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("handleList", () => { + it("returns the empty-state message when no entries", async () => { + const registry = makeRegistry({ listAll: vi.fn().mockResolvedValue([]) }); + expect(await handleList(registry, JIRA_BASE_URL)).toBe("No active workflows."); + }); + + it("filters out claiming sentinels", async () => { + const registry = makeRegistry({ + listAll: vi.fn().mockResolvedValue([ + { ticketKey: "AWT-1", runId: "run_real" }, + { ticketKey: "AWT-2", runId: "claiming:1700000000000" }, + ]), + }); + const out = await handleList(registry, JIRA_BASE_URL); + expect(out).toContain("AWT-1"); + expect(out).toContain("run_real"); + expect(out).not.toContain("AWT-2"); + }); + + it("returns empty-state message when only claiming sentinels exist", async () => { + const registry = makeRegistry({ + listAll: vi.fn().mockResolvedValue([ + { ticketKey: "AWT-1", runId: "claiming:1700000000000" }, + ]), + }); + expect(await handleList(registry, JIRA_BASE_URL)).toBe("No active workflows."); + }); +}); + +describe("handleStatus", () => { + it("reports not tracked when no runId", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue(null), + }); + expect(await handleStatus(registry, "AWT-99", JIRA_BASE_URL)).toContain( + "not tracked", + ); + }); + + it("reports runId + sandbox: yes when sandbox is registered", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue("run_a"), + getSandboxId: vi.fn().mockResolvedValue("sbx_z"), + }); + expect(await handleStatus(registry, "AWT-1", JIRA_BASE_URL)).toContain( + "runId `run_a`, sandbox: yes", + ); + }); + + it("reports sandbox: no when no sandbox", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue("run_a"), + getSandboxId: vi.fn().mockResolvedValue(null), + }); + expect(await handleStatus(registry, "AWT-1", JIRA_BASE_URL)).toContain( + "sandbox: no", + ); + }); +}); + +describe("handleCancel", () => { + it("returns 'no active run' when registry has no entry", async () => { + const registry = makeRegistry({ getRunId: vi.fn().mockResolvedValue(null) }); + const cancelRunFn = vi.fn(); + const stopSandboxes = vi.fn(); + const out = await handleCancel( + registry, + "AWT-1", + cancelRunFn, + stopSandboxes, + ); + expect(out).toContain("No active run"); + expect(out).toContain("AWT-1"); + expect(cancelRunFn).not.toHaveBeenCalled(); + expect(stopSandboxes).not.toHaveBeenCalled(); + }); + + it("warns the user when the entry is a claiming sentinel and stops the sandbox", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue("claiming:1700000000000"), + getSandboxId: vi.fn().mockResolvedValue("sbx_z"), + }); + const cancelRunFn = vi.fn(); + const stopSandboxes = vi.fn().mockResolvedValue(1); + const out = await handleCancel( + registry, + "AWT-1", + cancelRunFn, + stopSandboxes, + ); + expect(out).toContain("mid-dispatch"); + expect(stopSandboxes).toHaveBeenCalledWith("AWT-1", "sbx_z"); + expect(registry.unregister).toHaveBeenCalledWith("AWT-1"); + expect(cancelRunFn).not.toHaveBeenCalled(); + }); + + it("calls cancelRun with ticket key + runId + registry, and reports success", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue("run_a"), + }); + const cancelRunFn = vi.fn().mockResolvedValue(true); + const stopSandboxes = vi.fn(); + const out = await handleCancel( + registry, + "AWT-1", + cancelRunFn, + stopSandboxes, + ); + expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", registry); + expect(out).toContain("Cancelled"); + expect(out).toContain("AWT-1"); + }); + + it("reports cancel-failed-but-cleanup-done when cancelRun returns false", async () => { + const registry = makeRegistry({ + getRunId: vi.fn().mockResolvedValue("run_a"), + }); + const cancelRunFn = vi.fn().mockResolvedValue(false); + const out = await handleCancel(registry, "AWT-1", cancelRunFn, vi.fn()); + expect(out).toContain("AWT-1"); + expect(out.toLowerCase()).toContain("could not"); + }); +}); diff --git a/src/lib/slack/handlers.ts b/src/lib/slack/handlers.ts new file mode 100644 index 0000000..91c527d --- /dev/null +++ b/src/lib/slack/handlers.ts @@ -0,0 +1,60 @@ +import type { RunRegistryAdapter } from "../../adapters/run-registry/types.js"; +import { isClaimingSentinel } from "../dispatch.js"; +import { formatRunList, formatRunStatus } from "./format.js"; + +export type CancelRunFn = ( + ticketKey: string, + runId: string, + registry: RunRegistryAdapter, +) => Promise; + +export type StopTicketSandboxesFn = ( + ticketKey: string, + sandboxId: string | null, +) => Promise; + +export async function handleList( + registry: RunRegistryAdapter, + jiraBaseUrl: string, +): Promise { + const all = await registry.listAll(); + const live = all.filter((row) => !isClaimingSentinel(row.runId)); + return formatRunList(live, jiraBaseUrl); +} + +export async function handleStatus( + registry: RunRegistryAdapter, + ticketKey: string, + jiraBaseUrl: string, +): Promise { + const [runId, sandboxId] = await Promise.all([ + registry.getRunId(ticketKey), + registry.getSandboxId(ticketKey), + ]); + return formatRunStatus(ticketKey, { runId, sandboxId }, jiraBaseUrl); +} + +export async function handleCancel( + registry: RunRegistryAdapter, + ticketKey: string, + cancelRunFn: CancelRunFn, + stopSandboxes: StopTicketSandboxesFn, +): Promise { + const runId = await registry.getRunId(ticketKey); + if (!runId) return `No active run for ${ticketKey}.`; + + if (isClaimingSentinel(runId)) { + // Mid-dispatch: dispatch.ts has called start() but not yet swapped in the + // real runId. We can't cancel a workflow whose id we don't know. Stop any + // sandbox that may have leaked and clear the entry so the next dispatch + // sees a clean slot — same shape as the jira webhook handles it. + const sandboxId = await registry.getSandboxId(ticketKey).catch(() => null); + await stopSandboxes(ticketKey, sandboxId).catch(() => {}); + await registry.unregister(ticketKey).catch(() => {}); + return `${ticketKey} is mid-dispatch; cleared the claim. Try the cancel again in a moment if a real run shows up.`; + } + + const ok = await cancelRunFn(ticketKey, runId, registry); + if (ok) return `Cancelled ${ticketKey} (runId \`${runId}\`).`; + return `${ticketKey}: could not cancel run \`${runId}\` cleanly — sandbox + registry have been cleaned up.`; +} diff --git a/src/lib/slack/respond.ts b/src/lib/slack/respond.ts new file mode 100644 index 0000000..a6f0013 --- /dev/null +++ b/src/lib/slack/respond.ts @@ -0,0 +1,43 @@ +import { logger } from "../logger.js"; + +export interface SlackResponsePayload { + text: string; + /** + * Slack-specific reply target. `ephemeral` is only visible to the invoking + * user; `in_channel` is visible to everyone in the channel. + */ + response_type?: "ephemeral" | "in_channel"; + replace_original?: boolean; +} + +/** + * POST a follow-up message to Slack's `response_url` from a slash command. + * + * Failures are logged and swallowed: the same philosophy the messaging + * adapter applies — "notifications never break flows". A slash command that + * has already been ack'd should not retry the user's request just because + * Slack momentarily 5xx'd on the follow-up post. + */ +export async function postToResponseUrl( + responseUrl: string, + payload: SlackResponsePayload, +): Promise { + try { + const res = await fetch(responseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + logger.warn( + { status: res.status, statusText: res.statusText }, + "slack_response_url_post_failed", + ); + } + } catch (err) { + logger.warn( + { error: (err as Error).message }, + "slack_response_url_post_error", + ); + } +} diff --git a/src/lib/slack/verify.test.ts b/src/lib/slack/verify.test.ts new file mode 100644 index 0000000..a0e836e --- /dev/null +++ b/src/lib/slack/verify.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { createHmac } from "node:crypto"; +import { verifySlackSignature } from "./verify.js"; + +const SIGNING_SECRET = "shhhh-do-not-tell"; + +function sign(rawBody: string, timestamp: string, secret = SIGNING_SECRET): string { + const mac = createHmac("sha256", secret) + .update(`v0:${timestamp}:${rawBody}`) + .digest("hex"); + return `v0=${mac}`; +} + +describe("verifySlackSignature", () => { + it("returns true for a valid signature within the replay window", () => { + const rawBody = "command=%2Fblazebot&text=list"; + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = sign(rawBody, timestamp); + + expect( + verifySlackSignature({ + rawBody, + timestamp, + signature, + signingSecret: SIGNING_SECRET, + }), + ).toBe(true); + }); + + it("returns false when the body has been tampered", () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = sign("original", timestamp); + + expect( + verifySlackSignature({ + rawBody: "tampered", + timestamp, + signature, + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); + + it("returns false when the timestamp is older than 5 minutes", () => { + const rawBody = "ok"; + const tooOld = String(Math.floor(Date.now() / 1000) - 6 * 60); + const signature = sign(rawBody, tooOld); + + expect( + verifySlackSignature({ + rawBody, + timestamp: tooOld, + signature, + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); + + it("returns false when the timestamp is in the far future", () => { + const rawBody = "ok"; + const tooNew = String(Math.floor(Date.now() / 1000) + 10 * 60); + const signature = sign(rawBody, tooNew); + + expect( + verifySlackSignature({ + rawBody, + timestamp: tooNew, + signature, + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); + + it("returns false on a length-mismatched signature without throwing", () => { + const rawBody = "ok"; + const timestamp = String(Math.floor(Date.now() / 1000)); + + expect(() => + verifySlackSignature({ + rawBody, + timestamp, + signature: "v0=short", + signingSecret: SIGNING_SECRET, + }), + ).not.toThrow(); + expect( + verifySlackSignature({ + rawBody, + timestamp, + signature: "v0=short", + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); + + it("returns false when the signature is missing the v0= prefix", () => { + const rawBody = "ok"; + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = sign(rawBody, timestamp).slice("v0=".length); + + expect( + verifySlackSignature({ + rawBody, + timestamp, + signature, + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); + + it("returns false when the timestamp is not a valid integer", () => { + expect( + verifySlackSignature({ + rawBody: "ok", + timestamp: "not-a-number", + signature: "v0=deadbeef", + signingSecret: SIGNING_SECRET, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/slack/verify.ts b/src/lib/slack/verify.ts new file mode 100644 index 0000000..02bf674 --- /dev/null +++ b/src/lib/slack/verify.ts @@ -0,0 +1,39 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +const REPLAY_WINDOW_SECONDS = 60 * 5; +const SIGNATURE_PREFIX = "v0="; + +export interface VerifySlackSignatureInput { + rawBody: string; + timestamp: string; + signature: string; + signingSecret: string; +} + +/** + * Verify Slack's `x-slack-signature` HMAC, per + * https://api.slack.com/authentication/verifying-requests-from-slack. + * + * The 5-minute replay window protects against attackers replaying a captured + * (and otherwise valid) signed request long after Slack would have retried. + */ +export function verifySlackSignature(input: VerifySlackSignatureInput): boolean { + const { rawBody, timestamp, signature, signingSecret } = input; + + if (!signature.startsWith(SIGNATURE_PREFIX)) return false; + + const ts = Number.parseInt(timestamp, 10); + if (!Number.isFinite(ts)) return false; + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) return false; + + const expected = createHmac("sha256", signingSecret) + .update(`v0:${timestamp}:${rawBody}`) + .digest("hex"); + const expectedFull = SIGNATURE_PREFIX + expected; + + const a = Buffer.from(signature); + const b = Buffer.from(expectedFull); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/src/routes/webhooks/slack.post.test.ts b/src/routes/webhooks/slack.post.test.ts new file mode 100644 index 0000000..0d35bf0 --- /dev/null +++ b/src/routes/webhooks/slack.post.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createApp, toWebHandler } from "h3"; +import { createHmac } from "node:crypto"; + +const SIGNING_SECRET = "shhhh-do-not-tell"; +const JIRA_BASE_URL = "https://example.atlassian.net"; + +// Mock env BEFORE importing anything that pulls it in transitively. +vi.mock("../../../env.js", () => ({ + env: { + SLACK_SIGNING_SECRET: SIGNING_SECRET, + SLACK_ALLOWED_USER_IDS: undefined as string | undefined, + JIRA_BASE_URL, + }, +})); + +// Adapters: only runRegistry matters for these tests. +const runRegistry = { + claim: vi.fn(), + register: vi.fn(), + getRunId: vi.fn(), + unregister: vi.fn().mockResolvedValue(undefined), + listAll: vi.fn(), + registerSandbox: vi.fn(), + getSandboxId: vi.fn().mockResolvedValue(null), + getEntryCreatedAt: vi.fn(), + markFailed: vi.fn(), + isTicketFailed: vi.fn(), + listAllFailed: vi.fn(), + clearFailedMark: vi.fn(), +}; +vi.mock("../../lib/adapters.js", () => ({ + createAdapters: () => ({ + runRegistry, + issueTracker: {}, + vcs: {}, + messaging: {}, + }), +})); + +const cancelRunFn = vi.fn(); +vi.mock("../../lib/cancel-run.js", () => ({ + cancelRun: (...args: any[]) => cancelRunFn(...args), +})); + +const stopTicketSandboxesFn = vi.fn(); +vi.mock("../../sandbox/stop-ticket-sandboxes.js", () => ({ + stopTicketSandboxes: (...args: any[]) => stopTicketSandboxesFn(...args), +})); + +let postedToResponseUrl: Array<{ url: string; payload: any }> = []; +vi.mock("../../lib/slack/respond.js", () => ({ + postToResponseUrl: vi.fn(async (url: string, payload: any) => { + postedToResponseUrl.push({ url, payload }); + }), +})); + +const slackHandler = (await import("./slack.post.js")).default; + +function makeApp() { + const app = createApp(); + app.use("/", slackHandler); + return toWebHandler(app); +} + +function sign(rawBody: string, timestamp: string): string { + const mac = createHmac("sha256", SIGNING_SECRET) + .update(`v0:${timestamp}:${rawBody}`) + .digest("hex"); + return `v0=${mac}`; +} + +function makeRequest( + body: string, + opts: { signed?: boolean; timestamp?: string } = {}, +): Request { + const timestamp = opts.timestamp ?? String(Math.floor(Date.now() / 1000)); + const headers: Record = { + "content-type": "application/x-www-form-urlencoded", + }; + if (opts.signed !== false) { + headers["x-slack-request-timestamp"] = timestamp; + headers["x-slack-signature"] = sign(body, timestamp); + } + return new Request("http://localhost/", { + method: "POST", + headers, + body, + }); +} + +function form(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +async function flushDeferred(): Promise { + // event.waitUntil is not available on the bare h3 app, so the route falls + // back to fire-and-forget. Yield to the microtask queue so the deferred + // postToResponseUrl call lands before assertions. + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +} + +describe("POST /webhooks/slack", () => { + beforeEach(() => { + vi.clearAllMocks(); + postedToResponseUrl = []; + runRegistry.listAll.mockResolvedValue([]); + runRegistry.getRunId.mockResolvedValue(null); + runRegistry.getSandboxId.mockResolvedValue(null); + }); + + it("returns 401 on a tampered body", async () => { + const handler = makeApp(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const headers = { + "content-type": "application/x-www-form-urlencoded", + "x-slack-request-timestamp": timestamp, + "x-slack-signature": sign("original", timestamp), + }; + const res = await handler( + new Request("http://localhost/", { + method: "POST", + headers, + body: "tampered", + }), + ); + expect(res.status).toBe(401); + }); + + it("returns 401 when signature headers are missing", async () => { + const handler = makeApp(); + const res = await handler(makeRequest(form({ text: "list" }), { signed: false })); + expect(res.status).toBe(401); + }); + + it("acks /ai-workflow list within 200 and posts the formatted list to response_url", async () => { + runRegistry.listAll.mockResolvedValue([ + { ticketKey: "AWT-1", runId: "run_real" }, + { ticketKey: "AWT-2", runId: "claiming:1700000000000" }, + ]); + + const handler = makeApp(); + const body = form({ + command: "/ai-workflow", + text: "list", + user_id: "U999", + response_url: "https://hooks.slack.com/commands/T1/abc", + }); + const res = await handler(makeRequest(body)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.response_type).toBe("ephemeral"); + expect(json.text).toContain("Working on"); + + await flushDeferred(); + expect(postedToResponseUrl).toHaveLength(1); + expect(postedToResponseUrl[0]!.url).toBe( + "https://hooks.slack.com/commands/T1/abc", + ); + expect(postedToResponseUrl[0]!.payload.response_type).toBe("in_channel"); + expect(postedToResponseUrl[0]!.payload.text).toContain("AWT-1"); + expect(postedToResponseUrl[0]!.payload.text).toContain("run_real"); + expect(postedToResponseUrl[0]!.payload.text).not.toContain("AWT-2"); + }); + + it("returns ephemeral 'Not authorized.' when user is not in the allowlist", async () => { + const { env } = await import("../../../env.js"); + (env as any).SLACK_ALLOWED_USER_IDS = "UALLOWED1,UALLOWED2"; + try { + const handler = makeApp(); + const body = form({ + command: "/ai-workflow", + text: "list", + user_id: "UDENIED", + response_url: "https://hooks.slack.com/commands/T1/abc", + }); + const res = await handler(makeRequest(body)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.response_type).toBe("ephemeral"); + expect(json.text).toBe("Not authorized."); + await flushDeferred(); + expect(postedToResponseUrl).toHaveLength(0); + } finally { + (env as any).SLACK_ALLOWED_USER_IDS = undefined; + } + }); + + it("/ai-workflow cancel AWT-1 with no entry posts 'No active run' to response_url", async () => { + runRegistry.getRunId.mockResolvedValue(null); + const handler = makeApp(); + const body = form({ + command: "/ai-workflow", + text: "cancel AWT-1", + user_id: "U1", + response_url: "https://hooks.slack.com/commands/T1/abc", + }); + await handler(makeRequest(body)); + await flushDeferred(); + expect(cancelRunFn).not.toHaveBeenCalled(); + expect(postedToResponseUrl[0]!.payload.text).toContain("No active run for AWT-1"); + }); + + it("/ai-workflow cancel AWT-1 with a real entry calls cancelRun once with the right args", async () => { + runRegistry.getRunId.mockResolvedValue("run_a"); + cancelRunFn.mockResolvedValue(true); + + const handler = makeApp(); + const body = form({ + command: "/ai-workflow", + text: "cancel AWT-1", + user_id: "U1", + response_url: "https://hooks.slack.com/commands/T1/abc", + }); + await handler(makeRequest(body)); + await flushDeferred(); + + expect(cancelRunFn).toHaveBeenCalledTimes(1); + expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", runRegistry); + expect(postedToResponseUrl[0]!.payload.text).toContain("Cancelled AWT-1"); + }); + + it("an empty /ai-workflow returns the help text synchronously", async () => { + const handler = makeApp(); + const res = await handler( + makeRequest( + form({ + command: "/ai-workflow", + text: "", + user_id: "U1", + response_url: "https://hooks.slack.com/commands/T1/abc", + }), + ), + ); + const json = await res.json(); + expect(json.response_type).toBe("ephemeral"); + expect(json.text).toContain("Blazebot commands"); + }); +}); diff --git a/src/routes/webhooks/slack.post.ts b/src/routes/webhooks/slack.post.ts new file mode 100644 index 0000000..65b668a --- /dev/null +++ b/src/routes/webhooks/slack.post.ts @@ -0,0 +1,162 @@ +import { defineEventHandler, readRawBody, getHeader, createError, type H3Event } from "h3"; +import { env } from "../../../env.js"; +import { createAdapters } from "../../lib/adapters.js"; +import { cancelRun } from "../../lib/cancel-run.js"; +import { logger } from "../../lib/logger.js"; +import { stopTicketSandboxes } from "../../sandbox/stop-ticket-sandboxes.js"; +import { parseCommand, type ParsedCommand } from "../../lib/slack/commands.js"; +import { HELP_TEXT } from "../../lib/slack/format.js"; +import { + handleCancel, + handleList, + handleStatus, +} from "../../lib/slack/handlers.js"; +import { postToResponseUrl } from "../../lib/slack/respond.js"; +import { verifySlackSignature } from "../../lib/slack/verify.js"; + +/** + * Slack slash command webhook. + * + * Configure in api.slack.com → Slash Commands: + * Command: /ai-workflow + * Request URL: https:///webhooks/slack + * + * Auth: HMAC-SHA256 over `v0:${timestamp}:${rawBody}` (Slack signs every + * request when a Signing Secret is configured for the app). + * + * The 3s ack budget is critical: Slack drops requests that don't respond in + * time. We verify, parse, schedule the real work via `event.waitUntil`, and + * return immediately. Results are POSTed back to `response_url`. + */ +export default defineEventHandler(async (event) => { + const rawBody = (await readRawBody(event, "utf8")) ?? ""; + + verifyWebhookAuth(event, rawBody); + + const fields = parseFormBody(rawBody); + const text = fields.get("text") ?? ""; + const userId = fields.get("user_id") ?? ""; + const responseUrl = fields.get("response_url") ?? ""; + const command = fields.get("command") ?? "/ai-workflow"; + + if (!isUserAllowed(userId)) { + logger.info({ userId, command }, "slack_command_user_not_allowed"); + return ephemeral("Not authorized."); + } + + const parsed = parseCommand(text); + + if (parsed.kind === "help" || parsed.kind === "unknown") { + logger.info({ userId, command, parsedKind: parsed.kind }, "slack_command_help_or_unknown"); + return ephemeral(parsed.kind === "help" ? HELP_TEXT : `Unknown command. ${HELP_TEXT}`); + } + + if (!responseUrl) { + // Without response_url we have no way to post the deferred result, so + // fail loud rather than silently dropping the user's request. + throw createError({ statusCode: 400, statusMessage: "Missing response_url" }); + } + + logger.info( + { userId, command, parsedKind: parsed.kind }, + "slack_command_dispatching", + ); + + scheduleHandler(event, parsed, responseUrl); + + return ephemeral(`Working on \`${command} ${text}\`…`); +}); + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +function verifyWebhookAuth(event: H3Event, rawBody: string): void { + const signature = getHeader(event, "x-slack-signature"); + const timestamp = getHeader(event, "x-slack-request-timestamp"); + if (!signature || !timestamp) { + throw createError({ statusCode: 401, statusMessage: "Missing Slack signature headers" }); + } + const ok = verifySlackSignature({ + rawBody, + timestamp, + signature, + signingSecret: env.SLACK_SIGNING_SECRET, + }); + if (!ok) { + throw createError({ statusCode: 401, statusMessage: "Invalid Slack signature" }); + } +} + +function isUserAllowed(userId: string): boolean { + if (!env.SLACK_ALLOWED_USER_IDS) return true; + const allow = env.SLACK_ALLOWED_USER_IDS + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (allow.length === 0) return true; + return allow.includes(userId); +} + +// --------------------------------------------------------------------------- +// Body parsing +// --------------------------------------------------------------------------- + +function parseFormBody(rawBody: string): URLSearchParams { + return new URLSearchParams(rawBody); +} + +// --------------------------------------------------------------------------- +// Response shaping +// --------------------------------------------------------------------------- + +function ephemeral(text: string) { + return { response_type: "ephemeral" as const, text }; +} + +// --------------------------------------------------------------------------- +// Deferred work +// --------------------------------------------------------------------------- + +function scheduleHandler( + event: H3Event, + parsed: ParsedCommand, + responseUrl: string, +): void { + const promise = runHandler(parsed, responseUrl); + // Nitro mounts waitUntil onto the event in its app entry. On platforms that + // support it (Vercel, Cloudflare) this lets the function keep working after + // the response is sent. On platforms without it, fall back to fire-and- + // forget — the slash command will still ack within 3s. + if (typeof event.waitUntil === "function") { + event.waitUntil(promise); + return; + } + promise.catch((err) => + logger.error({ error: (err as Error).message }, "slack_handler_unhandled_error"), + ); +} + +async function runHandler(parsed: ParsedCommand, responseUrl: string): Promise { + const text = await executeCommand(parsed); + await postToResponseUrl(responseUrl, { + response_type: "in_channel", + text, + }); +} + +async function executeCommand(parsed: ParsedCommand): Promise { + const { runRegistry } = createAdapters(); + switch (parsed.kind) { + case "list": + return handleList(runRegistry, env.JIRA_BASE_URL); + case "status": + return handleStatus(runRegistry, parsed.ticketKey, env.JIRA_BASE_URL); + case "cancel": + return handleCancel(runRegistry, parsed.ticketKey, cancelRun, stopTicketSandboxes); + case "help": + case "unknown": + // Already handled synchronously, but exhaustive for type-narrowing. + return HELP_TEXT; + } +} From 8b603afb073687ab92fc01ebb4bd34d550020df7 Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 5 May 2026 11:56:25 +0200 Subject: [PATCH 3/4] fix: error handling --- .claude/skills/init-slack/SKILL.md | 6 +-- .../init-slack/references/slash-commands.md | 4 +- .../plans/2026-05-01-slack-slash-commands.md | 2 +- src/lib/slack/handlers.ts | 53 ++++++++++++++++--- src/lib/slack/respond.ts | 7 +++ src/routes/webhooks/slack.post.test.ts | 2 + src/routes/webhooks/slack.post.ts | 15 ++++-- 7 files changed, 71 insertions(+), 18 deletions(-) diff --git a/.claude/skills/init-slack/SKILL.md b/.claude/skills/init-slack/SKILL.md index 4d9a677..69e2e8f 100644 --- a/.claude/skills/init-slack/SKILL.md +++ b/.claude/skills/init-slack/SKILL.md @@ -47,19 +47,19 @@ The bot must be invited to the channel: `/invite @blazebot` from inside the chan ## Step 3 — Emit paste-template -``` +```bash CHAT_SDK_SLACK_TOKEN= CHAT_SDK_CHANNEL_ID= SLACK_SIGNING_SECRET= ``` If non-default bot name: -``` +```bash CHAT_SDK_BOT_NAME= ``` If restricting slash commands to specific users: -``` +```bash SLACK_ALLOWED_USER_IDS=U0123,U4567 ``` diff --git a/.claude/skills/init-slack/references/slash-commands.md b/.claude/skills/init-slack/references/slash-commands.md index 592be02..401dc34 100644 --- a/.claude/skills/init-slack/references/slash-commands.md +++ b/.claude/skills/init-slack/references/slash-commands.md @@ -25,7 +25,7 @@ If you don't want any random workspace member to be able to cancel runs, set `SL How to find a user ID: in Slack, click the person → **View full profile** → **More** (`⋮`) → **Copy member ID** (looks like `U0123ABCD`). -``` +```bash SLACK_ALLOWED_USER_IDS=U0123ABCD,U4567WXYZ ``` @@ -48,7 +48,7 @@ Save. If the app is already installed, Slack will prompt you to **Reinstall** so In any channel the bot can see: -``` +```bash /ai-workflow list ``` diff --git a/docs/superpowers/plans/2026-05-01-slack-slash-commands.md b/docs/superpowers/plans/2026-05-01-slack-slash-commands.md index cc1abd2..d3d5183 100644 --- a/docs/superpowers/plans/2026-05-01-slack-slash-commands.md +++ b/docs/superpowers/plans/2026-05-01-slack-slash-commands.md @@ -120,7 +120,7 @@ Each returns a `string` (Slack mrkdwn) — no Slack I/O inside, so they're trivi - Reinstall app (`commands` scope is already granted on most chat-adapter installs; verify). - Copy the Signing Secret into Vercel env (`SLACK_SIGNING_SECRET`) for Production + Preview. -**Task 5.2:** Update `init-slack` skill (`/Users/kacper/.claude/skills/init-slack`) to also prompt for `SLACK_SIGNING_SECRET` and mention the slash-command URL. Add a one-paragraph operator note in `README.md` under the existing Slack section. +**Task 5.2:** Update `init-slack` skill (`.claude/skills/init-slack`) to also prompt for `SLACK_SIGNING_SECRET` and mention the slash-command URL. Add a one-paragraph operator note in `README.md` under the existing Slack section. --- diff --git a/src/lib/slack/handlers.ts b/src/lib/slack/handlers.ts index 91c527d..c9e9514 100644 --- a/src/lib/slack/handlers.ts +++ b/src/lib/slack/handlers.ts @@ -1,5 +1,6 @@ import type { RunRegistryAdapter } from "../../adapters/run-registry/types.js"; import { isClaimingSentinel } from "../dispatch.js"; +import { logger } from "../logger.js"; import { formatRunList, formatRunStatus } from "./format.js"; export type CancelRunFn = ( @@ -27,10 +28,18 @@ export async function handleStatus( ticketKey: string, jiraBaseUrl: string, ): Promise { - const [runId, sandboxId] = await Promise.all([ - registry.getRunId(ticketKey), - registry.getSandboxId(ticketKey), - ]); + // Sandbox lookup is best-effort: a missing or transiently-failing sandbox + // shouldn't blank out the runId we *can* read. + const runId = await registry.getRunId(ticketKey); + let sandboxId: string | null = null; + try { + sandboxId = await registry.getSandboxId(ticketKey); + } catch (err) { + logger.warn( + { ticketKey, error: (err as Error).message }, + "slack_status_sandbox_lookup_failed", + ); + } return formatRunStatus(ticketKey, { runId, sandboxId }, jiraBaseUrl); } @@ -48,9 +57,39 @@ export async function handleCancel( // real runId. We can't cancel a workflow whose id we don't know. Stop any // sandbox that may have leaked and clear the entry so the next dispatch // sees a clean slot — same shape as the jira webhook handles it. - const sandboxId = await registry.getSandboxId(ticketKey).catch(() => null); - await stopSandboxes(ticketKey, sandboxId).catch(() => {}); - await registry.unregister(ticketKey).catch(() => {}); + let sandboxId: string | null = null; + try { + sandboxId = await registry.getSandboxId(ticketKey); + } catch (err) { + logger.warn( + { ticketKey, error: (err as Error).message }, + "slack_cancel_sandbox_lookup_failed", + ); + } + + const failures: string[] = []; + try { + await stopSandboxes(ticketKey, sandboxId); + } catch (err) { + failures.push(`stopSandboxes: ${(err as Error).message}`); + logger.error( + { ticketKey, sandboxId, error: (err as Error).message }, + "slack_cancel_stop_sandboxes_failed", + ); + } + try { + await registry.unregister(ticketKey); + } catch (err) { + failures.push(`registry.unregister: ${(err as Error).message}`); + logger.error( + { ticketKey, error: (err as Error).message }, + "slack_cancel_unregister_failed", + ); + } + + if (failures.length > 0) { + return `${ticketKey} is mid-dispatch; failed to clear the claim (${failures.join("; ")}). Check logs and retry.`; + } return `${ticketKey} is mid-dispatch; cleared the claim. Try the cancel again in a moment if a real run shows up.`; } diff --git a/src/lib/slack/respond.ts b/src/lib/slack/respond.ts index a6f0013..d8cf8db 100644 --- a/src/lib/slack/respond.ts +++ b/src/lib/slack/respond.ts @@ -18,15 +18,20 @@ export interface SlackResponsePayload { * has already been ack'd should not retry the user's request just because * Slack momentarily 5xx'd on the follow-up post. */ +const RESPONSE_URL_TIMEOUT_MS = 5000; + export async function postToResponseUrl( responseUrl: string, payload: SlackResponsePayload, ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RESPONSE_URL_TIMEOUT_MS); try { const res = await fetch(responseUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), + signal: controller.signal, }); if (!res.ok) { logger.warn( @@ -39,5 +44,7 @@ export async function postToResponseUrl( { error: (err as Error).message }, "slack_response_url_post_error", ); + } finally { + clearTimeout(timer); } } diff --git a/src/routes/webhooks/slack.post.test.ts b/src/routes/webhooks/slack.post.test.ts index 0d35bf0..aeb9ff7 100644 --- a/src/routes/webhooks/slack.post.test.ts +++ b/src/routes/webhooks/slack.post.test.ts @@ -199,6 +199,7 @@ describe("POST /webhooks/slack", () => { await handler(makeRequest(body)); await flushDeferred(); expect(cancelRunFn).not.toHaveBeenCalled(); + expect(postedToResponseUrl).toHaveLength(1); expect(postedToResponseUrl[0]!.payload.text).toContain("No active run for AWT-1"); }); @@ -218,6 +219,7 @@ describe("POST /webhooks/slack", () => { expect(cancelRunFn).toHaveBeenCalledTimes(1); expect(cancelRunFn).toHaveBeenCalledWith("AWT-1", "run_a", runRegistry); + expect(postedToResponseUrl).toHaveLength(1); expect(postedToResponseUrl[0]!.payload.text).toContain("Cancelled AWT-1"); }); diff --git a/src/routes/webhooks/slack.post.ts b/src/routes/webhooks/slack.post.ts index 65b668a..dd4af4b 100644 --- a/src/routes/webhooks/slack.post.ts +++ b/src/routes/webhooks/slack.post.ts @@ -123,18 +123,23 @@ function scheduleHandler( parsed: ParsedCommand, responseUrl: string, ): void { - const promise = runHandler(parsed, responseUrl); + // Always attach error logging *before* handing the promise off — otherwise an + // unhandled rejection inside the waitUntil-extended invocation disappears + // silently. The .catch returns void, so waitUntil still sees a settled + // promise and keeps the function alive for the original work. + const promise = runHandler(parsed, responseUrl).catch((err) => + logger.error( + { error: (err as Error).message, parsedKind: parsed.kind }, + "slack_handler_unhandled_error", + ), + ); // Nitro mounts waitUntil onto the event in its app entry. On platforms that // support it (Vercel, Cloudflare) this lets the function keep working after // the response is sent. On platforms without it, fall back to fire-and- // forget — the slash command will still ack within 3s. if (typeof event.waitUntil === "function") { event.waitUntil(promise); - return; } - promise.catch((err) => - logger.error({ error: (err as Error).message }, "slack_handler_unhandled_error"), - ); } async function runHandler(parsed: ParsedCommand, responseUrl: string): Promise { From 170aa7c67aac3081f814fee93033a2b43897a50e Mon Sep 17 00:00:00 2001 From: kasin-it Date: Tue, 5 May 2026 12:06:04 +0200 Subject: [PATCH 4/4] fix: build --- nitro.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nitro.config.ts b/nitro.config.ts index 7dd4624..4733963 100644 --- a/nitro.config.ts +++ b/nitro.config.ts @@ -5,4 +5,8 @@ export default defineNitroConfig({ modules: ["workflow/nitro"], compatibilityDate: "2025-01-01", srcDir: "src", + // Tests are co-located with source; exclude them from route scanning so a + // file like `slack.post.test.ts` doesn't get matched as a POST route by the + // file-based router. + ignore: ["**/*.test.ts"], });