Skip to content

Commit 8e8382d

Browse files
authored
Merge pull request #68 from Blazity/feat/jira-forge-app
feat: add Forge app endpoints for Jira event trigger and run status
2 parents 350a754 + 40ca7dd commit 8e8382d

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ export const env = createEnv({
106106
// Jira Webhook
107107
JIRA_WEBHOOK_SECRET: z.string().min(1).optional(),
108108

109+
// Forge app — shared secret required when using the Jira Forge app
110+
// instead of (or alongside) the classic webhook. Both /jira/dispatch
111+
// and /runs/:key are gated by an X-Forge-Secret header matching this.
112+
FORGE_SHARED_SECRET: z.string().min(1).optional(),
113+
109114
// Redis (run registry)
110115
AI_WORKFLOW_KV_REST_API_URL: z.string().url(),
111116
AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1),

src/routes/jira/dispatch.post.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { timingSafeEqual } from "node:crypto";
2+
import { defineEventHandler, readBody, getHeader, createError } from "h3";
3+
import { env } from "../../../env.js";
4+
import { createAdapters } from "../../lib/adapters.js";
5+
import { dispatchTicket } from "../../lib/dispatch.js";
6+
import { logger } from "../../lib/logger.js";
7+
8+
/**
9+
* Forge app dispatch endpoint — invoked by the Jira Forge app's
10+
* `avi:jira:updated:issue` trigger when a ticket enters the AI column.
11+
*
12+
* Auth: X-Forge-Secret header matching FORGE_SHARED_SECRET. The Forge runtime
13+
* adds the header in `src/index.js` of ai-workflow-jira-app; the same secret
14+
* value is stored in Forge Storage and in this backend's env.
15+
*
16+
* Differs from /webhooks/jira: the Forge trigger has already filtered by
17+
* status, so we skip the column / live-state checks and dispatch directly.
18+
*/
19+
export default defineEventHandler(async (event) => {
20+
if (!env.FORGE_SHARED_SECRET) {
21+
throw createError({ statusCode: 503, statusMessage: "FORGE_SHARED_SECRET not configured" });
22+
}
23+
24+
verifyForgeSecret(getHeader(event, "x-forge-secret"));
25+
26+
const body = (await readBody(event)) as {
27+
issueKey?: string;
28+
cloudId?: string;
29+
source?: string;
30+
};
31+
32+
if (!body?.issueKey) {
33+
throw createError({ statusCode: 400, statusMessage: "Missing issueKey" });
34+
}
35+
36+
logger.info(
37+
{ ticketKey: body.issueKey, cloudId: body.cloudId, source: body.source ?? "forge" },
38+
"forge_dispatch_received",
39+
);
40+
41+
const adapters = createAdapters();
42+
const result = await dispatchTicket(body.issueKey, adapters, env.MAX_CONCURRENT_AGENTS);
43+
44+
logger.info(
45+
{ ticketKey: body.issueKey, started: result.started, reason: result.reason, runId: result.runId },
46+
"forge_dispatch_result",
47+
);
48+
49+
return {
50+
status: result.started ? "dispatched" : "skipped",
51+
ticketKey: body.issueKey,
52+
reason: result.reason,
53+
runId: result.runId,
54+
};
55+
});
56+
57+
function verifyForgeSecret(received: string | undefined): void {
58+
if (!received) {
59+
throw createError({ statusCode: 401, statusMessage: "Missing X-Forge-Secret header" });
60+
}
61+
const expected = env.FORGE_SHARED_SECRET!;
62+
const a = Buffer.from(received);
63+
const b = Buffer.from(expected);
64+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
65+
throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" });
66+
}
67+
}

src/routes/runs/[key].get.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { timingSafeEqual } from "node:crypto";
2+
import { defineEventHandler, getHeader, getRouterParam, createError } from "h3";
3+
import { env } from "../../../env.js";
4+
import { createAdapters } from "../../lib/adapters.js";
5+
import { logger } from "../../lib/logger.js";
6+
7+
/**
8+
* Returns the current run state for a ticket, used by the Forge issue panel.
9+
*
10+
* Shape is intentionally narrow — only what the panel renders. Phase / PR URL
11+
* are best-effort: the run registry tracks runId + sandboxId only, so richer
12+
* state would require either expanding the registry or reading from VCS.
13+
*/
14+
export default defineEventHandler(async (event) => {
15+
if (!env.FORGE_SHARED_SECRET) {
16+
throw createError({ statusCode: 503, statusMessage: "FORGE_SHARED_SECRET not configured" });
17+
}
18+
19+
verifyForgeSecret(getHeader(event, "x-forge-secret"));
20+
21+
const issueKey = getRouterParam(event, "key");
22+
if (!issueKey) {
23+
throw createError({ statusCode: 400, statusMessage: "Missing issueKey" });
24+
}
25+
26+
const adapters = createAdapters();
27+
const runId = await adapters.runRegistry.getRunId(issueKey);
28+
29+
if (!runId) {
30+
const failed = await adapters.runRegistry.isTicketFailed(issueKey).catch(() => false);
31+
if (failed) {
32+
return { status: "failed", issueKey };
33+
}
34+
setResponseStatus(event, 404);
35+
return { status: "idle", issueKey };
36+
}
37+
38+
const sandboxId = await adapters.runRegistry.getSandboxId(issueKey).catch(() => null);
39+
40+
// Branch convention is set in agent.ts: `blazebot/{ticketKey-lowercase}`.
41+
// Mirror it here so the panel can show a PR link without expanding the
42+
// run registry schema.
43+
let prUrl: string | null = null;
44+
try {
45+
const branchName = `blazebot/${issueKey.toLowerCase()}`;
46+
const pr = await adapters.vcs.findPR(branchName);
47+
prUrl = pr?.url ?? null;
48+
} catch (err) {
49+
logger.debug({ issueKey, error: (err as Error).message }, "forge_runs_pr_lookup_failed");
50+
}
51+
52+
return {
53+
status: "active",
54+
issueKey,
55+
runId,
56+
sandboxId,
57+
prUrl,
58+
};
59+
});
60+
61+
function verifyForgeSecret(received: string | undefined): void {
62+
if (!received) {
63+
throw createError({ statusCode: 401, statusMessage: "Missing X-Forge-Secret header" });
64+
}
65+
const expected = env.FORGE_SHARED_SECRET!;
66+
const a = Buffer.from(received);
67+
const b = Buffer.from(expected);
68+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
69+
throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" });
70+
}
71+
}
72+
73+
function setResponseStatus(event: Parameters<typeof getHeader>[0], status: number): void {
74+
// h3 has setResponseStatus but importing it here to keep the file's imports
75+
// explicit and self-contained.
76+
(event.node?.res ?? (event as any).res).statusCode = status;
77+
}

0 commit comments

Comments
 (0)