@defrex/autobuild 0.11.0 → 0.13.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/README.md +3 -1
- package/SPEC.md +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +15 -1
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +172 -35
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTOBUILD_VERSION,
|
|
3
|
+
AUTOBUILD_VERSION_HEADER,
|
|
4
|
+
REMOTE_STORE_PROTOCOL_VERSION,
|
|
5
|
+
REMOTE_STORE_PROTOCOL_VERSION_HEADER,
|
|
6
|
+
} from '../../store/remote/version'
|
|
7
|
+
import { ticketGrantResponseSchema } from './remote-protocol'
|
|
8
|
+
|
|
9
|
+
export async function ticketGrantRequest(url: string, token: string, input: unknown) {
|
|
10
|
+
const response = await fetch(`${url.replace(/\/+$/, '')}/tickets/grants`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: {
|
|
13
|
+
authorization: `Bearer ${token}`,
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
[AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
|
|
16
|
+
[REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify(input),
|
|
19
|
+
redirect: 'error',
|
|
20
|
+
signal: AbortSignal.timeout(15000),
|
|
21
|
+
})
|
|
22
|
+
// Teardown can race guest/host cleanup; an ended grant is already revoked.
|
|
23
|
+
if (
|
|
24
|
+
response.status === 401 &&
|
|
25
|
+
typeof input === 'object' &&
|
|
26
|
+
input !== null &&
|
|
27
|
+
'action' in input &&
|
|
28
|
+
input.action === 'revoke'
|
|
29
|
+
)
|
|
30
|
+
return { ok: true }
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Ticket grant request failed (${response.status}); refusing project-authority fallback`,
|
|
34
|
+
)
|
|
35
|
+
return response.json()
|
|
36
|
+
}
|
|
37
|
+
export async function issueTicketGrant(
|
|
38
|
+
url: string,
|
|
39
|
+
token: string,
|
|
40
|
+
repo: string,
|
|
41
|
+
execution: string,
|
|
42
|
+
kind: 'runner' | 'phase',
|
|
43
|
+
) {
|
|
44
|
+
return ticketGrantResponseSchema.parse(
|
|
45
|
+
await ticketGrantRequest(url, token, { action: 'issue', repo, execution, kind }),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -5,10 +5,24 @@ export { HostedTicketSource } from './remote'
|
|
|
5
5
|
export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
|
|
6
6
|
export {
|
|
7
7
|
HOSTED_TICKET_OPERATIONS,
|
|
8
|
+
TICKET_COMMENTS_HEADER,
|
|
9
|
+
TICKET_COMMENTS_CAPABILITY,
|
|
8
10
|
hostedTicketContextSchema,
|
|
9
11
|
hostedTicketRequestSchemas,
|
|
12
|
+
TICKET_REVISIONS_HEADER,
|
|
13
|
+
TICKET_REVISIONS_CAPABILITY,
|
|
10
14
|
} from './remote-protocol'
|
|
11
15
|
export type { HostedTicketContext, HostedTicketOperation } from './remote-protocol'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
TicketRevisionConflict,
|
|
19
|
+
TicketRevisionUnsupported,
|
|
20
|
+
mutateTicketIfRevision,
|
|
21
|
+
} from './revision'
|
|
22
|
+
|
|
23
|
+
export { hostedTicketPolicySchema, type HostedTicketPolicy } from './remote-protocol'
|
|
24
|
+
export { ticketLifecycle } from './lifecycle'
|
|
25
|
+
|
|
26
|
+
export { orderStates, resolveState, validateStateName, validateStatePurpose } from './states'
|
|
27
|
+
|
|
28
|
+
export { hostedRequestedIds, MAX_TICKET_NUMBER } from './requested-id'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TicketRef } from '../../ontology'
|
|
2
|
+
import type { TicketSource } from '../types'
|
|
3
|
+
|
|
4
|
+
/** A per-operation comparison view. Durable references and proposal keys stay exact. */
|
|
5
|
+
export function ticketIdentity(source: TicketSource): (ref: TicketRef) => Promise<string> {
|
|
6
|
+
const cache = new Map<string, Promise<string>>()
|
|
7
|
+
return async (ref) => {
|
|
8
|
+
if (ref.source !== source.name) return `${ref.source}:${ref.id}`
|
|
9
|
+
let pending = cache.get(ref.id)
|
|
10
|
+
if (!pending) {
|
|
11
|
+
pending = source
|
|
12
|
+
.get(ref.id)
|
|
13
|
+
.then(
|
|
14
|
+
(ticket) => `${ref.source}:${ticket?.ref.source === ref.source ? ticket.ref.id : ref.id}`,
|
|
15
|
+
)
|
|
16
|
+
cache.set(ref.id, pending)
|
|
17
|
+
}
|
|
18
|
+
return pending
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TicketsConfig } from '../../config/schema'
|
|
2
|
+
|
|
3
|
+
/** Resolve names without changing persisted ticket states or their spelling. */
|
|
4
|
+
export function ticketLifecycle(tickets: TicketsConfig, legacyHosted = false) {
|
|
5
|
+
const hosted = tickets.source === 'hosted'
|
|
6
|
+
const old = tickets.source === 'linear' || (hosted && legacyHosted)
|
|
7
|
+
const triage = tickets.triageState ?? (old ? 'Backlog' : hosted ? 'triage' : 'Triage')
|
|
8
|
+
return {
|
|
9
|
+
triage,
|
|
10
|
+
ready: tickets.readyState,
|
|
11
|
+
doing:
|
|
12
|
+
tickets.claimedState ??
|
|
13
|
+
(tickets.source === 'linear' ? 'In Progress' : old ? 'Doing' : 'doing'),
|
|
14
|
+
done: tickets.doneState ?? (old ? 'Done' : hosted ? 'done' : 'Done'),
|
|
15
|
+
create: tickets.createState ?? triage,
|
|
16
|
+
proposal: tickets.proposalState ?? triage,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ticketRevision } from './revision'
|
|
1
2
|
/**
|
|
2
3
|
* Linear TicketSource (SPEC §3.2, §13): GraphQL over an injectable fetch, so
|
|
3
4
|
* tests run offline against canned exchanges. Queries are deliberately
|
|
@@ -109,7 +110,8 @@ interface GqlIssue {
|
|
|
109
110
|
description: string | null
|
|
110
111
|
url: string
|
|
111
112
|
state: { name: string; type: string } | null
|
|
112
|
-
labels: { nodes: Array<{ name: string }
|
|
113
|
+
labels: { nodes: Array<{ name: string }>; pageInfo?: PageInfo }
|
|
114
|
+
comments?: Connection<{ body: string; createdAt: string; user: { name: string } | null }>
|
|
113
115
|
/** Relations where THIS issue is the `relatedIssue` side. A relation
|
|
114
116
|
* `{issue: A, relatedIssue: B, type: "blocks"}` reads "A blocks B", so an
|
|
115
117
|
* issue's blockers are its inverse `blocks` relations, and the blocker is
|
|
@@ -237,6 +239,7 @@ function nextCursor(info: PageInfo | undefined, seen: Set<string>): string | nul
|
|
|
237
239
|
return cursor
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
const GET_DETAIL_QUERY = `query GetIssue($id: String!) { issue(id: $id) { id identifier title description url state { name type } labels(first: 100) { nodes { name } ${PAGE_INFO} } inverseRelations(first: 100) { nodes { ${READY_RELATIONS} } ${PAGE_INFO} } comments(first: 100, includeArchived: true, orderBy: createdAt) { nodes { body createdAt user { name } } ${PAGE_INFO} } } }`
|
|
240
243
|
const GET_ISSUE_QUERY = `query GetIssue($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`
|
|
241
244
|
const RESOLVE_ISSUE_QUERY = `query ResolveIssue($id: String!) { issue(id: $id) { id } }`
|
|
242
245
|
const ISSUE_STATE_QUERY = `query IssueState($id: String!) { issue(id: $id) { id state { name type } } }`
|
|
@@ -343,11 +346,17 @@ export class LinearTicketSource implements TicketSource {
|
|
|
343
346
|
const seen = new Set<string>()
|
|
344
347
|
let after: string | null = null
|
|
345
348
|
do {
|
|
346
|
-
const data: { issues: Connection<GqlIssue> } = await this.gql(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
349
|
+
const data: { issues: Connection<GqlIssue> } = await this.gql(
|
|
350
|
+
'listReady',
|
|
351
|
+
Object.values(criteria).every((value) => value === undefined)
|
|
352
|
+
? LIST_READY_QUERY.replace('after: $after)', 'after: $after, includeArchived: true)')
|
|
353
|
+
: LIST_READY_QUERY,
|
|
354
|
+
{
|
|
355
|
+
filter,
|
|
356
|
+
first: READY_PAGE_SIZE,
|
|
357
|
+
after,
|
|
358
|
+
},
|
|
359
|
+
)
|
|
351
360
|
await this.completeRelations(data.issues.nodes, true)
|
|
352
361
|
for (const issue of data.issues.nodes) {
|
|
353
362
|
const relations = issue.inverseRelations!.nodes
|
|
@@ -369,7 +378,7 @@ export class LinearTicketSource implements TicketSource {
|
|
|
369
378
|
async get(id: string): Promise<Ticket | null> {
|
|
370
379
|
let data: { issue: GqlIssue | null }
|
|
371
380
|
try {
|
|
372
|
-
data = await this.gql<{ issue: GqlIssue | null }>('get',
|
|
381
|
+
data = await this.gql<{ issue: GqlIssue | null }>('get', GET_DETAIL_QUERY, { id })
|
|
373
382
|
} catch (error) {
|
|
374
383
|
// Linear's `issue(id:)` field is non-null in the live schema: an unknown
|
|
375
384
|
// identifier arrives as INPUT_ERROR / Entity not found rather than the
|
|
@@ -377,7 +386,54 @@ export class LinearTicketSource implements TicketSource {
|
|
|
377
386
|
if (isEntityNotFound(error)) return null
|
|
378
387
|
throw error
|
|
379
388
|
}
|
|
380
|
-
|
|
389
|
+
if (!data.issue) return null
|
|
390
|
+
const issue = data.issue
|
|
391
|
+
// Detail reads exhaust labels and relations independently of dispatch bounds.
|
|
392
|
+
const labels: Array<{ name: string }> = [...issue.labels.nodes]
|
|
393
|
+
const labelSeen = new Set<string>()
|
|
394
|
+
let labelAfter = nextCursor(issue.labels.pageInfo, labelSeen)
|
|
395
|
+
while (labelAfter !== null) {
|
|
396
|
+
const page: { issue: { labels: Connection<{ name: string }> } } = await this.gql(
|
|
397
|
+
'getLabels',
|
|
398
|
+
`query TicketLabels($id: String!, $after: String) { issue(id: $id) { labels(first: 100, after: $after) { nodes { name } ${PAGE_INFO} } } }`,
|
|
399
|
+
{ id: issue.id ?? id, after: labelAfter },
|
|
400
|
+
)
|
|
401
|
+
labels.push(...page.issue.labels.nodes)
|
|
402
|
+
labelAfter = nextCursor(page.issue.labels.pageInfo, labelSeen)
|
|
403
|
+
}
|
|
404
|
+
issue.labels = { nodes: labels }
|
|
405
|
+
await this.completeRelations([issue], true)
|
|
406
|
+
const mapComment = (c: { body: string; createdAt: string; user: { name: string } | null }) => ({
|
|
407
|
+
body: c.body,
|
|
408
|
+
createdAt: c.createdAt,
|
|
409
|
+
...(c.user ? { author: c.user.name } : {}),
|
|
410
|
+
})
|
|
411
|
+
const comments: import('../types').TicketComment[] = (issue.comments?.nodes ?? []).map(
|
|
412
|
+
mapComment,
|
|
413
|
+
)
|
|
414
|
+
const seen = new Set<string>()
|
|
415
|
+
let after = nextCursor(issue.comments?.pageInfo, seen)
|
|
416
|
+
while (after !== null) {
|
|
417
|
+
const page: {
|
|
418
|
+
issue: {
|
|
419
|
+
comments: Connection<{ body: string; createdAt: string; user: { name: string } | null }>
|
|
420
|
+
}
|
|
421
|
+
} = await this.gql(
|
|
422
|
+
'getComments',
|
|
423
|
+
`query TicketComments($id: String!, $after: String) { issue(id: $id) { comments(first: 100, after: $after, includeArchived: true, orderBy: createdAt) { nodes { body createdAt user { name } } ${PAGE_INFO} } } }`,
|
|
424
|
+
{ id: issue.id ?? id, after },
|
|
425
|
+
)
|
|
426
|
+
comments.push(
|
|
427
|
+
...page.issue.comments.nodes.map((c) => ({
|
|
428
|
+
body: c.body,
|
|
429
|
+
createdAt: c.createdAt,
|
|
430
|
+
...(c.user ? { author: c.user.name } : {}),
|
|
431
|
+
})),
|
|
432
|
+
)
|
|
433
|
+
after = nextCursor(page.issue.comments.pageInfo, seen)
|
|
434
|
+
}
|
|
435
|
+
comments.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
|
436
|
+
return { ...this.toTicket(issue), comments }
|
|
381
437
|
}
|
|
382
438
|
|
|
383
439
|
/**
|
|
@@ -430,6 +486,7 @@ export class LinearTicketSource implements TicketSource {
|
|
|
430
486
|
}
|
|
431
487
|
|
|
432
488
|
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
489
|
+
if (opts.requestedId !== undefined) throw new Error('linear does not support requested ids')
|
|
433
490
|
const reservedId = opts.idempotencyKey
|
|
434
491
|
if (reservedId !== undefined && !linearReservedIssueIdSchema.safeParse(reservedId).success) {
|
|
435
492
|
throw new Error('linear create: idempotency key must be a UUID v4')
|
|
@@ -796,7 +853,8 @@ export class LinearTicketSource implements TicketSource {
|
|
|
796
853
|
// The issue projection predates newly created relations. Preserve native
|
|
797
854
|
// existing blockers and report the requested set now known to be recorded.
|
|
798
855
|
const blockedBy = [...new Set([...blockersOf(issue), ...requested])]
|
|
799
|
-
|
|
856
|
+
const ticket = { ...this.toTicket(issue), ...(blockedBy.length > 0 ? { blockedBy } : {}) }
|
|
857
|
+
return { ...ticket, revision: ticketRevision(ticket) }
|
|
800
858
|
}
|
|
801
859
|
|
|
802
860
|
private async adoptCreatedIssue(id: string): Promise<GqlIssue | null> {
|
|
@@ -1017,6 +1075,13 @@ export class LinearTicketSource implements TicketSource {
|
|
|
1017
1075
|
private toTicket(issue: GqlIssue): Ticket {
|
|
1018
1076
|
const blockedBy = blockersOf(issue)
|
|
1019
1077
|
return {
|
|
1078
|
+
revision: ticketRevision({
|
|
1079
|
+
title: issue.title,
|
|
1080
|
+
body: issue.description ?? '',
|
|
1081
|
+
labels: issue.labels.nodes.map((label) => label.name),
|
|
1082
|
+
state: issue.state?.name,
|
|
1083
|
+
blockedBy,
|
|
1084
|
+
}),
|
|
1020
1085
|
ref: {
|
|
1021
1086
|
source: this.name,
|
|
1022
1087
|
id: issue.identifier,
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { validateTicketAssetInput } from '../../store/ticket-assets'
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { Ticket, TicketSource, TicketAssetStore } from '../types'
|
|
6
|
+
import { FileTicketSource } from './file'
|
|
7
|
+
import { parseTicketFile, serializeTicketFile } from './file-codec'
|
|
8
|
+
import { validateFileTicketId } from './requested-id'
|
|
9
|
+
import { normalizeStateName } from './states'
|
|
10
|
+
import { encodePortableAsset, decodePortableAsset, type PortableAsset } from './portable-assets'
|
|
11
|
+
|
|
12
|
+
const MANIFEST = '.ab-migration.json'
|
|
13
|
+
export const IMPORT_MAP = '.ab-import-map.json'
|
|
14
|
+
const manifestSchema = z.strictObject({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
identity: z.string().min(1),
|
|
17
|
+
source: z.string(),
|
|
18
|
+
keys: z.record(z.string(), z.string().min(1)),
|
|
19
|
+
states: z.record(z.string(), z.string()),
|
|
20
|
+
})
|
|
21
|
+
type Manifest = z.infer<typeof manifestSchema>
|
|
22
|
+
async function atomicJson(path: string, value: unknown) {
|
|
23
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`
|
|
24
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`)
|
|
25
|
+
await rename(temp, path)
|
|
26
|
+
}
|
|
27
|
+
async function inventory(source: TicketSource): Promise<Ticket[]> {
|
|
28
|
+
const listing = await source.listReady({})
|
|
29
|
+
if (listing.diagnostics.length)
|
|
30
|
+
throw new Error(`incomplete ticket scan:\n${listing.diagnostics.join('\n')}`)
|
|
31
|
+
const ids = new Set<string>()
|
|
32
|
+
const tickets: Ticket[] = []
|
|
33
|
+
for (const ticket of listing.tickets) {
|
|
34
|
+
if (ids.has(ticket.ref.id)) throw new Error(`duplicate ticket id: ${ticket.ref.id}`)
|
|
35
|
+
ids.add(ticket.ref.id)
|
|
36
|
+
const detail = await source.get(ticket.ref.id)
|
|
37
|
+
if (!detail) throw new Error(`ticket disappeared: ${ticket.ref.id}`)
|
|
38
|
+
if (!detail.state) throw new Error(`ticket has no state: ${ticket.ref.id}`)
|
|
39
|
+
tickets.push(detail)
|
|
40
|
+
}
|
|
41
|
+
return tickets
|
|
42
|
+
}
|
|
43
|
+
function uniqueStateMap(names: string[], map: (name: string) => string): Record<string, string> {
|
|
44
|
+
const result: Record<string, string> = Object.create(null)
|
|
45
|
+
const groups = new Map<string, string[]>()
|
|
46
|
+
for (const name of new Set(names)) {
|
|
47
|
+
const target = map(name)
|
|
48
|
+
result[name] = target
|
|
49
|
+
groups.set(target, [...(groups.get(target) ?? []), name])
|
|
50
|
+
}
|
|
51
|
+
const collisions = [...groups].filter(([, originals]) => originals.length > 1)
|
|
52
|
+
if (collisions.length)
|
|
53
|
+
throw new Error(
|
|
54
|
+
`state collisions: ${collisions.map(([target, originals]) => `${originals.join(', ')} → ${target}`).join('; ')}`,
|
|
55
|
+
)
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
async function currentAssets(
|
|
59
|
+
store: TicketAssetStore | undefined,
|
|
60
|
+
repo: string,
|
|
61
|
+
id: string,
|
|
62
|
+
): Promise<PortableAsset[]> {
|
|
63
|
+
if (!store) return []
|
|
64
|
+
const result: PortableAsset[] = []
|
|
65
|
+
for (const summary of await store.listTicketAssets(repo, id)) {
|
|
66
|
+
const asset = await store.getTicketAsset(repo, id, summary.kind, summary.name, summary.revision)
|
|
67
|
+
if (!asset) throw new Error(`asset disappeared: ${id} ${summary.kind}/${summary.name}`)
|
|
68
|
+
result.push(encodePortableAsset({ ...asset.meta, entries: asset.entries }))
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
export interface MigrationOptions {
|
|
73
|
+
source: TicketSource
|
|
74
|
+
dir: string
|
|
75
|
+
repo: string
|
|
76
|
+
assets?: TicketAssetStore
|
|
77
|
+
print?: (line: string) => void
|
|
78
|
+
}
|
|
79
|
+
/** Publish a complete portable tracker only after every source read succeeds. */
|
|
80
|
+
export async function exportTickets(opts: MigrationOptions): Promise<void> {
|
|
81
|
+
const destination = resolve(opts.dir)
|
|
82
|
+
try {
|
|
83
|
+
if ((await readdir(destination)).length)
|
|
84
|
+
throw new Error(`export destination is not empty: ${destination}`)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
87
|
+
}
|
|
88
|
+
const tickets = await inventory(opts.source)
|
|
89
|
+
const names = [
|
|
90
|
+
...tickets.map((ticket) => ticket.state!),
|
|
91
|
+
...((await opts.source.listStates?.()) ?? []).map((state) => state.name),
|
|
92
|
+
]
|
|
93
|
+
const states = uniqueStateMap(names, normalizeStateName)
|
|
94
|
+
const identity = crypto.randomUUID()
|
|
95
|
+
const manifest: Manifest = {
|
|
96
|
+
version: 1,
|
|
97
|
+
identity,
|
|
98
|
+
source: `${opts.source.name}:${opts.repo}`,
|
|
99
|
+
keys: Object.create(null),
|
|
100
|
+
states,
|
|
101
|
+
}
|
|
102
|
+
const records = []
|
|
103
|
+
for (const ticket of tickets) {
|
|
104
|
+
validateFileTicketId(ticket.ref.id)
|
|
105
|
+
const key = ticket.creationKey ?? `migration:${identity}:${ticket.ref.id}`
|
|
106
|
+
manifest.keys[ticket.ref.id] = key
|
|
107
|
+
records.push({
|
|
108
|
+
state: states[ticket.state!]!,
|
|
109
|
+
id: ticket.ref.id,
|
|
110
|
+
raw: serializeTicketFile(
|
|
111
|
+
{
|
|
112
|
+
id: ticket.ref.id,
|
|
113
|
+
title: ticket.title,
|
|
114
|
+
labels: ticket.labels,
|
|
115
|
+
blockedBy: ticket.blockedBy,
|
|
116
|
+
idempotencyKey: key,
|
|
117
|
+
...(ticket.comments !== undefined ? { comments: ticket.comments } : {}),
|
|
118
|
+
attachments: await currentAssets(
|
|
119
|
+
opts.assets ?? opts.source.assets,
|
|
120
|
+
opts.repo,
|
|
121
|
+
ticket.ref.id,
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
ticket.body,
|
|
125
|
+
),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
129
|
+
const staging = `${destination}.${identity}.tmp`
|
|
130
|
+
await mkdir(staging)
|
|
131
|
+
try {
|
|
132
|
+
for (const state of Object.values(states))
|
|
133
|
+
await mkdir(join(staging, state), { recursive: true })
|
|
134
|
+
for (const record of records)
|
|
135
|
+
await writeFile(join(staging, record.state, `${record.id}.md`), record.raw, { flag: 'wx' })
|
|
136
|
+
await atomicJson(join(staging, MANIFEST), manifest)
|
|
137
|
+
// Recheck immediately before publication; never remove a populated destination.
|
|
138
|
+
try {
|
|
139
|
+
await rmdir(destination)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
142
|
+
}
|
|
143
|
+
await rename(staging, destination)
|
|
144
|
+
} finally {
|
|
145
|
+
await rm(staging, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
opts.print?.(`exported ${tickets.length} tickets to ${destination}`)
|
|
148
|
+
}
|
|
149
|
+
function matchState(name: string, existing: string[]): string | undefined {
|
|
150
|
+
if (existing.includes(name)) return name
|
|
151
|
+
const matches = existing.filter((value) => value.toLowerCase() === name.toLowerCase())
|
|
152
|
+
if (matches.length > 1) throw new Error(`ambiguous target state ${name}: ${matches.join(', ')}`)
|
|
153
|
+
return matches[0]
|
|
154
|
+
}
|
|
155
|
+
/** Resume through creation keys, never through content similarity or mapping files. */
|
|
156
|
+
export async function importTickets(opts: MigrationOptions): Promise<void> {
|
|
157
|
+
const policy = opts.source.requestedIds
|
|
158
|
+
if (!policy || !opts.source.listStates || !opts.source.addState)
|
|
159
|
+
throw new Error(`ticket source ${opts.source.name} does not support import`)
|
|
160
|
+
const dir = resolve(opts.dir)
|
|
161
|
+
const input = new FileTicketSource({ dir })
|
|
162
|
+
const tickets = await inventory(input)
|
|
163
|
+
const bundles = new Map<string, PortableAsset[]>()
|
|
164
|
+
for (const ticket of tickets) {
|
|
165
|
+
const path = join(dir, ticket.state!, `${ticket.ref.id}.md`)
|
|
166
|
+
bundles.set(
|
|
167
|
+
ticket.ref.id,
|
|
168
|
+
parseTicketFile(path, await readFile(path, 'utf8')).front.attachments ?? [],
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
let manifest: Manifest
|
|
172
|
+
try {
|
|
173
|
+
manifest = manifestSchema.parse(JSON.parse(await readFile(join(dir, MANIFEST), 'utf8')))
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
176
|
+
manifest = {
|
|
177
|
+
version: 1,
|
|
178
|
+
identity: crypto.randomUUID(),
|
|
179
|
+
source: 'file',
|
|
180
|
+
keys: Object.create(null),
|
|
181
|
+
states: {},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const sourceIds = new Set(tickets.map((ticket) => ticket.ref.id))
|
|
185
|
+
const keys = new Set<string>()
|
|
186
|
+
for (const ticket of tickets) {
|
|
187
|
+
for (const blocker of ticket.blockedBy ?? [])
|
|
188
|
+
if (!sourceIds.has(blocker) || blocker === ticket.ref.id)
|
|
189
|
+
throw new Error(`invalid blocker ${ticket.ref.id} → ${blocker}`)
|
|
190
|
+
const key =
|
|
191
|
+
ticket.creationKey ??
|
|
192
|
+
manifest.keys[ticket.ref.id] ??
|
|
193
|
+
`migration:${manifest.identity}:${ticket.ref.id}`
|
|
194
|
+
if (keys.has(key)) throw new Error(`duplicate creation key: ${key}`)
|
|
195
|
+
keys.add(key)
|
|
196
|
+
manifest.keys[ticket.ref.id] = key
|
|
197
|
+
}
|
|
198
|
+
const target = await inventory(opts.source)
|
|
199
|
+
const byKey = new Map<string, Ticket>()
|
|
200
|
+
for (const ticket of target)
|
|
201
|
+
if (ticket.creationKey) {
|
|
202
|
+
if (byKey.has(ticket.creationKey))
|
|
203
|
+
throw new Error(`ambiguous target creation key: ${ticket.creationKey}`)
|
|
204
|
+
byKey.set(ticket.creationKey, ticket)
|
|
205
|
+
}
|
|
206
|
+
const existingStates = (await opts.source.listStates()).map((state) => state.name)
|
|
207
|
+
// Only actual input directories participate; virtual lifecycle defaults are harmless empty states.
|
|
208
|
+
const inputStates: string[] = []
|
|
209
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
210
|
+
if (
|
|
211
|
+
!entry.name.startsWith('.') &&
|
|
212
|
+
(entry.isDirectory() ||
|
|
213
|
+
(entry.isSymbolicLink() && (await stat(join(dir, entry.name))).isDirectory()))
|
|
214
|
+
)
|
|
215
|
+
inputStates.push(entry.name)
|
|
216
|
+
}
|
|
217
|
+
const states = uniqueStateMap(
|
|
218
|
+
inputStates,
|
|
219
|
+
(name) =>
|
|
220
|
+
matchState(name, existingStates) ??
|
|
221
|
+
matchState(normalizeStateName(name), existingStates) ??
|
|
222
|
+
normalizeStateName(name),
|
|
223
|
+
)
|
|
224
|
+
const planned = new Map<string, string>()
|
|
225
|
+
const occupied = target.map((ticket) => ticket.ref.id)
|
|
226
|
+
const clashes: string[] = []
|
|
227
|
+
for (const ticket of tickets) {
|
|
228
|
+
const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
|
|
229
|
+
if (adopted) {
|
|
230
|
+
planned.set(ticket.ref.id, adopted.ref.id)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const kept = policy.preserve(ticket.ref.id)
|
|
234
|
+
if (kept !== undefined) {
|
|
235
|
+
if (occupied.includes(kept)) clashes.push(`${ticket.ref.id} → ${kept}`)
|
|
236
|
+
planned.set(ticket.ref.id, kept)
|
|
237
|
+
occupied.push(kept)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (clashes.length)
|
|
241
|
+
throw new Error(`ticket id clashes (no target changes):\n${clashes.join('\n')}`)
|
|
242
|
+
for (const ticket of tickets)
|
|
243
|
+
if (!planned.has(ticket.ref.id)) {
|
|
244
|
+
const id = policy.allocate(occupied)
|
|
245
|
+
planned.set(ticket.ref.id, id)
|
|
246
|
+
occupied.push(id)
|
|
247
|
+
}
|
|
248
|
+
const assets = opts.assets ?? opts.source.assets
|
|
249
|
+
if ([...bundles.values()].some((value) => value.length) && !assets)
|
|
250
|
+
throw new Error('target has no attachment capability')
|
|
251
|
+
if (assets && [...bundles.values()].some((value) => value.length)) {
|
|
252
|
+
const limits = await assets.ticketAssetLimits(opts.repo)
|
|
253
|
+
for (const values of bundles.values())
|
|
254
|
+
for (const bundle of values) validateTicketAssetInput(decodePortableAsset(bundle), limits)
|
|
255
|
+
}
|
|
256
|
+
for (const ticket of tickets) {
|
|
257
|
+
const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
|
|
258
|
+
if ((ticket.comments?.length ?? 0) > 0 && adopted && adopted.comments === undefined)
|
|
259
|
+
throw new Error(`target cannot read comment history: ${adopted.ref.id}`)
|
|
260
|
+
}
|
|
261
|
+
// Persist identities before the first target write, including for ordinary layouts.
|
|
262
|
+
await atomicJson(join(dir, MANIFEST), manifest)
|
|
263
|
+
const mapping: Record<string, string> = Object.create(null)
|
|
264
|
+
const mappingPath = join(dir, IMPORT_MAP)
|
|
265
|
+
const saveMap = () =>
|
|
266
|
+
atomicJson(mappingPath, { version: 1, identity: manifest.identity, states, tickets: mapping })
|
|
267
|
+
await saveMap()
|
|
268
|
+
try {
|
|
269
|
+
for (const state of new Set(Object.values(states)))
|
|
270
|
+
if (!existingStates.includes(state)) {
|
|
271
|
+
try {
|
|
272
|
+
await opts.source.addState(state)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const discovered = matchState(
|
|
275
|
+
state,
|
|
276
|
+
(await opts.source.listStates()).map((item) => item.name),
|
|
277
|
+
)
|
|
278
|
+
if (!discovered) throw error
|
|
279
|
+
for (const original of Object.keys(states))
|
|
280
|
+
if (states[original] === state) states[original] = discovered
|
|
281
|
+
await saveMap()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [from, to] of Object.entries(states)) opts.print?.(`state ${from} → ${to}`)
|
|
285
|
+
for (const ticket of tickets) {
|
|
286
|
+
const key = manifest.keys[ticket.ref.id]!
|
|
287
|
+
const result =
|
|
288
|
+
byKey.get(key) ??
|
|
289
|
+
(await opts.source.create(
|
|
290
|
+
{ title: ticket.title, body: ticket.body, labels: ticket.labels },
|
|
291
|
+
{
|
|
292
|
+
state: states[ticket.state!]!,
|
|
293
|
+
idempotencyKey: key,
|
|
294
|
+
requestedId: planned.get(ticket.ref.id)!,
|
|
295
|
+
},
|
|
296
|
+
))
|
|
297
|
+
mapping[ticket.ref.id] = result.ref.id
|
|
298
|
+
await saveMap()
|
|
299
|
+
opts.print?.(`${ticket.ref.id} → ${result.ref.id}`)
|
|
300
|
+
}
|
|
301
|
+
for (const ticket of tickets) {
|
|
302
|
+
const id = mapping[ticket.ref.id]!
|
|
303
|
+
for (const blocker of ticket.blockedBy ?? [])
|
|
304
|
+
await opts.source.addBlocker(id, mapping[blocker]!)
|
|
305
|
+
const fresh = await opts.source.get(id)
|
|
306
|
+
if (!fresh) throw new Error(`target disappeared: ${id}`)
|
|
307
|
+
if ((ticket.comments?.length ?? 0) && fresh.comments === undefined)
|
|
308
|
+
throw new Error(`target cannot read comment history: ${id}`)
|
|
309
|
+
const bodies = new Set(fresh.comments?.map((comment) => comment.body))
|
|
310
|
+
for (const comment of ticket.comments ?? [])
|
|
311
|
+
if (!bodies.has(comment.body)) {
|
|
312
|
+
await opts.source.comment(id, comment.body)
|
|
313
|
+
bodies.add(comment.body)
|
|
314
|
+
}
|
|
315
|
+
for (const bundle of bundles.get(ticket.ref.id) ?? []) {
|
|
316
|
+
const current = await assets!.getTicketAsset(opts.repo, id, bundle.kind, bundle.name)
|
|
317
|
+
const canonical = (asset: PortableAsset) =>
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
...asset,
|
|
320
|
+
entries: [...asset.entries].sort((a, b) => a.path.localeCompare(b.path)),
|
|
321
|
+
})
|
|
322
|
+
if (
|
|
323
|
+
!current ||
|
|
324
|
+
canonical(encodePortableAsset({ ...current.meta, entries: current.entries })) !==
|
|
325
|
+
canonical(bundle)
|
|
326
|
+
)
|
|
327
|
+
await assets!.putTicketAsset(opts.repo, id, decodePortableAsset(bundle))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
opts.print?.(`mapping: ${mappingPath}`)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${error instanceof Error ? error.message : error}\nImport progress retained in ${mappingPath}; rerun import to finish.`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mutateTicketIfRevision,
|
|
3
|
+
TicketRevisionConflict,
|
|
4
|
+
TicketRevisionUnsupported,
|
|
5
|
+
} from './revision'
|
|
1
6
|
import type { Config } from '../../config/schema'
|
|
2
7
|
import { readyCriteria } from '../../processes/dispatcher'
|
|
3
8
|
import type { Ticket, TicketDraft, TicketSource, TicketUpdate } from '../types'
|
|
@@ -91,6 +96,8 @@ export async function createTicket(
|
|
|
91
96
|
? await source.create(draft)
|
|
92
97
|
: await source.create(draft, { state: input.state })
|
|
93
98
|
} catch (error) {
|
|
99
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
100
|
+
throw error
|
|
94
101
|
throw new TicketOperationError(
|
|
95
102
|
'refusal',
|
|
96
103
|
error instanceof Error ? error.message : String(error),
|
|
@@ -103,10 +110,15 @@ export async function updateTicket(
|
|
|
103
110
|
id: string,
|
|
104
111
|
patch: TicketUpdate,
|
|
105
112
|
readAfter = true,
|
|
113
|
+
expectedRevision?: string,
|
|
106
114
|
): Promise<Ticket | null> {
|
|
107
115
|
try {
|
|
108
|
-
|
|
116
|
+
if (expectedRevision !== undefined)
|
|
117
|
+
await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'update', patch })
|
|
118
|
+
else await source.update(id, patch)
|
|
109
119
|
} catch (error) {
|
|
120
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
121
|
+
throw error
|
|
110
122
|
throw new TicketOperationError(
|
|
111
123
|
'refusal',
|
|
112
124
|
error instanceof Error ? error.message : String(error),
|
|
@@ -115,11 +127,20 @@ export async function updateTicket(
|
|
|
115
127
|
return readAfter ? requirePostMutationTicket(source, id, 'the update') : null
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
export async function moveTicket(
|
|
130
|
+
export async function moveTicket(
|
|
131
|
+
source: TicketSource,
|
|
132
|
+
id: string,
|
|
133
|
+
state: string,
|
|
134
|
+
expectedRevision?: string,
|
|
135
|
+
): Promise<Ticket> {
|
|
119
136
|
await requireTicket(source, id)
|
|
120
137
|
try {
|
|
121
|
-
|
|
138
|
+
if (expectedRevision !== undefined)
|
|
139
|
+
await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'transition', state })
|
|
140
|
+
else await source.transition(id, state)
|
|
122
141
|
} catch (error) {
|
|
142
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
143
|
+
throw error
|
|
123
144
|
throw new TicketOperationError(
|
|
124
145
|
'refusal',
|
|
125
146
|
error instanceof Error ? error.message : String(error),
|
|
@@ -133,6 +154,7 @@ export async function changeBlockers(
|
|
|
133
154
|
id: string,
|
|
134
155
|
blockerIds: string[],
|
|
135
156
|
operation: 'block' | 'unblock',
|
|
157
|
+
expectedRevision?: string,
|
|
136
158
|
): Promise<Ticket> {
|
|
137
159
|
await requireTicket(source, id)
|
|
138
160
|
const ids = await knownBlockers(source, blockerIds, '')
|
|
@@ -143,11 +165,20 @@ export async function changeBlockers(
|
|
|
143
165
|
)
|
|
144
166
|
}
|
|
145
167
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
if (expectedRevision !== undefined)
|
|
169
|
+
await mutateTicketIfRevision(source, id, expectedRevision, {
|
|
170
|
+
kind: 'blockers',
|
|
171
|
+
operation,
|
|
172
|
+
ids,
|
|
173
|
+
})
|
|
174
|
+
else
|
|
175
|
+
for (const blockerId of ids) {
|
|
176
|
+
if (operation === 'block') await source.addBlocker(id, blockerId)
|
|
177
|
+
else await source.removeBlocker(id, blockerId)
|
|
178
|
+
}
|
|
150
179
|
} catch (error) {
|
|
180
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
181
|
+
throw error
|
|
151
182
|
throw new TicketOperationError(
|
|
152
183
|
'refusal',
|
|
153
184
|
error instanceof Error ? error.message : String(error),
|