diff --git a/.env.example b/.env.example index 343b006..b8d55a9 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,15 @@ COLUMN_BACKLOG=Backlog # Without this, dispatch falls back to ~60s cron polling on every ticket. JIRA_WEBHOOK_SECRET= +# Forge bridge (alternative to JIRA_WEBHOOK_SECRET; replaces the personal +# API-token comment author with the ai-workflow-jira-app Forge app user). +# Set both when running the companion ai-workflow-jira-app: +# - FORGE_SHARED_SECRET: same value passed to `forge variables set --encrypt SHARED_SECRET ...` +# - FORGE_COMMENT_URL: the `post-comment` URL printed by `forge webtrigger` +# When unset, /jira/dispatch returns 503 and postComment uses Basic auth. +FORGE_SHARED_SECRET= +FORGE_COMMENT_URL= + # 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 diff --git a/env.ts b/env.ts index 51516cd..9408d02 100644 --- a/env.ts +++ b/env.ts @@ -116,6 +116,13 @@ export const env = createEnv({ // Jira Webhook JIRA_WEBHOOK_SECRET: z.string().min(1).optional(), + // Forge bridge — when both are set, /jira/dispatch authenticates the + // X-Forge-Secret header and JiraAdapter.postComment routes through the + // ai-workflow-jira-app Forge web trigger so comments are authored by + // the Forge app user instead of a personal Atlassian account. + FORGE_SHARED_SECRET: z.string().min(1).optional(), + FORGE_COMMENT_URL: z.string().url().optional(), + // Redis (run registry) AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), diff --git a/src/adapters/issue-tracker/jira.test.ts b/src/adapters/issue-tracker/jira.test.ts index 4df2808..c197b77 100644 --- a/src/adapters/issue-tracker/jira.test.ts +++ b/src/adapters/issue-tracker/jira.test.ts @@ -493,4 +493,109 @@ describe("JiraAdapter", () => { expect(collectText(body.body)).not.toContain("\n"); }); }); + + describe("postComment via Forge bridge", () => { + function forgeAdapter() { + return new JiraAdapter({ + baseUrl: "https://test.atlassian.net", + email: "test@example.com", + apiToken: "token", + projectKey: "PROJ", + forgeCommentUrl: "https://forge.example/x/post-comment", + forgeSharedSecret: "shh", + }); + } + + it("POSTs to the Forge web trigger with shared-secret header and plain-text body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id: "55555", permalinkPath: "?focusedCommentId=55555" }), + }); + + const url = await forgeAdapter().postComment("PROJ-1", "hello\nworld"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [calledUrl, init] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe("https://forge.example/x/post-comment"); + const headers = init.headers as Record; + expect(headers["x-shared-secret"]).toBe("shh"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(init.body)).toEqual({ + issueKey: "PROJ-1", + body: "hello\nworld", + }); + expect(url).toBe( + "https://test.atlassian.net/browse/PROJ-1?focusedCommentId=55555", + ); + }); + + it("returns null when Forge response omits id or permalinkPath", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id: null, permalinkPath: null }), + }); + + const url = await forgeAdapter().postComment("PROJ-1", "hi"); + expect(url).toBeNull(); + }); + + it("maps 404 from Forge to IssueTrackerNotFoundError", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + json: async () => ({ error: "jira_error", status: 404 }), + }); + + await expect(forgeAdapter().postComment("PROJ-9", "x")).rejects.toBeInstanceOf( + IssueTrackerNotFoundError, + ); + }); + + it("throws on other non-2xx Forge responses", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: async () => ({ error: "jira_error", status: 500 }), + }); + + await expect(forgeAdapter().postComment("PROJ-1", "x")).rejects.toThrow( + /Forge postComment error: 502/, + ); + }); + + it("falls back to direct Basic-auth path when only one Forge field is set", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "98765" }), + }); + + const adapter = new JiraAdapter({ + baseUrl: "https://test.atlassian.net", + email: "test@example.com", + apiToken: "token", + projectKey: "PROJ", + forgeCommentUrl: "https://forge.example/x/post-comment", + // forgeSharedSecret intentionally omitted + }); + + const url = await adapter.postComment("PROJ-1", "hi"); + + const [calledUrl, init] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe( + "https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment", + ); + expect((init.headers as Record).Authorization).toMatch( + /^Basic /, + ); + expect(url).toBe( + "https://test.atlassian.net/browse/PROJ-1?focusedCommentId=98765", + ); + }); + }); }); diff --git a/src/adapters/issue-tracker/jira.ts b/src/adapters/issue-tracker/jira.ts index 30b1a9a..b4021a0 100644 --- a/src/adapters/issue-tracker/jira.ts +++ b/src/adapters/issue-tracker/jira.ts @@ -11,6 +11,8 @@ export interface JiraConfig { email: string; apiToken: string; projectKey: string; + forgeCommentUrl?: string; + forgeSharedSecret?: string; } export class JiraAdapter implements IssueTrackerAdapter { @@ -39,7 +41,9 @@ export class JiraAdapter implements IssueTrackerAdapter { if (res.status === 404) { throw new IssueTrackerNotFoundError("Jira resource", path); } - throw new Error(`Jira API error: ${res.status} ${res.statusText} on ${path}`); + throw new Error( + `Jira API error: ${res.status} ${res.statusText} on ${path}`, + ); } if (res.status === 204) return null; try { @@ -69,17 +73,19 @@ export class JiraAdapter implements IssueTrackerAdapter { ), labels: data.fields.labels ?? [], trackerStatus: data.fields.status?.name ?? "", - attachments: (data.fields.attachment ?? []).map((a: any): TicketAttachment => { - const contentUrl = - a.content == null ? undefined : String(a.content).trim(); - return { - id: String(a.id), - filename: a.filename ?? "", - mimeType: a.mimeType ?? "application/octet-stream", - size: sanitizeAttachmentSize(a.size), - contentUrl: contentUrl || undefined, - }; - }), + attachments: (data.fields.attachment ?? []).map( + (a: any): TicketAttachment => { + const contentUrl = + a.content == null ? undefined : String(a.content).trim(); + return { + id: String(a.id), + filename: a.filename ?? "", + mimeType: a.mimeType ?? "application/octet-stream", + size: sanitizeAttachmentSize(a.size), + contentUrl: contentUrl || undefined, + }; + }, + ), }; } @@ -100,6 +106,9 @@ export class JiraAdapter implements IssueTrackerAdapter { } async postComment(id: string, comment: string): Promise { + if (this.config.forgeCommentUrl && this.config.forgeSharedSecret) { + return this.postCommentViaForge(id, comment); + } const data = await this.request(`/rest/api/3/issue/${id}/comment`, { method: "POST", body: JSON.stringify({ @@ -115,6 +124,34 @@ export class JiraAdapter implements IssueTrackerAdapter { return `${this.baseUrl}/browse/${encodeURIComponent(id)}?focusedCommentId=${encodeURIComponent(commentId)}`; } + private async postCommentViaForge( + id: string, + comment: string, + ): Promise { + const res = await fetch(this.config.forgeCommentUrl!, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-shared-secret": this.config.forgeSharedSecret!, + }, + body: JSON.stringify({ issueKey: id, body: comment }), + }); + if (res.status === 404) { + throw new IssueTrackerNotFoundError("Jira issue", id); + } + if (!res.ok) { + throw new Error( + `Forge postComment error: ${res.status} ${res.statusText}`, + ); + } + const data = (await res.json()) as { + id?: string | null; + permalinkPath?: string | null; + }; + if (!data?.id || !data?.permalinkPath) return null; + return `${this.baseUrl}/browse/${encodeURIComponent(id)}${data.permalinkPath}`; + } + async downloadAttachment( url: string, opts: { timeoutMs?: number } = {}, @@ -201,7 +238,9 @@ function extractAdfText(adf: any): string { function extractAcceptanceCriteria(description: any): string { const text = extractAdfText(description); - const match = text.match(/acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i); + const match = text.match( + /acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i, + ); return match?.[1]?.trim() ?? ""; } diff --git a/src/lib/adapters.ts b/src/lib/adapters.ts index 73d9a75..16597b9 100644 --- a/src/lib/adapters.ts +++ b/src/lib/adapters.ts @@ -40,6 +40,8 @@ export function createAdapters(): Adapters { email: env.JIRA_EMAIL, apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, + forgeCommentUrl: env.FORGE_COMMENT_URL, + forgeSharedSecret: env.FORGE_SHARED_SECRET, }), vcs: createVCS(), messaging, diff --git a/src/lib/step-adapters.ts b/src/lib/step-adapters.ts index 9643779..2cc89d1 100644 --- a/src/lib/step-adapters.ts +++ b/src/lib/step-adapters.ts @@ -37,6 +37,8 @@ export function createStepAdapters(): StepAdapters { email: env.JIRA_EMAIL, apiToken: env.JIRA_API_TOKEN, projectKey: env.JIRA_PROJECT_KEY, + forgeCommentUrl: env.FORGE_COMMENT_URL, + forgeSharedSecret: env.FORGE_SHARED_SECRET, }), vcs: createVCS(), messaging, diff --git a/src/routes/jira/dispatch.post.ts b/src/routes/jira/dispatch.post.ts new file mode 100644 index 0000000..05154ff --- /dev/null +++ b/src/routes/jira/dispatch.post.ts @@ -0,0 +1,67 @@ +import { timingSafeEqual } from "node:crypto"; +import { defineEventHandler, readBody, getHeader, createError } from "h3"; +import { env } from "../../../env.js"; +import { createAdapters } from "../../lib/adapters.js"; +import { dispatchTicket } from "../../lib/dispatch.js"; +import { logger } from "../../lib/logger.js"; + +/** + * Forge bridge endpoint — receives forwarded Jira issue-updated events from + * the ai-workflow-jira-app Forge app. Authenticated by a shared secret in + * the X-Forge-Secret header instead of HMAC over the body (Forge handles the + * Jira-side auth via api.asApp(), so we only need to verify that the caller + * is our own Forge install). + * + * Coexists with /webhooks/jira: either path can drive dispatch during the + * cutover. After Forge is confirmed working, deregister the manual webhook + * in Jira admin UI. + */ +export default defineEventHandler(async (event) => { + if (!env.FORGE_SHARED_SECRET) { + throw createError({ statusCode: 503, statusMessage: "Forge bridge disabled" }); + } + + const provided = getHeader(event, "x-forge-secret") ?? ""; + const a = Buffer.from(provided); + const b = Buffer.from(env.FORGE_SHARED_SECRET); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" }); + } + + const body = await readBody(event); + const ticketKey = typeof body?.issueKey === "string" ? body.issueKey.trim() : ""; + if (!ticketKey) { + return { status: "ignored", reason: "no_ticket_key" }; + } + + const expectedPrefix = `${env.JIRA_PROJECT_KEY.trim().toUpperCase()}-`; + if (!ticketKey.toUpperCase().startsWith(expectedPrefix)) { + logger.debug( + { ticketKey, expectedProject: env.JIRA_PROJECT_KEY }, + "forge_dispatch_ignored_wrong_project", + ); + return { status: "ignored", reason: "wrong_project", ticketKey }; + } + + logger.info( + { + ticketKey, + source: typeof body?.source === "string" ? body.source : "forge", + cloudId: typeof body?.cloudId === "string" ? body.cloudId : null, + payloadStatus: typeof body?.payloadStatus === "string" ? body.payloadStatus : null, + }, + "forge_dispatch_received", + ); + + const adapters = createAdapters(); + const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS); + logger.info( + { ticketKey, started: result.started, reason: result.reason, runId: result.runId }, + "forge_dispatch_result", + ); + return { + status: result.started ? "dispatched" : "skipped", + ticketKey, + reason: result.reason, + }; +});