@desplega.ai/agent-swarm 1.70.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/openapi.json +226 -1
  2. package/package.json +1 -1
  3. package/src/be/db-queries/oauth.ts +45 -15
  4. package/src/be/db-queries/tracker.ts +109 -0
  5. package/src/be/migrations/043_jira_source.sql +128 -0
  6. package/src/commands/runner.ts +7 -2
  7. package/src/http/core.ts +6 -21
  8. package/src/http/index.ts +9 -1
  9. package/src/http/route-def.ts +19 -0
  10. package/src/http/trackers/index.ts +13 -0
  11. package/src/http/trackers/jira.ts +395 -0
  12. package/src/http/trackers/linear.ts +47 -4
  13. package/src/http/utils.ts +27 -0
  14. package/src/jira/adf.ts +132 -0
  15. package/src/jira/app.ts +83 -0
  16. package/src/jira/client.ts +82 -0
  17. package/src/jira/index.ts +24 -0
  18. package/src/jira/metadata.ts +117 -0
  19. package/src/jira/oauth.ts +98 -0
  20. package/src/jira/outbound.ts +155 -0
  21. package/src/jira/sync.ts +534 -0
  22. package/src/jira/templates.ts +84 -0
  23. package/src/jira/types.ts +35 -0
  24. package/src/jira/webhook-lifecycle.ts +363 -0
  25. package/src/jira/webhook.ts +159 -0
  26. package/src/linear/app.ts +17 -0
  27. package/src/linear/oauth.ts +24 -0
  28. package/src/oauth/wrapper.ts +11 -1
  29. package/src/tasks/context-key.ts +29 -1
  30. package/src/telemetry.ts +38 -3
  31. package/src/tests/context-key.test.ts +19 -0
  32. package/src/tests/jira-adf.test.ts +239 -0
  33. package/src/tests/jira-metadata.test.ts +147 -0
  34. package/src/tests/jira-oauth.test.ts +167 -0
  35. package/src/tests/jira-outbound-sync.test.ts +334 -0
  36. package/src/tests/jira-sync.test.ts +327 -0
  37. package/src/tests/jira-webhook-lifecycle.test.ts +234 -0
  38. package/src/tests/jira-webhook.test.ts +274 -0
  39. package/src/tests/telemetry-init.test.ts +108 -0
  40. package/src/tools/tracker/tracker-link-task.ts +1 -1
  41. package/src/tools/tracker/tracker-map-agent.ts +1 -1
  42. package/src/tools/tracker/tracker-status.ts +1 -1
  43. package/src/tools/tracker/tracker-sync-status.ts +1 -1
  44. package/src/tracker/types.ts +1 -1
  45. package/src/types.ts +1 -0
@@ -0,0 +1,534 @@
1
+ /**
2
+ * Jira inbound sync handlers.
3
+ *
4
+ * Mirrors the Linear sync blueprint at `src/linear/sync.ts`, adapted for
5
+ * Jira's event payload shapes and ADF rich-text fields. Three entry points:
6
+ *
7
+ * - `handleIssueEvent` — `jira:issue_updated` (assignee changes)
8
+ * - `handleCommentEvent` — `comment_created` / `comment_updated`
9
+ * - `handleIssueDeleteEvent` — `jira:issue_deleted`
10
+ *
11
+ * Atomicity contract: tracker_sync row is inserted FIRST (via UNIQUE-gated
12
+ * `createTrackerSyncIfAbsent`), then the swarm task is created only when the
13
+ * insert was new. A crash between the two leaves an orphan sync row with
14
+ * `swarmId = ""` — reconcilable on retry.
15
+ */
16
+
17
+ import { cancelTask, getAllAgents, getTaskById } from "../be/db";
18
+ import { getOAuthTokens } from "../be/db-queries/oauth";
19
+ import {
20
+ createTrackerSyncIfAbsent,
21
+ getTrackerSyncByExternalId,
22
+ updateTrackerSyncSwarmId,
23
+ } from "../be/db-queries/tracker";
24
+ import { ensureToken } from "../oauth/ensure-token";
25
+ import { resolveTemplate } from "../prompts/resolver";
26
+ import { buildJiraContextKey } from "../tasks/context-key";
27
+ import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
28
+ import type { Agent } from "../types";
29
+ import { extractMentions, extractText } from "./adf";
30
+ import { getJiraMetadata } from "./metadata";
31
+ // Side-effect import: registers all Jira event templates in the prompt registry
32
+ import "./templates";
33
+
34
+ // ─── Bot identity (Atlassian accountId) ────────────────────────────────────
35
+
36
+ // Cache the bot's Atlassian accountId on globalThis (not a module-level `let`)
37
+ // so that test runners which re-import the module under cache-busting URLs —
38
+ // e.g. the templates `?t=${Date.now()}` pattern in src/tests/jira-sync.test.ts —
39
+ // still observe the same cache slot. Without this, CI's parallel test order can
40
+ // land a new module copy whose `cachedBotAccountId` is `null` while the test's
41
+ // seeded value sits on the original copy, causing handler short-circuits.
42
+ const BOT_ACCOUNT_ID_SLOT = Symbol.for("agent-swarm.jira.botAccountId");
43
+ type BotIdHolder = { [BOT_ACCOUNT_ID_SLOT]?: string | null };
44
+
45
+ function getCachedBotAccountId(): string | null {
46
+ return (globalThis as BotIdHolder)[BOT_ACCOUNT_ID_SLOT] ?? null;
47
+ }
48
+
49
+ function setCachedBotAccountId(value: string | null): void {
50
+ (globalThis as BotIdHolder)[BOT_ACCOUNT_ID_SLOT] = value;
51
+ }
52
+
53
+ /**
54
+ * Resolve and cache the bot Atlassian `accountId` via the User Identity API
55
+ * `https://api.atlassian.com/me`.
56
+ *
57
+ * We deliberately avoid `/rest/api/3/myself` because that endpoint requires
58
+ * `read:jira-user` (not in our Phase 0 scope set). `/me` returns the same
59
+ * Atlassian `account_id` (atlassian-wide identifier — issue assignees and
60
+ * comment authors are keyed on the same value) and is covered by `read:me`,
61
+ * which we already have.
62
+ *
63
+ * The first webhook delivery after a fresh boot pays the round-trip cost; all
64
+ * subsequent calls hit the in-memory cache. `resetBotAccountIdCache()` clears
65
+ * it on `resetJira()` so a reconnect as a different Atlassian user picks up
66
+ * the new identity.
67
+ *
68
+ * Returns `null` (not throws) when Jira is not connected — the inbound
69
+ * handlers prefer to return 200 + log a warning over surfacing 500s that
70
+ * would trigger Atlassian retries.
71
+ */
72
+ export async function resolveBotAccountId(): Promise<string | null> {
73
+ const cached = getCachedBotAccountId();
74
+ if (cached) return cached;
75
+
76
+ try {
77
+ await ensureToken("jira");
78
+ const tokens = getOAuthTokens("jira");
79
+ if (!tokens?.accessToken) {
80
+ console.warn("[Jira Sync] No Jira access token; cannot resolve bot accountId");
81
+ return null;
82
+ }
83
+ const res = await fetch("https://api.atlassian.com/me", {
84
+ headers: {
85
+ Authorization: `Bearer ${tokens.accessToken}`,
86
+ Accept: "application/json",
87
+ },
88
+ });
89
+ if (!res.ok) {
90
+ console.warn(`[Jira Sync] /me returned ${res.status}; cannot resolve bot accountId`);
91
+ return null;
92
+ }
93
+ const data = (await res.json()) as { account_id?: unknown };
94
+ if (typeof data.account_id !== "string" || data.account_id.length === 0) {
95
+ console.warn("[Jira Sync] /me response missing account_id");
96
+ return null;
97
+ }
98
+ setCachedBotAccountId(data.account_id);
99
+ return data.account_id;
100
+ } catch (err) {
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ console.warn(`[Jira Sync] Failed to resolve bot accountId: ${message}`);
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Test-visible cache reset; called from `resetJira()`. */
108
+ export function resetBotAccountIdCache(): void {
109
+ setCachedBotAccountId(null);
110
+ }
111
+
112
+ /** Test-only: seed the cache so handler tests can run without an OAuth round-trip. */
113
+ export function _setBotAccountIdForTesting(id: string | null): void {
114
+ setCachedBotAccountId(id);
115
+ }
116
+
117
+ // ─── Lead-agent picker (mirrors Linear) ────────────────────────────────────
118
+
119
+ function findLeadAgent(): Agent | null {
120
+ const agents = getAllAgents();
121
+ const onlineLead = agents.find((a) => a.isLead && a.status !== "offline");
122
+ if (onlineLead) return onlineLead;
123
+ return agents.find((a) => a.isLead) ?? null;
124
+ }
125
+
126
+ // ─── URL helpers ───────────────────────────────────────────────────────────
127
+
128
+ function buildIssueUrl(issueKey: string): string {
129
+ const meta = getJiraMetadata();
130
+ const siteUrl = (meta.siteUrl ?? "").replace(/\/+$/, "");
131
+ if (!siteUrl) return "";
132
+ return `${siteUrl}/browse/${issueKey}`;
133
+ }
134
+
135
+ // ─── Outbound-echo skip window (mirrors Linear's 5s) ────────────────────────
136
+
137
+ const OUTBOUND_ECHO_WINDOW_MS = 5_000;
138
+
139
+ function isWithinOutboundEchoWindow(lastSyncedAt: string | null | undefined): boolean {
140
+ if (!lastSyncedAt) return false;
141
+ const ts = new Date(lastSyncedAt).getTime();
142
+ if (Number.isNaN(ts)) return false;
143
+ return Date.now() - ts < OUTBOUND_ECHO_WINDOW_MS;
144
+ }
145
+
146
+ // ─── Issue events (assignee transitions) ───────────────────────────────────
147
+
148
+ type ChangelogItem = {
149
+ field?: string;
150
+ fieldId?: string;
151
+ from?: string | null;
152
+ to?: string | null;
153
+ };
154
+
155
+ type IssueShape = {
156
+ id?: string;
157
+ key?: string;
158
+ fields?: {
159
+ summary?: string;
160
+ description?: unknown;
161
+ reporter?: { displayName?: string; accountId?: string } | null;
162
+ };
163
+ };
164
+
165
+ /**
166
+ * Handle `jira:issue_updated` events. We only care about assignee transitions
167
+ * **to** the bot account (transitions away from the bot are ignored).
168
+ *
169
+ * Routing:
170
+ * - No prior sync row → create swarm task with `jira.issue.assigned`
171
+ * - Prior task active → log + ignore (Linear pattern: keep the existing
172
+ * in-flight task; reassignment just means "still ours")
173
+ * - Prior task done → create follow-up via `jira.issue.followup`
174
+ */
175
+ export async function handleIssueEvent(event: Record<string, unknown>): Promise<void> {
176
+ const issue = event.issue as IssueShape | undefined;
177
+ if (!issue?.id || !issue.key) {
178
+ console.log("[Jira Sync] issue_updated: missing issue.id/key — skipping");
179
+ return;
180
+ }
181
+
182
+ const changelog = event.changelog as { items?: ChangelogItem[] } | undefined;
183
+ const items = Array.isArray(changelog?.items) ? changelog.items : [];
184
+
185
+ const botAccountId = await resolveBotAccountId();
186
+ if (!botAccountId) {
187
+ console.warn(
188
+ "[Jira Sync] Webhook received but bot accountId is unresolved (Jira not connected?) — ignoring",
189
+ );
190
+ return;
191
+ }
192
+
193
+ // Direction filter: only handle transitions TO the bot, not FROM.
194
+ const transitionedToBot = items.some(
195
+ (it) =>
196
+ (it.field === "assignee" || it.fieldId === "assignee") &&
197
+ it.to === botAccountId &&
198
+ it.from !== botAccountId,
199
+ );
200
+
201
+ if (!transitionedToBot) {
202
+ return;
203
+ }
204
+
205
+ const issueId = issue.id;
206
+ const issueKey = issue.key;
207
+ const summary = issue.fields?.summary ?? "(no summary)";
208
+ const reporterName = issue.fields?.reporter?.displayName ?? "";
209
+ const descriptionText = extractText(issue.fields?.description);
210
+ const issueUrl = buildIssueUrl(issueKey);
211
+
212
+ // Step 1: claim the sync row UNIQUE-gated. Pass empty swarmId placeholder;
213
+ // we update it once the task is created.
214
+ const claim = createTrackerSyncIfAbsent({
215
+ provider: "jira",
216
+ entityType: "task",
217
+ providerEntityType: "Issue",
218
+ swarmId: "",
219
+ externalId: issueId,
220
+ externalIdentifier: issueKey,
221
+ externalUrl: issueUrl,
222
+ lastSyncOrigin: "external",
223
+ syncDirection: "inbound",
224
+ });
225
+
226
+ if (claim.inserted) {
227
+ // Fresh — create initial task
228
+ await createInitialJiraTask({
229
+ issueKey,
230
+ summary,
231
+ reporterName,
232
+ descriptionText,
233
+ issueUrl,
234
+ syncRowId: claim.sync.id,
235
+ followup: false,
236
+ });
237
+ return;
238
+ }
239
+
240
+ // Pre-existing — branch on prior task state.
241
+ const priorTask = claim.sync.swarmId ? getTaskById(claim.sync.swarmId) : null;
242
+ if (priorTask && !["completed", "failed", "cancelled"].includes(priorTask.status)) {
243
+ // In-progress: do not duplicate. Match Linear's behavior of acknowledging
244
+ // and continuing with the existing task.
245
+ console.log(
246
+ `[Jira Sync] Issue ${issueKey} re-assigned to bot but task ${priorTask.id} still ${priorTask.status} — ignoring`,
247
+ );
248
+ return;
249
+ }
250
+
251
+ // Terminal prior task (or orphan sync row with no swarmId) → follow-up.
252
+ await createInitialJiraTask({
253
+ issueKey,
254
+ summary,
255
+ reporterName,
256
+ descriptionText,
257
+ issueUrl,
258
+ syncRowId: claim.sync.id,
259
+ followup: true,
260
+ followupTrigger: "Issue re-assigned to bot",
261
+ followupMessage: "",
262
+ });
263
+ }
264
+
265
+ // ─── Comment events (mention triggers) ─────────────────────────────────────
266
+
267
+ type CommentShape = {
268
+ id?: string;
269
+ body?: unknown;
270
+ author?: { accountId?: string; displayName?: string } | null;
271
+ updateAuthor?: { accountId?: string; displayName?: string } | null;
272
+ };
273
+
274
+ /**
275
+ * Handle `comment_created` / `comment_updated` events.
276
+ *
277
+ * Three short-circuits before any work:
278
+ * 1. Self-authored skip (we don't process our own comments)
279
+ * 2. Outbound-echo skip (5s window after a swarm-posted comment)
280
+ * 3. Mention check (is the bot @-mentioned in the body?)
281
+ *
282
+ * Routing on a hit:
283
+ * - No prior sync row → create swarm task with `jira.issue.assigned`
284
+ * - Prior task active → log + ignore
285
+ * - Prior task done → follow-up via `jira.issue.followup`
286
+ */
287
+ export async function handleCommentEvent(event: Record<string, unknown>): Promise<void> {
288
+ const issue = event.issue as IssueShape | undefined;
289
+ const comment = event.comment as CommentShape | undefined;
290
+
291
+ if (!issue?.id || !issue.key) {
292
+ console.log("[Jira Sync] comment event: missing issue.id/key — skipping");
293
+ return;
294
+ }
295
+ if (!comment) {
296
+ console.log("[Jira Sync] comment event: missing comment payload — skipping");
297
+ return;
298
+ }
299
+
300
+ const botAccountId = await resolveBotAccountId();
301
+ if (!botAccountId) {
302
+ console.warn("[Jira Sync] Comment webhook received but bot accountId is unresolved — ignoring");
303
+ return;
304
+ }
305
+
306
+ // 1. Self-authored skip.
307
+ const authorId = comment.author?.accountId ?? comment.updateAuthor?.accountId ?? "";
308
+ if (authorId === botAccountId) {
309
+ return;
310
+ }
311
+
312
+ // 2. Outbound-echo skip (race window).
313
+ const existing = getTrackerSyncByExternalId("jira", "task", issue.id);
314
+ if (
315
+ existing &&
316
+ existing.lastSyncOrigin === "swarm" &&
317
+ isWithinOutboundEchoWindow(existing.lastSyncedAt)
318
+ ) {
319
+ console.log(
320
+ `[Jira Sync] Outbound-echo skip for issue ${issue.key} (within ${OUTBOUND_ECHO_WINDOW_MS}ms)`,
321
+ );
322
+ return;
323
+ }
324
+
325
+ // 3. Mention check.
326
+ const mentionIds = extractMentions(comment.body);
327
+ if (!mentionIds.includes(botAccountId)) {
328
+ return;
329
+ }
330
+
331
+ const issueKey = issue.key;
332
+ const summary = issue.fields?.summary ?? "(no summary)";
333
+ const descriptionText = extractText(issue.fields?.description);
334
+ const commentText = extractText(comment.body);
335
+ const commentAuthor = comment.author?.displayName ?? "";
336
+ const issueUrl = buildIssueUrl(issueKey);
337
+
338
+ if (!existing) {
339
+ // Comment-mention into existence.
340
+ const claim = createTrackerSyncIfAbsent({
341
+ provider: "jira",
342
+ entityType: "task",
343
+ providerEntityType: "Issue",
344
+ swarmId: "",
345
+ externalId: issue.id,
346
+ externalIdentifier: issueKey,
347
+ externalUrl: issueUrl,
348
+ lastSyncOrigin: "external",
349
+ syncDirection: "inbound",
350
+ });
351
+
352
+ // Race: another concurrent delivery may have just won the insert. Fall
353
+ // through to the follow-up branch — we still want the user's mention
354
+ // surfaced as either a fresh task or a follow-up, depending on prior
355
+ // state.
356
+ if (claim.inserted) {
357
+ await createCommentMentionTask({
358
+ issueKey,
359
+ summary,
360
+ descriptionText,
361
+ commentText,
362
+ commentAuthor,
363
+ issueUrl,
364
+ syncRowId: claim.sync.id,
365
+ });
366
+ return;
367
+ }
368
+ // Fall through with the now-existing row so we route via follow-up logic.
369
+ return routeCommentOnExistingSync({
370
+ issueKey,
371
+ summary,
372
+ issueUrl,
373
+ commentText,
374
+ commentAuthor,
375
+ syncRow: claim.sync,
376
+ });
377
+ }
378
+
379
+ await routeCommentOnExistingSync({
380
+ issueKey,
381
+ summary,
382
+ issueUrl,
383
+ commentText,
384
+ commentAuthor,
385
+ syncRow: existing,
386
+ });
387
+ }
388
+
389
+ async function routeCommentOnExistingSync(input: {
390
+ issueKey: string;
391
+ summary: string;
392
+ issueUrl: string;
393
+ commentText: string;
394
+ commentAuthor: string;
395
+ syncRow: { id: string; swarmId: string };
396
+ }): Promise<void> {
397
+ const priorTask = input.syncRow.swarmId ? getTaskById(input.syncRow.swarmId) : null;
398
+ if (priorTask && !["completed", "failed", "cancelled"].includes(priorTask.status)) {
399
+ // In-progress: log and ignore (mirrors Linear's prompted-on-active path).
400
+ console.log(
401
+ `[Jira Sync] Bot mentioned on issue ${input.issueKey} but task ${priorTask.id} still ${priorTask.status} — ignoring`,
402
+ );
403
+ return;
404
+ }
405
+
406
+ // Terminal or orphan sync row → follow-up.
407
+ await createInitialJiraTask({
408
+ issueKey: input.issueKey,
409
+ summary: input.summary,
410
+ reporterName: input.commentAuthor,
411
+ descriptionText: "",
412
+ issueUrl: input.issueUrl,
413
+ syncRowId: input.syncRow.id,
414
+ followup: true,
415
+ followupTrigger: `New comment from ${input.commentAuthor || "user"}`,
416
+ followupMessage: input.commentText,
417
+ });
418
+ }
419
+
420
+ // ─── Issue delete events ───────────────────────────────────────────────────
421
+
422
+ export async function handleIssueDeleteEvent(event: Record<string, unknown>): Promise<void> {
423
+ const issue = event.issue as IssueShape | undefined;
424
+ if (!issue?.id) return;
425
+
426
+ const sync = getTrackerSyncByExternalId("jira", "task", issue.id);
427
+ if (!sync) return;
428
+
429
+ const task = sync.swarmId ? getTaskById(sync.swarmId) : null;
430
+ if (task && !["completed", "failed", "cancelled"].includes(task.status)) {
431
+ cancelTask(sync.swarmId, "Jira issue deleted");
432
+ console.log(
433
+ `[Jira Sync] Cancelled task ${sync.swarmId} (Jira issue ${issue.key ?? issue.id} deleted)`,
434
+ );
435
+ }
436
+ }
437
+
438
+ // ─── Task creation helpers ─────────────────────────────────────────────────
439
+
440
+ async function createInitialJiraTask(input: {
441
+ issueKey: string;
442
+ summary: string;
443
+ reporterName: string;
444
+ descriptionText: string;
445
+ issueUrl: string;
446
+ syncRowId: string;
447
+ followup: boolean;
448
+ followupTrigger?: string;
449
+ followupMessage?: string;
450
+ }): Promise<void> {
451
+ const lead = findLeadAgent();
452
+ const descriptionSection = input.descriptionText
453
+ ? `\nDescription:\n${input.descriptionText}\n`
454
+ : "";
455
+
456
+ const tmplName = input.followup ? "jira.issue.followup" : "jira.issue.assigned";
457
+ const variables = input.followup
458
+ ? {
459
+ issue_key: input.issueKey,
460
+ issue_summary: input.summary,
461
+ issue_url: input.issueUrl,
462
+ trigger: input.followupTrigger ?? "Re-engagement on tracked issue",
463
+ user_message: input.followupMessage ?? "",
464
+ }
465
+ : {
466
+ issue_key: input.issueKey,
467
+ issue_summary: input.summary,
468
+ issue_url: input.issueUrl,
469
+ reporter: input.reporterName,
470
+ description_section: descriptionSection,
471
+ };
472
+
473
+ const result = resolveTemplate(tmplName, variables);
474
+ if (result.skipped) {
475
+ console.log(`[Jira Sync] Template ${tmplName} resolved as skipped — not creating task`);
476
+ return;
477
+ }
478
+
479
+ const task = createTaskWithSiblingAwareness(result.text, {
480
+ agentId: lead?.id ?? "",
481
+ source: "jira",
482
+ taskType: "jira-issue",
483
+ contextKey: buildJiraContextKey(input.issueKey),
484
+ });
485
+
486
+ updateTrackerSyncSwarmId(input.syncRowId, task.id);
487
+
488
+ const action = input.followup ? "follow-up" : "new";
489
+ console.log(
490
+ `[Jira Sync] Created ${action} task ${task.id} for ${input.issueKey} -> ${lead?.name ?? "unassigned"}`,
491
+ );
492
+ }
493
+
494
+ async function createCommentMentionTask(input: {
495
+ issueKey: string;
496
+ summary: string;
497
+ descriptionText: string;
498
+ commentText: string;
499
+ commentAuthor: string;
500
+ issueUrl: string;
501
+ syncRowId: string;
502
+ }): Promise<void> {
503
+ const lead = findLeadAgent();
504
+ const descriptionSection = input.descriptionText
505
+ ? `\nDescription:\n${input.descriptionText}\n`
506
+ : "";
507
+
508
+ const result = resolveTemplate("jira.issue.commented", {
509
+ issue_key: input.issueKey,
510
+ issue_summary: input.summary,
511
+ issue_url: input.issueUrl,
512
+ comment_author: input.commentAuthor,
513
+ description_section: descriptionSection,
514
+ comment_text: input.commentText,
515
+ });
516
+
517
+ if (result.skipped) {
518
+ console.log("[Jira Sync] jira.issue.commented resolved as skipped — not creating task");
519
+ return;
520
+ }
521
+
522
+ const task = createTaskWithSiblingAwareness(result.text, {
523
+ agentId: lead?.id ?? "",
524
+ source: "jira",
525
+ taskType: "jira-issue",
526
+ contextKey: buildJiraContextKey(input.issueKey),
527
+ });
528
+
529
+ updateTrackerSyncSwarmId(input.syncRowId, task.id);
530
+
531
+ console.log(
532
+ `[Jira Sync] Created comment-mention task ${task.id} for ${input.issueKey} -> ${lead?.name ?? "unassigned"}`,
533
+ );
534
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Jira event prompt template definitions.
3
+ *
4
+ * Each template is registered at module load time via `registerTemplate()`.
5
+ * Handlers import this module for the side effect of registration (mirrors
6
+ * the pattern in `src/linear/templates.ts`).
7
+ */
8
+
9
+ import { registerTemplate } from "../prompts/registry";
10
+
11
+ // ============================================================================
12
+ // Issue events
13
+ // ============================================================================
14
+
15
+ registerTemplate({
16
+ eventType: "jira.issue.assigned",
17
+ header: "[Jira {{issue_key}}] {{issue_summary}}",
18
+ defaultBody: `Source: Jira (issue assigned to bot)
19
+ URL: {{issue_url}}
20
+ Reporter: {{reporter}}
21
+ {{description_section}}`,
22
+ variables: [
23
+ { name: "issue_key", description: "Jira issue key (e.g. ENG-123)" },
24
+ { name: "issue_summary", description: "Issue summary / title" },
25
+ { name: "issue_url", description: "Issue URL on the Jira site" },
26
+ { name: "reporter", description: "Reporter display name (or empty string)" },
27
+ {
28
+ name: "description_section",
29
+ description: "Description section (extracted from ADF) or empty string",
30
+ },
31
+ ],
32
+ category: "event",
33
+ });
34
+
35
+ registerTemplate({
36
+ eventType: "jira.issue.commented",
37
+ header: "[Jira {{issue_key}}] {{issue_summary}}",
38
+ defaultBody: `Source: Jira (bot mentioned in comment)
39
+ URL: {{issue_url}}
40
+ Comment author: {{comment_author}}
41
+ {{description_section}}
42
+ Comment:
43
+ {{comment_text}}
44
+ `,
45
+ variables: [
46
+ { name: "issue_key", description: "Jira issue key (e.g. ENG-123)" },
47
+ { name: "issue_summary", description: "Issue summary / title" },
48
+ { name: "issue_url", description: "Issue URL on the Jira site" },
49
+ { name: "comment_author", description: "Comment author display name" },
50
+ {
51
+ name: "description_section",
52
+ description: "Description section (extracted from ADF) or empty string",
53
+ },
54
+ { name: "comment_text", description: "Comment body (extracted from ADF)" },
55
+ ],
56
+ category: "event",
57
+ });
58
+
59
+ registerTemplate({
60
+ eventType: "jira.issue.followup",
61
+ header: "[Jira {{issue_key}}] Follow-up: {{issue_summary}}",
62
+ defaultBody: `Source: Jira (follow-up on previously-tracked issue)
63
+ URL: {{issue_url}}
64
+
65
+ Trigger: {{trigger}}
66
+
67
+ {{user_message}}
68
+
69
+ Original issue: {{issue_key}} — {{issue_summary}}`,
70
+ variables: [
71
+ { name: "issue_key", description: "Jira issue key (e.g. ENG-123)" },
72
+ { name: "issue_summary", description: "Issue summary / title" },
73
+ { name: "issue_url", description: "Issue URL on the Jira site" },
74
+ {
75
+ name: "trigger",
76
+ description: "Why a follow-up was created (re-assignment / new comment)",
77
+ },
78
+ {
79
+ name: "user_message",
80
+ description: "Either the new comment text or empty for re-assignment",
81
+ },
82
+ ],
83
+ category: "event",
84
+ });
@@ -0,0 +1,35 @@
1
+ /** Atlassian token endpoint response (POST https://auth.atlassian.com/oauth/token). */
2
+ export interface JiraTokenResponse {
3
+ access_token: string;
4
+ token_type: string;
5
+ expires_in: number;
6
+ refresh_token?: string;
7
+ scope?: string;
8
+ }
9
+
10
+ /**
11
+ * One entry from GET https://api.atlassian.com/oauth/token/accessible-resources.
12
+ * The `id` is the `cloudId` we need for all subsequent REST calls under
13
+ * `https://api.atlassian.com/ex/jira/{cloudId}`.
14
+ */
15
+ export interface JiraAccessibleResource {
16
+ id: string;
17
+ url: string;
18
+ name: string;
19
+ scopes: string[];
20
+ avatarUrl?: string;
21
+ }
22
+
23
+ /**
24
+ * JSON shape of `oauth_apps.metadata` for the `jira` provider. All fields are
25
+ * optional because the row is created at install-time before OAuth completes
26
+ * (cloudId lands on first successful callback) and webhooks come even later.
27
+ *
28
+ * All writes MUST go through `updateJiraMetadata()` (read-modify-write inside
29
+ * a transaction) to avoid clobbering keys written by concurrent callers.
30
+ */
31
+ export interface JiraOAuthAppMetadata {
32
+ cloudId?: string;
33
+ siteUrl?: string;
34
+ webhookIds?: Array<{ id: number; expiresAt: string; jql: string }>;
35
+ }