Skip to content

Commit 7612bf2

Browse files
authored
Merge pull request #70 from Blazity/feat/jira-app
feat/jira-app
2 parents 9ebbec5 + f5bc5ee commit 7612bf2

7 files changed

Lines changed: 244 additions & 13 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ COLUMN_BACKLOG=Backlog
1313
# Without this, dispatch falls back to ~60s cron polling on every ticket.
1414
JIRA_WEBHOOK_SECRET=
1515

16+
# Forge bridge (alternative to JIRA_WEBHOOK_SECRET; replaces the personal
17+
# API-token comment author with the ai-workflow-jira-app Forge app user).
18+
# Set both when running the companion ai-workflow-jira-app:
19+
# - FORGE_SHARED_SECRET: same value passed to `forge variables set --encrypt SHARED_SECRET ...`
20+
# - FORGE_COMMENT_URL: the `post-comment` URL printed by `forge webtrigger`
21+
# When unset, /jira/dispatch returns 503 and postComment uses Basic auth.
22+
FORGE_SHARED_SECRET=
23+
FORGE_COMMENT_URL=
24+
1625
# VCS — choose one provider by setting VCS_KIND to "github" or "gitlab".
1726
# Only ONE VCS_KIND line should be active in this file.
1827
VCS_KIND=github

env.ts

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

109+
// Forge bridge — when both are set, /jira/dispatch authenticates the
110+
// X-Forge-Secret header and JiraAdapter.postComment routes through the
111+
// ai-workflow-jira-app Forge web trigger so comments are authored by
112+
// the Forge app user instead of a personal Atlassian account.
113+
FORGE_SHARED_SECRET: z.string().min(1).optional(),
114+
FORGE_COMMENT_URL: z.string().url().optional(),
115+
109116
// Redis (run registry)
110117
AI_WORKFLOW_KV_REST_API_URL: z.string().url(),
111118
AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1),

src/adapters/issue-tracker/jira.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,4 +493,109 @@ describe("JiraAdapter", () => {
493493
expect(collectText(body.body)).not.toContain("\n");
494494
});
495495
});
496+
497+
describe("postComment via Forge bridge", () => {
498+
function forgeAdapter() {
499+
return new JiraAdapter({
500+
baseUrl: "https://test.atlassian.net",
501+
email: "test@example.com",
502+
apiToken: "token",
503+
projectKey: "PROJ",
504+
forgeCommentUrl: "https://forge.example/x/post-comment",
505+
forgeSharedSecret: "shh",
506+
});
507+
}
508+
509+
it("POSTs to the Forge web trigger with shared-secret header and plain-text body", async () => {
510+
mockFetch.mockResolvedValueOnce({
511+
ok: true,
512+
status: 200,
513+
statusText: "OK",
514+
json: async () => ({ id: "55555", permalinkPath: "?focusedCommentId=55555" }),
515+
});
516+
517+
const url = await forgeAdapter().postComment("PROJ-1", "hello\nworld");
518+
519+
expect(mockFetch).toHaveBeenCalledTimes(1);
520+
const [calledUrl, init] = mockFetch.mock.calls[0];
521+
expect(calledUrl).toBe("https://forge.example/x/post-comment");
522+
const headers = init.headers as Record<string, string>;
523+
expect(headers["x-shared-secret"]).toBe("shh");
524+
expect(headers["Content-Type"]).toBe("application/json");
525+
expect(JSON.parse(init.body)).toEqual({
526+
issueKey: "PROJ-1",
527+
body: "hello\nworld",
528+
});
529+
expect(url).toBe(
530+
"https://test.atlassian.net/browse/PROJ-1?focusedCommentId=55555",
531+
);
532+
});
533+
534+
it("returns null when Forge response omits id or permalinkPath", async () => {
535+
mockFetch.mockResolvedValueOnce({
536+
ok: true,
537+
status: 200,
538+
statusText: "OK",
539+
json: async () => ({ id: null, permalinkPath: null }),
540+
});
541+
542+
const url = await forgeAdapter().postComment("PROJ-1", "hi");
543+
expect(url).toBeNull();
544+
});
545+
546+
it("maps 404 from Forge to IssueTrackerNotFoundError", async () => {
547+
mockFetch.mockResolvedValueOnce({
548+
ok: false,
549+
status: 404,
550+
statusText: "Not Found",
551+
json: async () => ({ error: "jira_error", status: 404 }),
552+
});
553+
554+
await expect(forgeAdapter().postComment("PROJ-9", "x")).rejects.toBeInstanceOf(
555+
IssueTrackerNotFoundError,
556+
);
557+
});
558+
559+
it("throws on other non-2xx Forge responses", async () => {
560+
mockFetch.mockResolvedValueOnce({
561+
ok: false,
562+
status: 502,
563+
statusText: "Bad Gateway",
564+
json: async () => ({ error: "jira_error", status: 500 }),
565+
});
566+
567+
await expect(forgeAdapter().postComment("PROJ-1", "x")).rejects.toThrow(
568+
/Forge postComment error: 502/,
569+
);
570+
});
571+
572+
it("falls back to direct Basic-auth path when only one Forge field is set", async () => {
573+
mockFetch.mockResolvedValueOnce({
574+
ok: true,
575+
json: async () => ({ id: "98765" }),
576+
});
577+
578+
const adapter = new JiraAdapter({
579+
baseUrl: "https://test.atlassian.net",
580+
email: "test@example.com",
581+
apiToken: "token",
582+
projectKey: "PROJ",
583+
forgeCommentUrl: "https://forge.example/x/post-comment",
584+
// forgeSharedSecret intentionally omitted
585+
});
586+
587+
const url = await adapter.postComment("PROJ-1", "hi");
588+
589+
const [calledUrl, init] = mockFetch.mock.calls[0];
590+
expect(calledUrl).toBe(
591+
"https://test.atlassian.net/rest/api/3/issue/PROJ-1/comment",
592+
);
593+
expect((init.headers as Record<string, string>).Authorization).toMatch(
594+
/^Basic /,
595+
);
596+
expect(url).toBe(
597+
"https://test.atlassian.net/browse/PROJ-1?focusedCommentId=98765",
598+
);
599+
});
600+
});
496601
});

src/adapters/issue-tracker/jira.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface JiraConfig {
1111
email: string;
1212
apiToken: string;
1313
projectKey: string;
14+
forgeCommentUrl?: string;
15+
forgeSharedSecret?: string;
1416
}
1517

1618
export class JiraAdapter implements IssueTrackerAdapter {
@@ -39,7 +41,9 @@ export class JiraAdapter implements IssueTrackerAdapter {
3941
if (res.status === 404) {
4042
throw new IssueTrackerNotFoundError("Jira resource", path);
4143
}
42-
throw new Error(`Jira API error: ${res.status} ${res.statusText} on ${path}`);
44+
throw new Error(
45+
`Jira API error: ${res.status} ${res.statusText} on ${path}`,
46+
);
4347
}
4448
if (res.status === 204) return null;
4549
try {
@@ -69,17 +73,19 @@ export class JiraAdapter implements IssueTrackerAdapter {
6973
),
7074
labels: data.fields.labels ?? [],
7175
trackerStatus: data.fields.status?.name ?? "",
72-
attachments: (data.fields.attachment ?? []).map((a: any): TicketAttachment => {
73-
const contentUrl =
74-
a.content == null ? undefined : String(a.content).trim();
75-
return {
76-
id: String(a.id),
77-
filename: a.filename ?? "",
78-
mimeType: a.mimeType ?? "application/octet-stream",
79-
size: sanitizeAttachmentSize(a.size),
80-
contentUrl: contentUrl || undefined,
81-
};
82-
}),
76+
attachments: (data.fields.attachment ?? []).map(
77+
(a: any): TicketAttachment => {
78+
const contentUrl =
79+
a.content == null ? undefined : String(a.content).trim();
80+
return {
81+
id: String(a.id),
82+
filename: a.filename ?? "",
83+
mimeType: a.mimeType ?? "application/octet-stream",
84+
size: sanitizeAttachmentSize(a.size),
85+
contentUrl: contentUrl || undefined,
86+
};
87+
},
88+
),
8389
};
8490
}
8591

@@ -100,6 +106,9 @@ export class JiraAdapter implements IssueTrackerAdapter {
100106
}
101107

102108
async postComment(id: string, comment: string): Promise<string | null> {
109+
if (this.config.forgeCommentUrl && this.config.forgeSharedSecret) {
110+
return this.postCommentViaForge(id, comment);
111+
}
103112
const data = await this.request(`/rest/api/3/issue/${id}/comment`, {
104113
method: "POST",
105114
body: JSON.stringify({
@@ -115,6 +124,34 @@ export class JiraAdapter implements IssueTrackerAdapter {
115124
return `${this.baseUrl}/browse/${encodeURIComponent(id)}?focusedCommentId=${encodeURIComponent(commentId)}`;
116125
}
117126

127+
private async postCommentViaForge(
128+
id: string,
129+
comment: string,
130+
): Promise<string | null> {
131+
const res = await fetch(this.config.forgeCommentUrl!, {
132+
method: "POST",
133+
headers: {
134+
"Content-Type": "application/json",
135+
"x-shared-secret": this.config.forgeSharedSecret!,
136+
},
137+
body: JSON.stringify({ issueKey: id, body: comment }),
138+
});
139+
if (res.status === 404) {
140+
throw new IssueTrackerNotFoundError("Jira issue", id);
141+
}
142+
if (!res.ok) {
143+
throw new Error(
144+
`Forge postComment error: ${res.status} ${res.statusText}`,
145+
);
146+
}
147+
const data = (await res.json()) as {
148+
id?: string | null;
149+
permalinkPath?: string | null;
150+
};
151+
if (!data?.id || !data?.permalinkPath) return null;
152+
return `${this.baseUrl}/browse/${encodeURIComponent(id)}${data.permalinkPath}`;
153+
}
154+
118155
async downloadAttachment(
119156
url: string,
120157
opts: { timeoutMs?: number } = {},
@@ -201,7 +238,9 @@ function extractAdfText(adf: any): string {
201238

202239
function extractAcceptanceCriteria(description: any): string {
203240
const text = extractAdfText(description);
204-
const match = text.match(/acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i);
241+
const match = text.match(
242+
/acceptance criteria[:\s]*([\s\S]*?)(?:\n\n|\n#|$)/i,
243+
);
205244
return match?.[1]?.trim() ?? "";
206245
}
207246

src/lib/adapters.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export function createAdapters(): Adapters {
4040
email: env.JIRA_EMAIL,
4141
apiToken: env.JIRA_API_TOKEN,
4242
projectKey: env.JIRA_PROJECT_KEY,
43+
forgeCommentUrl: env.FORGE_COMMENT_URL,
44+
forgeSharedSecret: env.FORGE_SHARED_SECRET,
4345
}),
4446
vcs: createVCS(),
4547
messaging,

src/lib/step-adapters.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export function createStepAdapters(): StepAdapters {
3737
email: env.JIRA_EMAIL,
3838
apiToken: env.JIRA_API_TOKEN,
3939
projectKey: env.JIRA_PROJECT_KEY,
40+
forgeCommentUrl: env.FORGE_COMMENT_URL,
41+
forgeSharedSecret: env.FORGE_SHARED_SECRET,
4042
}),
4143
vcs: createVCS(),
4244
messaging,

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 bridge endpoint — receives forwarded Jira issue-updated events from
10+
* the ai-workflow-jira-app Forge app. Authenticated by a shared secret in
11+
* the X-Forge-Secret header instead of HMAC over the body (Forge handles the
12+
* Jira-side auth via api.asApp(), so we only need to verify that the caller
13+
* is our own Forge install).
14+
*
15+
* Coexists with /webhooks/jira: either path can drive dispatch during the
16+
* cutover. After Forge is confirmed working, deregister the manual webhook
17+
* in Jira admin UI.
18+
*/
19+
export default defineEventHandler(async (event) => {
20+
if (!env.FORGE_SHARED_SECRET) {
21+
throw createError({ statusCode: 503, statusMessage: "Forge bridge disabled" });
22+
}
23+
24+
const provided = getHeader(event, "x-forge-secret") ?? "";
25+
const a = Buffer.from(provided);
26+
const b = Buffer.from(env.FORGE_SHARED_SECRET);
27+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
28+
throw createError({ statusCode: 401, statusMessage: "Invalid forge secret" });
29+
}
30+
31+
const body = await readBody(event);
32+
const ticketKey = typeof body?.issueKey === "string" ? body.issueKey.trim() : "";
33+
if (!ticketKey) {
34+
return { status: "ignored", reason: "no_ticket_key" };
35+
}
36+
37+
const expectedPrefix = `${env.JIRA_PROJECT_KEY.trim().toUpperCase()}-`;
38+
if (!ticketKey.toUpperCase().startsWith(expectedPrefix)) {
39+
logger.debug(
40+
{ ticketKey, expectedProject: env.JIRA_PROJECT_KEY },
41+
"forge_dispatch_ignored_wrong_project",
42+
);
43+
return { status: "ignored", reason: "wrong_project", ticketKey };
44+
}
45+
46+
logger.info(
47+
{
48+
ticketKey,
49+
source: typeof body?.source === "string" ? body.source : "forge",
50+
cloudId: typeof body?.cloudId === "string" ? body.cloudId : null,
51+
payloadStatus: typeof body?.payloadStatus === "string" ? body.payloadStatus : null,
52+
},
53+
"forge_dispatch_received",
54+
);
55+
56+
const adapters = createAdapters();
57+
const result = await dispatchTicket(ticketKey, adapters, env.MAX_CONCURRENT_AGENTS);
58+
logger.info(
59+
{ ticketKey, started: result.started, reason: result.reason, runId: result.runId },
60+
"forge_dispatch_result",
61+
);
62+
return {
63+
status: result.started ? "dispatched" : "skipped",
64+
ticketKey,
65+
reason: result.reason,
66+
};
67+
});

0 commit comments

Comments
 (0)