@andypai/agent-kanban 0.5.1 → 0.6.0
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.
- package/package.json +1 -1
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +177 -20
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira.ts +115 -33
- package/src/providers/postgres-jira.ts +187 -63
|
@@ -145,6 +145,16 @@ function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
|
|
|
145
145
|
)
|
|
146
146
|
},
|
|
147
147
|
},
|
|
148
|
+
{
|
|
149
|
+
// GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
|
|
150
|
+
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+$/.test(new URL(url).pathname),
|
|
151
|
+
handler: (url) => {
|
|
152
|
+
const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)$/)![1]!
|
|
153
|
+
const issue = issues.find((candidate) => String(candidate.key) === issueKey)
|
|
154
|
+
if (!issue) return jsonResponse({ errorMessages: ['missing'] }, 404)
|
|
155
|
+
return jsonResponse(issue)
|
|
156
|
+
},
|
|
157
|
+
},
|
|
148
158
|
{
|
|
149
159
|
match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
|
|
150
160
|
handler: async (url, init) => {
|
|
@@ -401,4 +411,149 @@ describe('postgres jira provider', () => {
|
|
|
401
411
|
column_id: '20',
|
|
402
412
|
})
|
|
403
413
|
})
|
|
414
|
+
|
|
415
|
+
pgTest(
|
|
416
|
+
'delta sync adds new catalog rows but only a full reconcile prunes obsolete ones',
|
|
417
|
+
async () => {
|
|
418
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
419
|
+
const prioritiesState = {
|
|
420
|
+
list: [
|
|
421
|
+
{ id: '2', name: 'High' },
|
|
422
|
+
{ id: '3', name: 'Medium' },
|
|
423
|
+
] as Array<{ id: string; name: string }>,
|
|
424
|
+
}
|
|
425
|
+
const routes = standardRoutes()
|
|
426
|
+
const pIdx = routes.findIndex((route) =>
|
|
427
|
+
route.match('https://example.atlassian.net/rest/api/3/priority'),
|
|
428
|
+
)
|
|
429
|
+
routes[pIdx] = {
|
|
430
|
+
match: (url) => url.includes('/rest/api/3/priority'),
|
|
431
|
+
handler: () => jsonResponse(prioritiesState.list),
|
|
432
|
+
}
|
|
433
|
+
globalThis.fetch = jiraFetchStub(routes).fn
|
|
434
|
+
// pollingSyncIntervalMs: 0 lets every getBoard run; the first is a full
|
|
435
|
+
// reconcile (no lastFullSyncAt yet), later ones are delta syncs because the
|
|
436
|
+
// full-reconcile interval has not elapsed.
|
|
437
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
438
|
+
baseUrl: 'https://example.atlassian.net',
|
|
439
|
+
email: 'user@example.com',
|
|
440
|
+
apiToken: 'token',
|
|
441
|
+
projectKey: 'ENG',
|
|
442
|
+
boardId: 1006,
|
|
443
|
+
pollingSyncIntervalMs: 0,
|
|
444
|
+
})
|
|
445
|
+
const priorityNames = async () =>
|
|
446
|
+
(await sql!<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
|
|
447
|
+
(row) => row.name,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
await provider.getBoard()
|
|
451
|
+
expect(await priorityNames()).toEqual(['High', 'Medium'])
|
|
452
|
+
|
|
453
|
+
// Drop Medium, add Low upstream. A delta sync must pick up the new row
|
|
454
|
+
// (Low) via the every-sync UPSERT but must NOT prune the now-obsolete row
|
|
455
|
+
// (Medium): pruning on a possibly-stale delta snapshot could delete a row
|
|
456
|
+
// another replica just added. Pruning is confined to the full reconcile.
|
|
457
|
+
prioritiesState.list = [
|
|
458
|
+
{ id: '2', name: 'High' },
|
|
459
|
+
{ id: '4', name: 'Low' },
|
|
460
|
+
]
|
|
461
|
+
await provider.getBoard()
|
|
462
|
+
expect(await priorityNames()).toEqual(['High', 'Low', 'Medium'])
|
|
463
|
+
|
|
464
|
+
// Force the next sync to be a full reconcile; only then is the obsolete
|
|
465
|
+
// Medium pruned.
|
|
466
|
+
await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
|
|
467
|
+
await provider.getBoard()
|
|
468
|
+
expect(await priorityNames()).toEqual(['High', 'Low'])
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
pgTest('concurrent catalog refreshes do not collide on a primary key', async () => {
|
|
473
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
474
|
+
globalThis.fetch = jiraFetchStub(standardRoutes()).fn
|
|
475
|
+
const sql2 = postgres(databaseUrl as string, { max: 1, onnotice: () => {} })
|
|
476
|
+
try {
|
|
477
|
+
const config = {
|
|
478
|
+
baseUrl: 'https://example.atlassian.net',
|
|
479
|
+
email: 'user@example.com',
|
|
480
|
+
apiToken: 'token',
|
|
481
|
+
projectKey: 'ENG',
|
|
482
|
+
boardId: 1006,
|
|
483
|
+
pollingSyncIntervalMs: 0,
|
|
484
|
+
}
|
|
485
|
+
// Initialize schema sequentially first; concurrent CREATE TABLE IF NOT
|
|
486
|
+
// EXISTS races at the DDL level (a pre-existing Postgres quirk unrelated to
|
|
487
|
+
// catalog refresh). The race under test is the catalog DML below.
|
|
488
|
+
const p1 = new PostgresJiraProvider(sql, config)
|
|
489
|
+
await p1.initialize()
|
|
490
|
+
const p2 = new PostgresJiraProvider(sql2, config)
|
|
491
|
+
await p2.initialize()
|
|
492
|
+
// Two replicas on separate connections refreshing the same shared cache
|
|
493
|
+
// concurrently must not trip a primary-key collision. Both first syncs are
|
|
494
|
+
// full reconciles (no lastFullSyncAt yet), so this also exercises the
|
|
495
|
+
// obsolete-row DELETE running concurrently: UPSERT + conditional DELETE is
|
|
496
|
+
// idempotent and order-independent.
|
|
497
|
+
await Promise.all([p1.getBoard(), p2.getBoard()])
|
|
498
|
+
const rows = await sql<
|
|
499
|
+
{ count: number }[]
|
|
500
|
+
>`SELECT COUNT(*)::int AS count FROM jira_priorities`
|
|
501
|
+
expect(rows[0]?.count ?? 0).toBeGreaterThan(0)
|
|
502
|
+
} finally {
|
|
503
|
+
await sql2.end({ timeout: 1 })
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
pgTest('full reconcile with a stalled cursor does not prune unfetched-page issues', async () => {
|
|
508
|
+
if (!sql) throw new Error('expected postgres test connection')
|
|
509
|
+
// Mirror of the SQLite prune-safety coverage: if the cursor stalls (a
|
|
510
|
+
// repeated non-last token) mid-scan, seenIssueIds is only partial, so the
|
|
511
|
+
// incomplete scan must NOT prune issues that exist upstream on pages we never
|
|
512
|
+
// fetched.
|
|
513
|
+
let stalled = false
|
|
514
|
+
const routes = standardRoutes()
|
|
515
|
+
const sIdx = routes.findIndex((route) => route.match('https://x/rest/api/3/search/jql'))
|
|
516
|
+
routes[sIdx] = {
|
|
517
|
+
match: (url) => url.includes('/rest/api/3/search/jql'),
|
|
518
|
+
handler: () =>
|
|
519
|
+
stalled
|
|
520
|
+
? // Later full reconcile: the cursor stalls after returning only ENG-1,
|
|
521
|
+
// so ENG-2's page is never reached. isLast=false + a repeated token =>
|
|
522
|
+
// incomplete.
|
|
523
|
+
jsonResponse({
|
|
524
|
+
nextPageToken: 'stuck',
|
|
525
|
+
isLast: false,
|
|
526
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1' })],
|
|
527
|
+
})
|
|
528
|
+
: // First full reconcile: a clean single terminal page with both issues.
|
|
529
|
+
jsonResponse({
|
|
530
|
+
isLast: true,
|
|
531
|
+
issues: [makeIssue({ id: '1', key: 'ENG-1' }), makeIssue({ id: '2', key: 'ENG-2' })],
|
|
532
|
+
}),
|
|
533
|
+
}
|
|
534
|
+
globalThis.fetch = jiraFetchStub(routes).fn
|
|
535
|
+
const provider = new PostgresJiraProvider(sql, {
|
|
536
|
+
baseUrl: 'https://example.atlassian.net',
|
|
537
|
+
email: 'user@example.com',
|
|
538
|
+
apiToken: 'token',
|
|
539
|
+
projectKey: 'ENG',
|
|
540
|
+
boardId: 1006,
|
|
541
|
+
pollingSyncIntervalMs: 0,
|
|
542
|
+
})
|
|
543
|
+
const issueKeys = async () =>
|
|
544
|
+
(await sql!<{ key: string }[]>`SELECT key FROM jira_issues ORDER BY key`).map(
|
|
545
|
+
(row) => row.key,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
await provider.getBoard()
|
|
549
|
+
expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
|
|
550
|
+
|
|
551
|
+
// Force the next sync to be a full reconcile and hit the stalled cursor.
|
|
552
|
+
stalled = true
|
|
553
|
+
await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
|
|
554
|
+
await provider.getBoard()
|
|
555
|
+
|
|
556
|
+
// ENG-2 must survive: the incomplete scan is not authoritative for pruning.
|
|
557
|
+
expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
|
|
558
|
+
})
|
|
404
559
|
})
|
|
@@ -45,12 +45,73 @@ export interface JiraIssue {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface JiraSearchPage {
|
|
48
|
-
startAt
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// The legacy /rest/api/3/search endpoint returned startAt/maxResults/total.
|
|
49
|
+
// The current /rest/api/3/search/jql endpoint omits `total` and paginates by
|
|
50
|
+
// an opaque `nextPageToken` (with `isLast`), so these are optional now.
|
|
51
|
+
startAt?: number
|
|
52
|
+
maxResults?: number
|
|
53
|
+
total?: number
|
|
54
|
+
nextPageToken?: string
|
|
55
|
+
isLast?: boolean
|
|
51
56
|
issues: JiraIssue[]
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
export interface JiraPaginationDecision {
|
|
60
|
+
// When set, advance the scan to this cursor. When absent, the scan is over and
|
|
61
|
+
// `complete` says whether it ended definitively (safe to prune against the
|
|
62
|
+
// accumulated issue set) or was cut short (must NOT prune — issues on unfetched
|
|
63
|
+
// pages would be wrongly deleted).
|
|
64
|
+
nextToken?: string
|
|
65
|
+
complete: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Decide how a `/rest/api/3/search/jql` cursor scan should proceed after a page.
|
|
69
|
+
// The endpoint signals continuation with `isLast`/`nextPageToken`, but real and
|
|
70
|
+
// degraded servers vary, so termination must be conservative: a scan is reported
|
|
71
|
+
// `complete` (safe to prune against the accumulated issue set) ONLY when the
|
|
72
|
+
// server proves the end, never from a page-size guess.
|
|
73
|
+
// - usable cursor (fresh `nextPageToken`, isLast !== true) → advance.
|
|
74
|
+
// - isLast === true → definitive end (complete).
|
|
75
|
+
// - isLast === false → more pages exist; if the
|
|
76
|
+
// cursor is missing/stale we
|
|
77
|
+
// cannot fetch them, so the
|
|
78
|
+
// scan is incomplete.
|
|
79
|
+
// - isLast absent, `total` present → legacy total/startAt proof:
|
|
80
|
+
// complete once startAt+count
|
|
81
|
+
// reaches `total`.
|
|
82
|
+
// - isLast absent, no `total`, no usable cursor → NO completeness proof. A
|
|
83
|
+
// short/empty page must NOT be
|
|
84
|
+
// read as "the end": a degraded
|
|
85
|
+
// `{ issues: [] }` would then
|
|
86
|
+
// prune the entire cache on a
|
|
87
|
+
// full reconcile. Treat as
|
|
88
|
+
// incomplete and retry instead.
|
|
89
|
+
// A cursor already in `seenPageTokens` counts as not usable (a stalled cursor
|
|
90
|
+
// that would otherwise loop forever).
|
|
91
|
+
export function decideJiraPagination(
|
|
92
|
+
page: Pick<JiraSearchPage, 'isLast' | 'nextPageToken' | 'issues' | 'total' | 'startAt'>,
|
|
93
|
+
seenPageTokens: ReadonlySet<string>,
|
|
94
|
+
): JiraPaginationDecision {
|
|
95
|
+
const token = page.nextPageToken
|
|
96
|
+
const canAdvance = !!token && page.isLast !== true && !seenPageTokens.has(token)
|
|
97
|
+
if (canAdvance) return { nextToken: token, complete: false }
|
|
98
|
+
if (page.isLast === true) return { complete: true }
|
|
99
|
+
if (page.isLast === false) return { complete: false }
|
|
100
|
+
// isLast absent below. Prefer the legacy total/startAt signal when a
|
|
101
|
+
// (non-standard) server supplies it: complete once we have fetched everything
|
|
102
|
+
// `total` promises. The live /search/jql endpoint omits `total` and ignores
|
|
103
|
+
// `startAt`, so the loop never advances by offset — a `total` promising more
|
|
104
|
+
// than this page is reported incomplete rather than fetched.
|
|
105
|
+
if (typeof page.total === 'number') {
|
|
106
|
+
const issueCount = page.issues?.length ?? 0
|
|
107
|
+
return { complete: (page.startAt ?? 0) + issueCount >= page.total }
|
|
108
|
+
}
|
|
109
|
+
// No completeness signal at all (no isLast, no total, no usable cursor). We
|
|
110
|
+
// cannot prove the scan reached the end, so never guess `complete` from page
|
|
111
|
+
// size — that would prune the whole cache on a degraded short/empty response.
|
|
112
|
+
return { complete: false }
|
|
113
|
+
}
|
|
114
|
+
|
|
54
115
|
export interface JiraCreatePayload {
|
|
55
116
|
fields: Record<string, unknown>
|
|
56
117
|
}
|
|
@@ -254,12 +315,17 @@ export class JiraClient {
|
|
|
254
315
|
startAt: number
|
|
255
316
|
maxResults: number
|
|
256
317
|
fields?: string[]
|
|
318
|
+
nextPageToken?: string
|
|
257
319
|
}): Promise<JiraSearchPage> {
|
|
258
320
|
const query: QueryParams = {
|
|
259
321
|
jql: params.jql,
|
|
260
|
-
startAt: params.startAt,
|
|
261
322
|
maxResults: params.maxResults,
|
|
262
323
|
}
|
|
324
|
+
// /rest/api/3/search/jql ignores startAt and paginates by nextPageToken.
|
|
325
|
+
// Send the cursor when we have one; only send startAt on the first page for
|
|
326
|
+
// back-compat with the legacy endpoint.
|
|
327
|
+
if (params.nextPageToken) query.nextPageToken = params.nextPageToken
|
|
328
|
+
else query.startAt = params.startAt
|
|
263
329
|
if (params.fields && params.fields.length > 0) {
|
|
264
330
|
query.fields = params.fields.join(',')
|
|
265
331
|
}
|
package/src/providers/jira.ts
CHANGED
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
import { adfToPlainText, plainTextToAdf, type AdfDocument } from './jira-adf'
|
|
21
21
|
import { JIRA_CAPABILITIES } from './capabilities'
|
|
22
22
|
import { providerUpstreamError, unsupportedOperation } from './errors'
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
JiraClient,
|
|
25
|
+
decideJiraPagination,
|
|
26
|
+
normalizeJiraLabels,
|
|
27
|
+
type JiraComment,
|
|
28
|
+
type JiraIssue,
|
|
29
|
+
} from './jira-client'
|
|
24
30
|
import {
|
|
25
31
|
adjustJiraIssueCommentCount,
|
|
26
32
|
decodeColumnStatusIds,
|
|
@@ -114,7 +120,11 @@ export class JiraProvider implements KanbanProvider {
|
|
|
114
120
|
const lastSyncAtMs = meta.lastSyncAt ? Date.parse(meta.lastSyncAt) : 0
|
|
115
121
|
const now = Date.now()
|
|
116
122
|
if (!force && lastSyncAtMs && now - lastSyncAtMs < this.pollingSyncIntervalMs) return
|
|
117
|
-
|
|
123
|
+
// `force` only bypasses the poll throttle; it must NOT imply a full
|
|
124
|
+
// 1970-based reconcile, which re-fetches every issue plus a per-issue
|
|
125
|
+
// changelog call (~minutes). Writes get read-after-write freshness from
|
|
126
|
+
// hydrateIssueByKey instead, so a forced sync stays a cheap delta.
|
|
127
|
+
const fullReconcile = shouldRunFullReconcile(meta.lastFullSyncAt, now)
|
|
118
128
|
|
|
119
129
|
// 1. Resolve project.
|
|
120
130
|
const project = await this.client.getProject(this.config.projectKey)
|
|
@@ -181,10 +191,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
181
191
|
const sinceClause = since ?? '1970-01-01 00:00'
|
|
182
192
|
const jql = `project = ${project.key} AND updated >= "${sinceClause}" ORDER BY updated ASC`
|
|
183
193
|
|
|
184
|
-
let startAt = 0
|
|
185
194
|
const maxResults = 100
|
|
186
|
-
let accumulated = 0
|
|
187
|
-
let total = Infinity
|
|
188
195
|
let newestUpdatedAt: string | null = meta.lastIssueUpdatedAt
|
|
189
196
|
const seenIssueIds = new Set<string>()
|
|
190
197
|
const issueFields = [
|
|
@@ -200,12 +207,28 @@ export class JiraProvider implements KanbanProvider {
|
|
|
200
207
|
'updated',
|
|
201
208
|
'project',
|
|
202
209
|
]
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
// /rest/api/3/search/jql paginates by an opaque nextPageToken and omits
|
|
211
|
+
// `total`, so we follow the cursor until the server reports `isLast` or stops
|
|
212
|
+
// handing back a token. The previous total/startAt loop fetched only the first
|
|
213
|
+
// page (oldest 100 by updated ASC) once `total` came back undefined, so every
|
|
214
|
+
// issue beyond the first page — including newly-created tickets on a project
|
|
215
|
+
// with >100 issues — was never cached. seenPageTokens guards against a server
|
|
216
|
+
// that repeats a cursor, which would otherwise spin this loop forever and hang
|
|
217
|
+
// the poll cycle; an empty page is not treated as terminal because a non-last
|
|
218
|
+
// page may legitimately carry a token with zero issues.
|
|
219
|
+
const seenPageTokens = new Set<string>()
|
|
220
|
+
let nextPageToken: string | undefined
|
|
221
|
+
let firstPage = true
|
|
222
|
+
let paginationComplete = false
|
|
223
|
+
while (firstPage || nextPageToken !== undefined) {
|
|
224
|
+
firstPage = false
|
|
225
|
+
const page = await this.client.listIssues({
|
|
226
|
+
jql,
|
|
227
|
+
startAt: 0,
|
|
228
|
+
maxResults,
|
|
229
|
+
fields: issueFields,
|
|
230
|
+
nextPageToken,
|
|
231
|
+
})
|
|
209
232
|
|
|
210
233
|
upsertJiraIssues(
|
|
211
234
|
this.db,
|
|
@@ -249,10 +272,37 @@ export class JiraProvider implements KanbanProvider {
|
|
|
249
272
|
})
|
|
250
273
|
}
|
|
251
274
|
|
|
252
|
-
|
|
253
|
-
|
|
275
|
+
const decision = decideJiraPagination(page, seenPageTokens)
|
|
276
|
+
if (decision.nextToken !== undefined) {
|
|
277
|
+
seenPageTokens.add(decision.nextToken)
|
|
278
|
+
nextPageToken = decision.nextToken
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
paginationComplete = decision.complete
|
|
282
|
+
if (!paginationComplete) {
|
|
283
|
+
// The server stopped advancing the cursor before reporting a definitive
|
|
284
|
+
// end (stalled/contradictory). seenIssueIds is only a partial scan, so we
|
|
285
|
+
// must not treat it as authoritative for pruning below.
|
|
286
|
+
console.warn(
|
|
287
|
+
`[jira] search/jql scan ended without a definitive last page; treating as incomplete`,
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
break
|
|
254
291
|
}
|
|
255
292
|
|
|
293
|
+
if (!paginationComplete) {
|
|
294
|
+
// The scan ended early (stalled/contradictory cursor). Leave sync metadata
|
|
295
|
+
// unchanged so this partial result is not recorded as a clean sync: lastSyncAt
|
|
296
|
+
// is not advanced, so the next sync is not throttled and retries promptly, and
|
|
297
|
+
// the full-reconcile marker stays due. The issues we did fetch are already
|
|
298
|
+
// cached (additive); we only skip pruning — which would delete issues that
|
|
299
|
+
// exist upstream on pages we never fetched — and the metadata advance.
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Prune against seenIssueIds and advance lastFullSyncAt only on a full
|
|
304
|
+
// reconcile; a delta sync's seenIssueIds is intentionally partial. (The scan
|
|
305
|
+
// is known complete here — an incomplete scan returned above.)
|
|
256
306
|
if (fullReconcile) {
|
|
257
307
|
pruneJiraIssuesMissingUpstream(this.db, project.key, [...seenIssueIds])
|
|
258
308
|
}
|
|
@@ -452,6 +502,55 @@ export class JiraProvider implements KanbanProvider {
|
|
|
452
502
|
}
|
|
453
503
|
}
|
|
454
504
|
|
|
505
|
+
// Read-after-write via the direct issue endpoint. Unlike JQL search (used by
|
|
506
|
+
// sync()), GET /issue/{key} has no search-index lag, so a just-created or
|
|
507
|
+
// just-transitioned issue is reflected immediately. This replaces the previous
|
|
508
|
+
// "create/transition then sync(true) then getCachedTask" pattern, which raced
|
|
509
|
+
// the search index (creates reported "not yet visible"; a move's new status
|
|
510
|
+
// sometimes didn't land) and forced a full whole-project reconcile per write.
|
|
511
|
+
private async hydrateIssueByKey(key: string): Promise<Task> {
|
|
512
|
+
// getIssue throws on a missing key (404), so reaching this method means the
|
|
513
|
+
// issue exists upstream. A null read-back would therefore be a genuine cache
|
|
514
|
+
// anomaly (the upsert above did not land), not an ordinary not-found — surface
|
|
515
|
+
// it rather than threading an unreachable null through every caller.
|
|
516
|
+
const issue = await this.client.getIssue(key)
|
|
517
|
+
upsertJiraIssues(this.db, [
|
|
518
|
+
{
|
|
519
|
+
id: issue.id,
|
|
520
|
+
key: issue.key,
|
|
521
|
+
summary: issue.fields.summary,
|
|
522
|
+
descriptionText: issue.fields.description
|
|
523
|
+
? adfToPlainText(issue.fields.description as AdfDocument)
|
|
524
|
+
: '',
|
|
525
|
+
statusId: issue.fields.status.id,
|
|
526
|
+
priorityName: issue.fields.priority?.name ?? null,
|
|
527
|
+
issueTypeName: issue.fields.issuetype?.name ?? '',
|
|
528
|
+
assigneeAccountId: issue.fields.assignee?.accountId ?? null,
|
|
529
|
+
assigneeName: issue.fields.assignee?.displayName ?? null,
|
|
530
|
+
labels: issue.fields.labels ?? [],
|
|
531
|
+
commentCount: issue.fields.comment?.total ?? 0,
|
|
532
|
+
projectKey: issue.fields.project?.key ?? this.config.projectKey,
|
|
533
|
+
url: `${this.config.baseUrl}/browse/${issue.key}`,
|
|
534
|
+
createdAt: issue.fields.created,
|
|
535
|
+
updatedAt: issue.fields.updated,
|
|
536
|
+
},
|
|
537
|
+
])
|
|
538
|
+
// Ingest the changelog like the sync loop does, so a just-applied transition
|
|
539
|
+
// is recorded in jira_activity immediately (backs getActivity and the
|
|
540
|
+
// poll-based `moved` trigger) rather than waiting for the next unthrottled
|
|
541
|
+
// sync. Best-effort: activity must not fail the mutation.
|
|
542
|
+
await this.ingestIssueActivity(issue.id).catch((err) => {
|
|
543
|
+
console.warn(`[jira] activity fetch for ${issue.key} failed:`, err)
|
|
544
|
+
})
|
|
545
|
+
const task = getCachedTask(this.db, key)
|
|
546
|
+
if (!task) {
|
|
547
|
+
providerUpstreamError(
|
|
548
|
+
`Jira issue ${key} was hydrated from GET /issue but is missing from the cache.`,
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
return task
|
|
552
|
+
}
|
|
553
|
+
|
|
455
554
|
async createTask(input: CreateTaskInput): Promise<Task> {
|
|
456
555
|
await this.sync()
|
|
457
556
|
this.normalizeProjectField(input.project)
|
|
@@ -479,14 +578,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
479
578
|
// issues land in the project workflow's default start state. Use
|
|
480
579
|
// `moveTask` after create to change status.
|
|
481
580
|
const created = await this.client.createIssue({ fields })
|
|
482
|
-
|
|
483
|
-
const fresh = getCachedTask(this.db, created.key)
|
|
484
|
-
if (!fresh) {
|
|
485
|
-
providerUpstreamError(
|
|
486
|
-
`Jira issue ${created.key} was created but is not yet visible in the cache after sync.`,
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
return fresh
|
|
581
|
+
return this.hydrateIssueByKey(created.key)
|
|
490
582
|
}
|
|
491
583
|
|
|
492
584
|
async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
|
|
@@ -521,12 +613,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
521
613
|
if (Object.keys(fields).length > 0) {
|
|
522
614
|
await this.client.updateIssue(issueKey, { fields })
|
|
523
615
|
}
|
|
524
|
-
|
|
525
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
526
|
-
if (!fresh) {
|
|
527
|
-
providerUpstreamError(`Jira issue ${issueKey} disappeared from cache after update.`)
|
|
528
|
-
}
|
|
529
|
-
return fresh
|
|
616
|
+
return this.hydrateIssueByKey(issueKey)
|
|
530
617
|
}
|
|
531
618
|
|
|
532
619
|
async moveTask(idOrRef: string, column: string): Promise<Task> {
|
|
@@ -563,12 +650,7 @@ export class JiraProvider implements KanbanProvider {
|
|
|
563
650
|
)
|
|
564
651
|
}
|
|
565
652
|
await this.client.transitionIssue(issueKey, match.id)
|
|
566
|
-
|
|
567
|
-
const fresh = getCachedTask(this.db, issueKey)
|
|
568
|
-
if (!fresh) {
|
|
569
|
-
providerUpstreamError(`Jira issue ${issueKey} missing from cache after transition.`)
|
|
570
|
-
}
|
|
571
|
-
return fresh
|
|
653
|
+
return this.hydrateIssueByKey(issueKey)
|
|
572
654
|
}
|
|
573
655
|
|
|
574
656
|
async deleteTask(_idOrRef: string): Promise<Task> {
|