From f4326e6a965b763333f2f4861f0c3027ca4a7861 Mon Sep 17 00:00:00 2001 From: woywro Date: Sun, 10 May 2026 23:13:23 +0200 Subject: [PATCH 1/2] feat(jira): route scoped service-account tokens through Atlassian API gateway --- .claude/skills/init-env/SKILL.md | 2 +- .claude/skills/init-jira/SKILL.md | 4 +- .../init-jira/references/column-statuses.md | 5 +- .../init-jira/references/transitions.md | 5 +- .env.e2e.example | 3 +- .env.example | 3 +- SETUP.md | 5 +- e2e/env.ts | 1 - e2e/helpers/jira.ts | 47 ++++++++++-- e2e/tier2/us02-attachments.test.ts | 1 - env.test.ts | 1 - env.ts | 1 - src/adapters/issue-tracker/jira.test.ts | 26 +++++-- src/adapters/issue-tracker/jira.ts | 76 +++++++++++++++---- src/lib/adapters.ts | 1 - src/lib/step-adapters.ts | 1 - 16 files changed, 132 insertions(+), 50 deletions(-) diff --git a/.claude/skills/init-env/SKILL.md b/.claude/skills/init-env/SKILL.md index 7f5f69d..1efe374 100644 --- a/.claude/skills/init-env/SKILL.md +++ b/.claude/skills/init-env/SKILL.md @@ -123,7 +123,7 @@ The CLI is interactive — let the user complete it. On success, `.vercel/projec 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`. +- Asks for `JIRA_BASE_URL` / `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). diff --git a/.claude/skills/init-jira/SKILL.md b/.claude/skills/init-jira/SKILL.md index 09e68ab..e47aec1 100644 --- a/.claude/skills/init-jira/SKILL.md +++ b/.claude/skills/init-jira/SKILL.md @@ -66,8 +66,7 @@ Hold the value for the paste-template below. Even if the user later defers webho 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_API_TOKEN` — scoped service-account token (Bearer) from https://id.atlassian.com/manage-profile/security/api-tokens - `JIRA_PROJECT_KEY` — e.g. `AWT` Then ask: @@ -84,7 +83,6 @@ Print this single block for the user to copy into Vercel → Project Settings ``` JIRA_BASE_URL= -JIRA_EMAIL= JIRA_API_TOKEN= JIRA_PROJECT_KEY= COLUMN_AI= diff --git a/.claude/skills/init-jira/references/column-statuses.md b/.claude/skills/init-jira/references/column-statuses.md index 9425463..161c0af 100644 --- a/.claude/skills/init-jira/references/column-statuses.md +++ b/.claude/skills/init-jira/references/column-statuses.md @@ -39,8 +39,9 @@ Each column should contain exactly one status with the matching name. Don't put ## Verify the status spelling ```bash -curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \ - "$JIRA_BASE_URL/rest/api/3/project/$JIRA_PROJECT_KEY/statuses" | \ +CLOUD_ID=$(curl -s "$JIRA_BASE_URL/_edge/tenant_info" | jq -r .cloudId) +curl -H "Authorization: Bearer $JIRA_API_TOKEN" \ + "https://api.atlassian.com/ex/jira/$CLOUD_ID/rest/api/3/project/$JIRA_PROJECT_KEY/statuses" | \ jq '.[].statuses[].name' ``` diff --git a/.claude/skills/init-jira/references/transitions.md b/.claude/skills/init-jira/references/transitions.md index 5a30559..b0f7e2b 100644 --- a/.claude/skills/init-jira/references/transitions.md +++ b/.claude/skills/init-jira/references/transitions.md @@ -32,8 +32,9 @@ If the source status doesn't have an outbound transition to the target, draw a n 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" | \ +CLOUD_ID=$(curl -s "$JIRA_BASE_URL/_edge/tenant_info" | jq -r .cloudId) +curl -H "Authorization: Bearer $JIRA_API_TOKEN" \ + "https://api.atlassian.com/ex/jira/$CLOUD_ID/rest/api/3/issue/$JIRA_PROJECT_KEY-1/transitions" | \ jq '.transitions[] | {name, to: .to.name}' ``` diff --git a/.env.e2e.example b/.env.e2e.example index 42a800e..8113b19 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -1,9 +1,8 @@ # Target server E2E_BASE_URL=https://your-staging.vercel.app -# Jira +# Jira (scoped service-account Bearer token; routed through api.atlassian.com) JIRA_BASE_URL=https://your-domain.atlassian.net -JIRA_EMAIL=your-email@example.com JIRA_API_TOKEN= JIRA_PROJECT_KEY=PROJ JIRA_WEBHOOK_SECRET= diff --git a/.env.example b/.env.example index 9951000..cf99d31 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # Issue Tracker (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 +# Scoped service-account API token (Bearer). Issue from id.atlassian.com +# under the bot account; requests are routed through api.atlassian.com. JIRA_API_TOKEN=your-jira-api-token JIRA_PROJECT_KEY=PROJ diff --git a/SETUP.md b/SETUP.md index fe89c74..e0e3ce6 100644 --- a/SETUP.md +++ b/SETUP.md @@ -83,8 +83,7 @@ ai-workflow authenticates to Jira as a **scoped service account** — a dedicate **Capture the rest of the config:** 6. Note your Atlassian instance URL (e.g. `https://your-domain.atlassian.net`) → `JIRA_BASE_URL`. -7. Note the email of the **service account** (not your personal email) → `JIRA_EMAIL`. -8. Open the project ai-workflow will operate on. Note its key (e.g. `AWT`) → `JIRA_PROJECT_KEY`. +7. Open the project ai-workflow will operate on. Note its key (e.g. `AWT`) → `JIRA_PROJECT_KEY`. 9. On the project board, identify the three columns ai-workflow uses. Create them if they don't exist: - `COLUMN_AI` — tickets assigned to the agent (default: `AI`) - `COLUMN_AI_REVIEW` — completed tickets pending human review (default: `AI Review`) @@ -228,7 +227,7 @@ vercel env add JIRA_API_TOKEN production | Variable | Purpose | |----------|---------| -| `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` | Jira credentials | +| `JIRA_BASE_URL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` | Jira credentials (scoped service-account Bearer token) | | `COLUMN_AI`, `COLUMN_AI_REVIEW`, `COLUMN_BACKLOG` | Board columns | | `VCS_KIND` | `github` or `gitlab` | | `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_INSTALLATION_ID`, `GITHUB_OWNER`, `GITHUB_REPO` | If `VCS_KIND=github` (GitHub App auth) | diff --git a/e2e/env.ts b/e2e/env.ts index fd7f841..1f80f20 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -4,7 +4,6 @@ const schema = z.object({ E2E_BASE_URL: z.string().url(), JIRA_BASE_URL: z.string().url(), - JIRA_EMAIL: z.string().email(), JIRA_API_TOKEN: z.string().min(1), JIRA_PROJECT_KEY: z.string().min(1), diff --git a/e2e/helpers/jira.ts b/e2e/helpers/jira.ts index ee56278..ed80e88 100644 --- a/e2e/helpers/jira.ts +++ b/e2e/helpers/jira.ts @@ -1,13 +1,38 @@ import { e2eEnv } from "../env.js"; -const authHeader = - "Basic " + - Buffer.from(`${e2eEnv.JIRA_EMAIL}:${e2eEnv.JIRA_API_TOKEN}`).toString( - "base64", - ); +const ATLASSIAN_API_ORIGIN = "https://api.atlassian.com"; +const authHeader = `Bearer ${e2eEnv.JIRA_API_TOKEN}`; + +let cloudIdPromise: Promise | null = null; +function getCloudId(): Promise { + if (cloudIdPromise) return cloudIdPromise; + cloudIdPromise = (async () => { + const tenantOrigin = new URL(e2eEnv.JIRA_BASE_URL).origin; + const res = await fetch(`${tenantOrigin}/_edge/tenant_info`); + if (!res.ok) { + throw new Error( + `Jira cloudId discovery failed: ${res.status} ${res.statusText}`, + ); + } + const data = (await res.json()) as { cloudId?: unknown }; + if (typeof data?.cloudId !== "string" || data.cloudId === "") { + throw new Error("Jira cloudId discovery: missing cloudId in response"); + } + return data.cloudId; + })().catch((err) => { + cloudIdPromise = null; + throw err; + }); + return cloudIdPromise; +} + +async function apiUrl(path: string): Promise { + const cloudId = await getCloudId(); + return `${ATLASSIAN_API_ORIGIN}/ex/jira/${cloudId}${path}`; +} async function jiraRequest(path: string, options?: RequestInit) { - const res = await fetch(`${e2eEnv.JIRA_BASE_URL}${path}`, { + const res = await fetch(await apiUrl(path), { ...options, headers: { Authorization: authHeader, @@ -185,7 +210,7 @@ export async function addAttachment( form.append("file", new Blob([new Uint8Array(content)]), filename); const res = await fetch( - `${e2eEnv.JIRA_BASE_URL}/rest/api/3/issue/${ticketKey}/attachments`, + await apiUrl(`/rest/api/3/issue/${ticketKey}/attachments`), { method: "POST", headers: { @@ -227,7 +252,13 @@ export async function getTicketAttachments( export async function downloadJiraAttachment( contentUrl: string, ): Promise { - const res = await fetch(contentUrl, { + const tenantOrigin = new URL(e2eEnv.JIRA_BASE_URL).origin; + const parsed = new URL(contentUrl); + const url = + parsed.origin === tenantOrigin + ? `${ATLASSIAN_API_ORIGIN}/ex/jira/${await getCloudId()}${parsed.pathname}${parsed.search}` + : contentUrl; + const res = await fetch(url, { headers: { Authorization: authHeader }, }); if (!res.ok) { diff --git a/e2e/tier2/us02-attachments.test.ts b/e2e/tier2/us02-attachments.test.ts index 0c0ea96..36f482d 100644 --- a/e2e/tier2/us02-attachments.test.ts +++ b/e2e/tier2/us02-attachments.test.ts @@ -66,7 +66,6 @@ describe("US-02: Ticket with attachments (real pipeline)", () => { ); const jira = new JiraAdapter({ baseUrl: e2eEnv.JIRA_BASE_URL, - email: e2eEnv.JIRA_EMAIL, apiToken: e2eEnv.JIRA_API_TOKEN, projectKey: e2eEnv.JIRA_PROJECT_KEY, }); diff --git a/env.test.ts b/env.test.ts index 824a7bd..adc6a3e 100644 --- a/env.test.ts +++ b/env.test.ts @@ -4,7 +4,6 @@ describe("env", () => { const VALID_ENV = { ISSUE_TRACKER_KIND: "jira", JIRA_BASE_URL: "https://test.atlassian.net", - JIRA_EMAIL: "test@example.com", JIRA_API_TOKEN: "token", JIRA_PROJECT_KEY: "PROJ", COLUMN_AI: "AI", diff --git a/env.ts b/env.ts index 51516cd..20b2631 100644 --- a/env.ts +++ b/env.ts @@ -13,7 +13,6 @@ export const env = createEnv({ // Issue Tracker 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), JIRA_PROJECT_KEY: z.string().min(1), diff --git a/src/adapters/issue-tracker/jira.test.ts b/src/adapters/issue-tracker/jira.test.ts index 4df2808..266adf8 100644 --- a/src/adapters/issue-tracker/jira.test.ts +++ b/src/adapters/issue-tracker/jira.test.ts @@ -5,12 +5,15 @@ import { IssueTrackerNotFoundError } from "./types.js"; const mockFetch = vi.fn(); global.fetch = mockFetch; +const CLOUD_ID = "test-cloud-id"; +const API_BASE = `https://api.atlassian.com/ex/jira/${CLOUD_ID}`; + function jiraAdapter() { return new JiraAdapter({ baseUrl: "https://test.atlassian.net", - email: "test@example.com", apiToken: "token", projectKey: "PROJ", + cloudId: CLOUD_ID, }); } @@ -234,10 +237,13 @@ describe("JiraAdapter", () => { expect(buf.length).toBe(4); expect(mockFetch).toHaveBeenCalledTimes(2); - // First call: to Jira, with Authorization. + // First call: to Atlassian API gateway, with Bearer Authorization. const firstInit = mockFetch.mock.calls[0][1] as RequestInit; - expect((firstInit.headers as Record).Authorization).toMatch(/^Basic /); + expect((firstInit.headers as Record).Authorization).toMatch(/^Bearer /); expect(firstInit.redirect).toBe("manual"); + expect(mockFetch.mock.calls[0][0]).toBe( + `${API_BASE}/secure/attachment/att-1/mockup.png`, + ); // First response body drained to release the socket back to the pool. expect(cancelFn).toHaveBeenCalledOnce(); @@ -265,14 +271,17 @@ describe("JiraAdapter", () => { expect(firstHeaders.Authorization).toBeUndefined(); }); - it("resolves relative redirect targets and keeps Authorization for same-origin refetches", async () => { + it("rewrites tenant-origin redirect targets onto the Atlassian gateway and keeps Authorization", async () => { mockFetch .mockResolvedValueOnce({ ok: false, status: 302, statusText: "Found", headers: { - get: (n: string) => (n.toLowerCase() === "location" ? "/secure/attachment/att-9/file.png?dl=1" : null), + get: (n: string) => + n.toLowerCase() === "location" + ? "https://test.atlassian.net/secure/attachment/att-9/file.png?dl=1" + : null, }, body: { cancel: vi.fn() }, }) @@ -286,11 +295,14 @@ describe("JiraAdapter", () => { const adapter = jiraAdapter(); await adapter.downloadAttachment("https://test.atlassian.net/secure/attachment/att-9/file.png"); + expect(mockFetch.mock.calls[0][0]).toBe( + `${API_BASE}/secure/attachment/att-9/file.png`, + ); expect(mockFetch.mock.calls[1][0]).toBe( - "https://test.atlassian.net/secure/attachment/att-9/file.png?dl=1", + `${API_BASE}/secure/attachment/att-9/file.png?dl=1`, ); const secondInit = mockFetch.mock.calls[1][1] as RequestInit; - expect((secondInit.headers as Record).Authorization).toMatch(/^Basic /); + expect((secondInit.headers as Record).Authorization).toMatch(/^Bearer /); }); it("also follows one 303 redirect", async () => { diff --git a/src/adapters/issue-tracker/jira.ts b/src/adapters/issue-tracker/jira.ts index 30b1a9a..952049c 100644 --- a/src/adapters/issue-tracker/jira.ts +++ b/src/adapters/issue-tracker/jira.ts @@ -8,26 +8,62 @@ import { export interface JiraConfig { baseUrl: string; - email: string; apiToken: string; projectKey: string; + cloudId?: string; } +const ATLASSIAN_API_ORIGIN = "https://api.atlassian.com"; + export class JiraAdapter implements IssueTrackerAdapter { - private baseUrl: string; - private jiraBaseOrigin: string; + private tenantOrigin: string; private authHeader: string; + private cloudIdPromise: Promise | null; - constructor(private config: JiraConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, ""); - this.jiraBaseOrigin = new URL(this.baseUrl).origin; - this.authHeader = - "Basic " + - Buffer.from(`${config.email}:${config.apiToken}`).toString("base64"); + constructor(config: JiraConfig) { + const trimmed = config.baseUrl.replace(/\/$/, ""); + this.tenantOrigin = new URL(trimmed).origin; + this.authHeader = `Bearer ${config.apiToken}`; + this.cloudIdPromise = config.cloudId + ? Promise.resolve(config.cloudId) + : null; + } + + private getCloudId(): Promise { + if (this.cloudIdPromise) return this.cloudIdPromise; + const pending = this.discoverCloudId(); + this.cloudIdPromise = pending.catch((err) => { + this.cloudIdPromise = null; + throw err; + }); + return this.cloudIdPromise; + } + + private async discoverCloudId(): Promise { + const url = `${this.tenantOrigin}/_edge/tenant_info`; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Jira cloudId discovery failed: ${res.status} ${res.statusText} on ${url}`, + ); + } + const data = (await res.json()) as { cloudId?: unknown }; + if (typeof data?.cloudId !== "string" || data.cloudId === "") { + throw new Error( + `Jira cloudId discovery: missing cloudId in ${url} response`, + ); + } + return data.cloudId; + } + + private async apiUrl(path: string): Promise { + const cloudId = await this.getCloudId(); + return `${ATLASSIAN_API_ORIGIN}/ex/jira/${cloudId}${path}`; } private async request(path: string, options?: RequestInit) { - const res = await fetch(`${this.baseUrl}${path}`, { + const url = await this.apiUrl(path); + const res = await fetch(url, { ...options, headers: { Authorization: this.authHeader, @@ -112,7 +148,7 @@ export class JiraAdapter implements IssueTrackerAdapter { }); const commentId = typeof data?.id === "string" ? data.id : null; if (!commentId) return null; - return `${this.baseUrl}/browse/${encodeURIComponent(id)}?focusedCommentId=${encodeURIComponent(commentId)}`; + return `${this.tenantOrigin}/browse/${encodeURIComponent(id)}?focusedCommentId=${encodeURIComponent(commentId)}`; } async downloadAttachment( @@ -126,7 +162,9 @@ export class JiraAdapter implements IssueTrackerAdapter { if (!url || url.trim() === "") { throw new Error("Jira attachment error: missing attachment content URL"); } - let currentUrl = new URL(url, this.baseUrl).toString(); + let currentUrl = await this.rewriteIfTenant( + new URL(url, this.tenantOrigin).toString(), + ); for (let redirects = 0; redirects <= maxRedirects; redirects++) { const res = await fetch(currentUrl, { @@ -144,9 +182,10 @@ export class JiraAdapter implements IssueTrackerAdapter { `Jira attachment redirect (${res.status}) missing Location header for ${currentUrl}`, ); } - // Drain redirect response body to release the socket back to the pool. await res.body?.cancel?.(); - currentUrl = new URL(location, currentUrl).toString(); + currentUrl = await this.rewriteIfTenant( + new URL(location, currentUrl).toString(), + ); continue; } @@ -164,8 +203,15 @@ export class JiraAdapter implements IssueTrackerAdapter { ); } + private async rewriteIfTenant(url: string): Promise { + const parsed = new URL(url); + if (parsed.origin !== this.tenantOrigin) return url; + const cloudId = await this.getCloudId(); + return `${ATLASSIAN_API_ORIGIN}/ex/jira/${cloudId}${parsed.pathname}${parsed.search}`; + } + private buildAttachmentHeaders(url: string): HeadersInit | undefined { - if (new URL(url).origin !== this.jiraBaseOrigin) return undefined; + if (new URL(url).origin !== ATLASSIAN_API_ORIGIN) return undefined; return { Authorization: this.authHeader }; } diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 73d9a75..4ed301b 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -37,7 +37,6 @@ export function createAdapters(): Adapters { return { issueTracker: new JiraAdapter({ baseUrl: env.JIRA_BASE_URL, - email: env.JIRA_EMAIL, apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, }), diff --git a/src/lib/step-adapters.ts b/src/lib/step-adapters.ts index 9643779..95d43a8 100644 --- a/src/lib/step-adapters.ts +++ b/src/lib/step-adapters.ts @@ -34,7 +34,6 @@ export function createStepAdapters(): StepAdapters { return { issueTracker: new JiraAdapter({ baseUrl: env.JIRA_BASE_URL, - email: env.JIRA_EMAIL, apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, }), From e0fe0bb2e39d787b1c0d0db55236070061aa8905 Mon Sep 17 00:00:00 2001 From: woywro Date: Mon, 11 May 2026 08:29:07 +0200 Subject: [PATCH 2/2] docs(jira): rewrite SETUP around Atlassian service accounts; drop JIRA_EMAIL from CI --- .github/workflows/ci.yml | 3 - .github/workflows/e2e.yml | 3 - SETUP.md | 173 +++++++++++++++++++------------------- 3 files changed, 87 insertions(+), 92 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9518151..8f040cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} @@ -88,7 +87,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} @@ -141,7 +139,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e7c4883..2f92c00 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,7 +29,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} @@ -85,7 +84,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} @@ -141,7 +139,6 @@ jobs: env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} JIRA_PROJECT_KEY: ${{ secrets.JIRA_PROJECT_KEY }} JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }} diff --git a/SETUP.md b/SETUP.md index e0e3ce6..805de87 100644 --- a/SETUP.md +++ b/SETUP.md @@ -25,20 +25,20 @@ End-to-end instructions for deploying ai-workflow to your own Vercel account. Re Local toolchain: -| Tool | Version | Install | -|------|---------|---------| -| Node.js | 20+ | https://nodejs.org | -| pnpm | 10+ | `npm i -g pnpm` | -| Vercel CLI | latest | `npm i -g vercel@latest` | -| Git | 2.40+ | https://git-scm.com | +| Tool | Version | Install | +| ---------- | ------- | ------------------------ | +| Node.js | 20+ | https://nodejs.org | +| pnpm | 10+ | `npm i -g pnpm` | +| Vercel CLI | latest | `npm i -g vercel@latest` | +| Git | 2.40+ | https://git-scm.com | Accounts you must own: - **Vercel** — Pro plan recommended (Cron Jobs, Sandbox, Workflow are paid features on Hobby). - **Atlassian Jira Cloud** — admin access on the project to manage columns, transitions, and webhooks. -- **GitHub** *or* **GitLab** — admin on the target repository (PR + branch creation). +- **GitHub** _or_ **GitLab** — admin on the target repository (PR + branch creation). - **Slack** workspace — admin to install a custom app and register slash commands. -- **Anthropic** *or* **OpenAI** — API key for the agent runtime. +- **Anthropic** _or_ **OpenAI** — API key for the agent runtime. - **Upstash** — installed via Vercel Marketplace in step 4. --- @@ -49,34 +49,22 @@ Do these in any order — you'll paste the resulting values into Vercel in step ### 2.1 Jira -ai-workflow authenticates to Jira as a **scoped service account** — a dedicated Atlassian user that owns only the API token and only the scopes the bot needs. Don't use a human's personal token: rotation, audit, and least-privilege all break down when the bot shares an identity with a real engineer. +ai-workflow authenticates to Jira as an **Atlassian service account** — a machine identity managed in the organization admin, with no human login. Tokens are Bearer-style and routed through `api.atlassian.com/ex/jira/{cloudId}`. Don't use a personal API token from a real user account: rotation, audit, and least-privilege all break down when the bot shares identity with a human. -**Create the service account:** +**Create the service account** (requires Atlassian org admin): -1. In Atlassian admin (https://admin.atlassian.com), invite a new user — e.g. `ai-workflow@your-domain.com` — into the same site as your Jira project. Give it product access to **Jira** only. -2. Add the service account to the project as a **member** (or to a group that has project access). Grant it permission to browse, create, edit, transition, and comment on issues in `JIRA_PROJECT_KEY`. Nothing else. -3. Sign in as the service account once to accept the EULA, then sign out. +1. Go to **https://admin.atlassian.com** → pick your organization → **Directory** → **Service accounts**. +2. **Create service account** → name it (e.g. `ai-workflow`) → grant product access to **Jira** only. +3. In Jira, add the service account (by its principal name) to the target project as a **member** or via a group. Grant exactly: browse, create, edit, transition, and comment on issues in `JIRA_PROJECT_KEY`. Nothing else. **Generate a scoped API token:** -4. Sign back in as the service account and go to https://id.atlassian.com/manage-profile/security/api-tokens → **Create API token with scopes**. Pick the granular scopes below — _not_ a classic unscoped token: - - | Scope | Why | - |-------|-----| - | `read:issue:jira` | Read issue core fields (summary, description, status, assignee, dates) | - | `read:issue-meta:jira` | Read create/edit metadata (which fields exist, which are required, allowed values) | - | `read:issuedetails:jira` | Read full issue payload incl. nested fields, links, relations | - | `read:issue.transition:jira` | List available workflow transitions for an issue (does NOT execute them) | - | `read:comment:jira` | Read issue comments | - | `read:attachment:jira` | Read attachment metadata and download attachment files | - | `read:project:jira` | Read project info (key, name, lead, components, versions) | - | `read:status:jira` | Read workflow status definitions | - | `read:user:jira` | Look up user profiles by accountId / search users | - | `read:jql:jira` | Execute JQL queries (`/search/jql`) to find issues | - | `read:field:jira` | Read field definitions (system + custom field schemas) | - | `read:issue-type:jira` | Read issue type definitions (Task, Bug, Story, custom types) | - | `write:issue:jira` | Create issues, edit fields, assign, execute transitions, delete issues | - | `write:comment:jira` | Add, edit, delete comments on issues | +4. Back in **admin.atlassian.com → Directory → Service accounts**, open the account you just created → **API tokens** → **Create credentials**. Give it a label (e.g. `ai-workflow-prod`) and pick these two **classic** scopes: + + | Scope | Covers | + | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `read:jira-work` | `GET /issue/{id}` (summary, description, comments, labels, status, project, attachments), `GET /issue/{id}/transitions`, `GET /search/jql`, attachment download | + | `write:jira-work` | `POST /issue/{id}/comment`, `POST /issue/{id}/transitions` (move ticket) | 5. Copy the token immediately (it's shown once) → `JIRA_API_TOKEN`. @@ -84,15 +72,15 @@ ai-workflow authenticates to Jira as a **scoped service account** — a dedicate 6. Note your Atlassian instance URL (e.g. `https://your-domain.atlassian.net`) → `JIRA_BASE_URL`. 7. Open the project ai-workflow will operate on. Note its key (e.g. `AWT`) → `JIRA_PROJECT_KEY`. -9. On the project board, identify the three columns ai-workflow uses. Create them if they don't exist: +8. On the project board, identify the three columns ai-workflow uses. Create them if they don't exist: - `COLUMN_AI` — tickets assigned to the agent (default: `AI`) - `COLUMN_AI_REVIEW` — completed tickets pending human review (default: `AI Review`) - `COLUMN_BACKLOG` — tickets bounced back for clarification (default: `Backlog`) -10. Generate a webhook secret to authenticate Jira → Vercel deliveries: - ```bash - openssl rand -hex 32 - ``` - Save as `JIRA_WEBHOOK_SECRET`. You'll register the webhook itself in step 7. +9. Generate a webhook secret to authenticate Jira → Vercel deliveries: + ```bash + openssl rand -hex 32 + ``` + Save as `JIRA_WEBHOOK_SECRET`. You'll register the webhook itself in step 7. > Without a webhook, dispatch falls back to the 1-minute cron poll — workable for testing, sluggish in production. @@ -106,16 +94,17 @@ ai-workflow authenticates to GitHub via a **GitHub App**. The App scopes the bot 2. Set **Webhook → Active** to off (ai-workflow drives via Jira, not GitHub events) and pick a name + homepage URL. 3. Under **Repository permissions**, grant exactly: - | Permission | Access | Why | - |------------|--------|-----| - | Contents | Read & write | Clone the repo, push commits | - | Pull requests | Read & write | Create PRs, fetch PR data | - | Issues | Read & write | PR review comments live on the issues API | - | Checks | Read-only | Read CI check results | - | Actions | Read-only | Read workflow run status | - | Metadata | Read-only | Mandatory, auto-included | + | Permission | Access | Why | + | ------------- | ------------ | ----------------------------------------- | + | Contents | Read & write | Clone the repo, push commits | + | Pull requests | Read & write | Create PRs, fetch PR data | + | Issues | Read & write | PR review comments live on the issues API | + | Checks | Read-only | Read CI check results | + | Actions | Read-only | Read workflow run status | + | Metadata | Read-only | Mandatory, auto-included | Leave every other permission at **No access**. + 4. Choose **Only on this account** for installation scope, create the app, then **Install App** on the target repo's owner and select that one repo. 5. From the app settings page, capture: - **App ID** → `GITHUB_APP_ID` @@ -127,6 +116,7 @@ ai-workflow authenticates to GitHub via a **GitHub App**. The App scopes the bot > The legacy `GITHUB_TOKEN` PAT path was removed — `VCS_KIND=github` now requires the App vars above. `env.ts` enforces this at boot. **GitLab:** + 1. Create a project access token (or PAT) with `api`, `read_repository`, `write_repository` scopes → `GITLAB_TOKEN`. 2. Note the project ID or `group/repo` path → `GITLAB_PROJECT_ID`. 3. For self-hosted, set `GITLAB_HOST` to your instance base URL. @@ -140,12 +130,13 @@ The Slack app powers two things: **notifications** (run start, success, failure 1. Go to https://api.slack.com/apps → **Create New App** → **From scratch**. Name it (e.g. `ai-workflow`) and pick the workspace. 2. Under **OAuth & Permissions → Bot Token Scopes**, add exactly: - | Scope | Why | - |-------|-----| - | `chat:write` | Post notifications to the channel | - | `commands` | Register and respond to the `/ai-workflow` slash command | + | Scope | Why | + | ------------ | -------------------------------------------------------- | + | `chat:write` | Post notifications to the channel | + | `commands` | Register and respond to the `/ai-workflow` slash command | Don't add `chat:write.public` unless you want the bot to post in channels it isn't a member of — keeping it out forces the explicit invite below, which is what you want. + 3. Click **Install to Workspace** and approve. Copy the **Bot User OAuth Token** (`xoxb-...`) → `CHAT_SDK_SLACK_TOKEN`. 4. Under **Basic Information → App Credentials**, copy **Signing Secret** → `SLACK_SIGNING_SECRET`. This authenticates incoming slash-command requests. @@ -166,10 +157,12 @@ The slash command itself is registered in step 8 (after you have a deployment UR Pick one — controlled by `AGENT_KIND`. **Claude (default):** + - Create an API key at https://console.anthropic.com → `ANTHROPIC_API_KEY`. - Optionally pin a model: `CLAUDE_MODEL=claude-opus-4-6` (default). **Codex:** + - `AGENT_KIND=codex` - `CODEX_API_KEY=sk-...` (or `CODEX_CHATGPT_OAUTH_TOKEN`) - Optionally `CODEX_MODEL=gpt-5-codex`. @@ -199,6 +192,7 @@ ai-workflow uses Upstash Redis as its run registry (atomic claim/release for con 4. Vercel auto-injects both vars into Production, Preview, and Development environments. Verify: + ```bash vercel env ls | grep AI_WORKFLOW_KV ``` @@ -225,34 +219,34 @@ vercel env add JIRA_API_TOKEN production ### Required variables -| Variable | Purpose | -|----------|---------| -| `JIRA_BASE_URL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` | Jira credentials (scoped service-account Bearer token) | -| `COLUMN_AI`, `COLUMN_AI_REVIEW`, `COLUMN_BACKLOG` | Board columns | -| `VCS_KIND` | `github` or `gitlab` | -| `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_INSTALLATION_ID`, `GITHUB_OWNER`, `GITHUB_REPO` | If `VCS_KIND=github` (GitHub App auth) | -| `GITLAB_TOKEN`, `GITLAB_PROJECT_ID` | If `VCS_KIND=gitlab` | -| `ANTHROPIC_API_KEY` | If `AGENT_KIND=claude` (default) | -| `CODEX_API_KEY` (or `CODEX_CHATGPT_OAUTH_TOKEN`) | If `AGENT_KIND=codex` | -| `AI_WORKFLOW_KV_REST_API_URL`, `AI_WORKFLOW_KV_REST_API_TOKEN` | Auto-injected by Upstash integration | +| Variable | Purpose | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `JIRA_BASE_URL`, `JIRA_API_TOKEN`, `JIRA_PROJECT_KEY` | Jira credentials (scoped service-account Bearer token) | +| `COLUMN_AI`, `COLUMN_AI_REVIEW`, `COLUMN_BACKLOG` | Board columns | +| `VCS_KIND` | `github` or `gitlab` | +| `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_INSTALLATION_ID`, `GITHUB_OWNER`, `GITHUB_REPO` | If `VCS_KIND=github` (GitHub App auth) | +| `GITLAB_TOKEN`, `GITLAB_PROJECT_ID` | If `VCS_KIND=gitlab` | +| `ANTHROPIC_API_KEY` | If `AGENT_KIND=claude` (default) | +| `CODEX_API_KEY` (or `CODEX_CHATGPT_OAUTH_TOKEN`) | If `AGENT_KIND=codex` | +| `AI_WORKFLOW_KV_REST_API_URL`, `AI_WORKFLOW_KV_REST_API_TOKEN` | Auto-injected by Upstash integration | ### Optional / has defaults -| Variable | Default | Purpose | -|----------|---------|---------| -| `GITHUB_BASE_BRANCH` | `main` | PR target branch | -| `CHAT_SDK_SLACK_TOKEN`, `CHAT_SDK_CHANNEL_ID` | unset | Slack bot. When unset, runs proceed silently (no notifications). | -| `CHAT_SDK_BOT_NAME` | `blazebot` | Slack display name | -| `SLACK_SIGNING_SECRET` | unset | Required only if you register the `/ai-workflow` slash command. When unset, `/webhooks/slack` rejects all requests. | -| `SLACK_ALLOWED_USER_IDS` | empty (anyone) | Comma-separated user IDs allowed to run slash commands | -| `CRON_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, `/cron/poll` accepts unauthenticated callers — strongly recommended in production. | -| `JIRA_WEBHOOK_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, dispatch is cron-bound (1-min latency). | -| `CLAUDE_MODEL` | `claude-opus-4-6` | Anthropic model | -| `CODEX_MODEL` | `gpt-5-codex` | Codex model | -| `MAX_CONCURRENT_AGENTS` | `3` | Parallel sandbox cap | -| `JOB_TIMEOUT_MS` | `1800000` (30 min) | Per-run timeout | -| `POLL_INTERVAL_MS` | `300000` (5 min) | Internal poll cadence | -| `COMMIT_AUTHOR`, `COMMIT_EMAIL` | _unset_ on GitHub → auto-derived from the App (commits author as `[bot]`); GitLab falls back to `ai-workflow-blazity` / `ai-workflow@blazity.com` | Optional override; set both or neither | +| Variable | Default | Purpose | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `GITHUB_BASE_BRANCH` | `main` | PR target branch | +| `CHAT_SDK_SLACK_TOKEN`, `CHAT_SDK_CHANNEL_ID` | unset | Slack bot. When unset, runs proceed silently (no notifications). | +| `CHAT_SDK_BOT_NAME` | `blazebot` | Slack display name | +| `SLACK_SIGNING_SECRET` | unset | Required only if you register the `/ai-workflow` slash command. When unset, `/webhooks/slack` rejects all requests. | +| `SLACK_ALLOWED_USER_IDS` | empty (anyone) | Comma-separated user IDs allowed to run slash commands | +| `CRON_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, `/cron/poll` accepts unauthenticated callers — strongly recommended in production. | +| `JIRA_WEBHOOK_SECRET` | unset | Generate: `openssl rand -hex 32`. Without it, dispatch is cron-bound (1-min latency). | +| `CLAUDE_MODEL` | `claude-opus-4-6` | Anthropic model | +| `CODEX_MODEL` | `gpt-5-codex` | Codex model | +| `MAX_CONCURRENT_AGENTS` | `3` | Parallel sandbox cap | +| `JOB_TIMEOUT_MS` | `1800000` (30 min) | Per-run timeout | +| `POLL_INTERVAL_MS` | `300000` (5 min) | Internal poll cadence | +| `COMMIT_AUTHOR`, `COMMIT_EMAIL` | _unset_ on GitHub → auto-derived from the App (commits author as `[bot]`); GitLab falls back to `ai-workflow-blazity` / `ai-workflow@blazity.com` | Optional override; set both or neither | `env.ts` cross-validates at startup — missing required vars or wrong combinations (e.g. `VCS_KIND=github` without `GITHUB_OWNER`) crash the process with a precise error. @@ -267,6 +261,7 @@ vercel ``` Confirm the preview URL works: + ```bash curl https:///health ``` @@ -315,6 +310,7 @@ Verify by moving a test ticket into the AI column and watching the Vercel runtim 4. Confirm `SLACK_SIGNING_SECRET` is set in Vercel (step 5) — `/webhooks/slack` rejects requests with bad signatures. Test in Slack: + ``` /ai-workflow list ``` @@ -328,12 +324,14 @@ If you set `SLACK_ALLOWED_USER_IDS`, only those Slack user IDs can invoke the co ## 9. Smoke test the deployment ### Health + ```bash curl https:///health # → {"status":"ok","timestamp":"..."} ``` ### Cron auth + ```bash curl https:///cron/poll # → 401 Unauthorized @@ -343,6 +341,7 @@ curl -H "Authorization: Bearer $CRON_SECRET" https:///cron/p ``` ### End-to-end + 1. Create a test Jira ticket with a clear acceptance criterion (e.g. "add a `/ping` route returning `pong`"). 2. Move it to the **AI** column. 3. Within ~1 minute (cron) or instantly (webhook), watch: @@ -378,12 +377,14 @@ The E2E jobs need the production env vars exposed as GitHub Actions secrets in t ### Arthur AI Engine (tracing + hosted prompts) Set both: + ```bash GENAI_ENGINE_API_KEY=... GENAI_ENGINE_TRACE_ENDPOINT=https://your-arthur-host/api/v1/traces ``` Then run once to register hosted prompts: + ```bash pnpm setup:arthur-prompts # saves the resulting task ID — set it as: @@ -400,18 +401,18 @@ Flip `VCS_KIND=gitlab` and provide `GITLAB_TOKEN` + `GITLAB_PROJECT_ID`. For sel ## 12. Troubleshooting -| Symptom | Likely cause | Fix | -|---------|--------------|-----| -| Startup crash: `Invalid environment variables` | Missing required var or wrong cross-field combination | Read the error — `env.ts` lists exactly what's missing. | -| `/cron/poll` returns 401 from Vercel Cron | `CRON_SECRET` mismatch | Ensure the var is set in Production environment. Redeploy after changing. | -| Tickets in AI column never get picked up | Cron disabled / webhook misregistered | Check **Vercel → Project → Cron Jobs** is enabled. Curl `/cron/poll` with the secret to test manually. | -| Workflow starts but sandbox fails to provision | Missing Vercel OIDC / Sandbox quota | On Vercel, OIDC is automatic. Check the project has Sandbox enabled (Pro plan). For local dev, set `VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`. | -| Run registry: `AI_WORKFLOW_KV_REST_API_URL undefined` | Upstash integration installed with wrong prefix | Reinstall with prefix `AI_WORKFLOW` (Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`). | -| Agent runs but PR isn't created | GitHub App missing **Pull requests: Read & write** or **Contents: Read & write**, App not installed on target repo, or wrong owner/repo | In the App settings, re-check **Repository permissions** and the **Installations** list. Verify `GITHUB_OWNER`/`GITHUB_REPO` point at the *target* repo, not this repo. | -| Slack messages don't arrive | Bot not in channel, or wrong `CHAT_SDK_CHANNEL_ID` | Invite bot to the channel. Re-copy the channel ID. | -| Slash command returns `dispatch_failed` | Signing secret wrong, or app not reinstalled | Verify `SLACK_SIGNING_SECRET`. Reinstall the Slack app after adding the slash command. | -| Two pollers race on the same ticket | Stale claim sentinel | The reconciler clears claims older than 5 minutes on every poll — wait one cycle, or flush the registry key in Upstash. | -| Sandbox times out | Job too large for `JOB_TIMEOUT_MS` | Increase to 60–90 minutes for complex tickets, or split the work. | +| Symptom | Likely cause | Fix | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Startup crash: `Invalid environment variables` | Missing required var or wrong cross-field combination | Read the error — `env.ts` lists exactly what's missing. | +| `/cron/poll` returns 401 from Vercel Cron | `CRON_SECRET` mismatch | Ensure the var is set in Production environment. Redeploy after changing. | +| Tickets in AI column never get picked up | Cron disabled / webhook misregistered | Check **Vercel → Project → Cron Jobs** is enabled. Curl `/cron/poll` with the secret to test manually. | +| Workflow starts but sandbox fails to provision | Missing Vercel OIDC / Sandbox quota | On Vercel, OIDC is automatic. Check the project has Sandbox enabled (Pro plan). For local dev, set `VERCEL_TOKEN`/`VERCEL_TEAM_ID`/`VERCEL_PROJECT_ID`. | +| Run registry: `AI_WORKFLOW_KV_REST_API_URL undefined` | Upstash integration installed with wrong prefix | Reinstall with prefix `AI_WORKFLOW` (Upstash appends `_KV_REST_API_URL` / `_KV_REST_API_TOKEN`). | +| Agent runs but PR isn't created | GitHub App missing **Pull requests: Read & write** or **Contents: Read & write**, App not installed on target repo, or wrong owner/repo | In the App settings, re-check **Repository permissions** and the **Installations** list. Verify `GITHUB_OWNER`/`GITHUB_REPO` point at the _target_ repo, not this repo. | +| Slack messages don't arrive | Bot not in channel, or wrong `CHAT_SDK_CHANNEL_ID` | Invite bot to the channel. Re-copy the channel ID. | +| Slash command returns `dispatch_failed` | Signing secret wrong, or app not reinstalled | Verify `SLACK_SIGNING_SECRET`. Reinstall the Slack app after adding the slash command. | +| Two pollers race on the same ticket | Stale claim sentinel | The reconciler clears claims older than 5 minutes on every poll — wait one cycle, or flush the registry key in Upstash. | +| Sandbox times out | Job too large for `JOB_TIMEOUT_MS` | Increase to 60–90 minutes for complex tickets, or split the work. | ### Useful logs