Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
105 changes: 105 additions & 0 deletions src/adapters/issue-tracker/jira.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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<string, string>).Authorization).toMatch(
/^Basic /,
);
expect(url).toBe(
"https://test.atlassian.net/browse/PROJ-1?focusedCommentId=98765",
);
});
});
});
65 changes: 52 additions & 13 deletions src/adapters/issue-tracker/jira.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface JiraConfig {
email: string;
apiToken: string;
projectKey: string;
forgeCommentUrl?: string;
forgeSharedSecret?: string;
}

export class JiraAdapter implements IssueTrackerAdapter {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
},
),
};
}

Expand All @@ -100,6 +106,9 @@ export class JiraAdapter implements IssueTrackerAdapter {
}

async postComment(id: string, comment: string): Promise<string | null> {
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({
Expand All @@ -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<string | null> {
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 } = {},
Expand Down Expand Up @@ -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() ?? "";
}

Expand Down
2 changes: 2 additions & 0 deletions src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/step-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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 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,
};
});
Loading