From 40ca7dd297f203f378cc9490885df90ab6e022a8 Mon Sep 17 00:00:00 2001 From: woywro Date: Thu, 7 May 2026 13:33:30 +0200 Subject: [PATCH] feat: add Forge app endpoints for Jira event trigger and run status --- env.ts | 5 +++ src/routes/jira/dispatch.post.ts | 67 +++++++++++++++++++++++++++ src/routes/runs/[key].get.ts | 77 ++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/routes/jira/dispatch.post.ts create mode 100644 src/routes/runs/[key].get.ts diff --git a/env.ts b/env.ts index 3b58417..2f4a8c0 100644 --- a/env.ts +++ b/env.ts @@ -106,6 +106,11 @@ export const env = createEnv({ // Jira Webhook JIRA_WEBHOOK_SECRET: z.string().min(1).optional(), + // Forge app — shared secret required when using the Jira Forge app + // instead of (or alongside) the classic webhook. Both /jira/dispatch + // and /runs/:key are gated by an X-Forge-Secret header matching this. + FORGE_SHARED_SECRET: z.string().min(1).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/routes/jira/dispatch.post.ts b/src/routes/jira/dispatch.post.ts new file mode 100644 index 0000000..f357f2a --- /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 app dispatch endpoint — invoked by the Jira Forge app's + * `avi:jira:updated:issue` trigger when a ticket enters the AI column. + * + * Auth: X-Forge-Secret header matching FORGE_SHARED_SECRET. The Forge runtime + * adds the header in `src/index.js` of ai-workflow-jira-app; the same secret + * value is stored in Forge Storage and in this backend's env. + * + * Differs from /webhooks/jira: the Forge trigger has already filtered by + * status, so we skip the column / live-state checks and dispatch directly. + */ +export default defineEventHandler(async (event) => { + if (!env.FORGE_SHARED_SECRET) { + throw createError({ statusCode: 503, statusMessage: "FORGE_SHARED_SECRET not configured" }); + } + + verifyForgeSecret(getHeader(event, "x-forge-secret")); + + const body = (await readBody(event)) as { + issueKey?: string; + cloudId?: string; + source?: string; + }; + + if (!body?.issueKey) { + throw createError({ statusCode: 400, statusMessage: "Missing issueKey" }); + } + + logger.info( + { ticketKey: body.issueKey, cloudId: body.cloudId, source: body.source ?? "forge" }, + "forge_dispatch_received", + ); + + const adapters = createAdapters(); + const result = await dispatchTicket(body.issueKey, adapters, env.MAX_CONCURRENT_AGENTS); + + logger.info( + { ticketKey: body.issueKey, started: result.started, reason: result.reason, runId: result.runId }, + "forge_dispatch_result", + ); + + return { + status: result.started ? "dispatched" : "skipped", + ticketKey: body.issueKey, + reason: result.reason, + runId: result.runId, + }; +}); + +function verifyForgeSecret(received: string | undefined): void { + if (!received) { + throw createError({ statusCode: 401, statusMessage: "Missing X-Forge-Secret header" }); + } + const expected = env.FORGE_SHARED_SECRET!; + const a = Buffer.from(received); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" }); + } +} diff --git a/src/routes/runs/[key].get.ts b/src/routes/runs/[key].get.ts new file mode 100644 index 0000000..8d0bccb --- /dev/null +++ b/src/routes/runs/[key].get.ts @@ -0,0 +1,77 @@ +import { timingSafeEqual } from "node:crypto"; +import { defineEventHandler, getHeader, getRouterParam, createError } from "h3"; +import { env } from "../../../env.js"; +import { createAdapters } from "../../lib/adapters.js"; +import { logger } from "../../lib/logger.js"; + +/** + * Returns the current run state for a ticket, used by the Forge issue panel. + * + * Shape is intentionally narrow — only what the panel renders. Phase / PR URL + * are best-effort: the run registry tracks runId + sandboxId only, so richer + * state would require either expanding the registry or reading from VCS. + */ +export default defineEventHandler(async (event) => { + if (!env.FORGE_SHARED_SECRET) { + throw createError({ statusCode: 503, statusMessage: "FORGE_SHARED_SECRET not configured" }); + } + + verifyForgeSecret(getHeader(event, "x-forge-secret")); + + const issueKey = getRouterParam(event, "key"); + if (!issueKey) { + throw createError({ statusCode: 400, statusMessage: "Missing issueKey" }); + } + + const adapters = createAdapters(); + const runId = await adapters.runRegistry.getRunId(issueKey); + + if (!runId) { + const failed = await adapters.runRegistry.isTicketFailed(issueKey).catch(() => false); + if (failed) { + return { status: "failed", issueKey }; + } + setResponseStatus(event, 404); + return { status: "idle", issueKey }; + } + + const sandboxId = await adapters.runRegistry.getSandboxId(issueKey).catch(() => null); + + // Branch convention is set in agent.ts: `blazebot/{ticketKey-lowercase}`. + // Mirror it here so the panel can show a PR link without expanding the + // run registry schema. + let prUrl: string | null = null; + try { + const branchName = `blazebot/${issueKey.toLowerCase()}`; + const pr = await adapters.vcs.findPR(branchName); + prUrl = pr?.url ?? null; + } catch (err) { + logger.debug({ issueKey, error: (err as Error).message }, "forge_runs_pr_lookup_failed"); + } + + return { + status: "active", + issueKey, + runId, + sandboxId, + prUrl, + }; +}); + +function verifyForgeSecret(received: string | undefined): void { + if (!received) { + throw createError({ statusCode: 401, statusMessage: "Missing X-Forge-Secret header" }); + } + const expected = env.FORGE_SHARED_SECRET!; + const a = Buffer.from(received); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" }); + } +} + +function setResponseStatus(event: Parameters[0], status: number): void { + // h3 has setResponseStatus but importing it here to keep the file's imports + // explicit and self-contained. + (event.node?.res ?? (event as any).res).statusCode = status; +}