Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
67 changes: 67 additions & 0 deletions src/routes/jira/dispatch.post.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
}
77 changes: 77 additions & 0 deletions src/routes/runs/[key].get.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getHeader>[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;
}
Loading