@defrex/autobuild 0.12.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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,5 @@
1
+ import { fileRequestedIds, validateFileTicketId } from './requested-id'
2
+ import { FileTicketAssets } from './file-assets'
1
3
  /**
2
4
  * File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
3
5
  * of state directories — the lifecycle four `triage/ ready/ doing/ done/`
@@ -17,14 +19,28 @@
17
19
  * Concurrency: writes locate and validate immediately before a same-path
18
20
  * rewrite or same-filesystem rename; there is no filesystem lock. The
19
21
  * dispatcher remains the lifecycle single writer (SPEC §12), and concurrent
20
- * human edits are explicitly last-write-wins. State renames never cross a
22
+ * unguarded human edits are explicitly last-write-wins. Revision-checked writes
23
+ * compare the final read, retaining the same unlocked read/write race window. State renames never cross a
21
24
  * filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
22
25
  */
26
+ import {
27
+ STATE_NAME_RULE,
28
+ validateStateName,
29
+ validateStatePurpose,
30
+ orderStates,
31
+ resolveState,
32
+ } from './states'
23
33
  import { readdirSync, statSync } from 'node:fs'
24
34
  import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
25
35
  import { join } from 'node:path'
26
- import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
27
- import { z } from 'zod'
36
+ import {
37
+ parseTicketFile,
38
+ serializeTicketFile,
39
+ TicketFileValidationError,
40
+ type Frontmatter,
41
+ } from './file-codec'
42
+ export { TicketFileValidationError } from './file-codec'
43
+ import { ticketRevision, checkTicketRevision } from './revision'
28
44
  import { systemClock, type Clock } from '../../store/types'
29
45
  import type {
30
46
  DependencyState,
@@ -35,6 +51,7 @@ import type {
35
51
  TicketSource,
36
52
  TicketStateInfo,
37
53
  TicketUpdate,
54
+ TicketMutation,
38
55
  } from '../types'
39
56
  import { validateTicketUpdate } from './update'
40
57
 
@@ -51,116 +68,22 @@ const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
51
68
  * and non-`.md` files are never tickets, so it can never be mistaken for one. */
52
69
  const ABOUT_FILE = '.about'
53
70
 
54
- const STATE_NAME_RULE =
55
- 'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
56
- const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
71
+ export { validateStateName } from './states'
57
72
 
58
- /** Throws, stating the rule, unless `name` may be used to create a state. */
59
- export function validateStateName(name: string): void {
60
- if (!STATE_NAME_RE.test(name)) {
61
- throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
62
- }
63
- }
64
-
65
- /** The state names a root listing yields: the lifecycle four, then every other
66
- * non-dot directory alphabetically. Dot entries and files are never states. */
67
73
  function statesIn(directories: string[]): string[] {
68
- const lifecycle: string[] = [...LIFECYCLE_DIRS]
69
- const extra = directories
70
- .filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
71
- .sort()
72
- return [...lifecycle, ...extra]
73
- }
74
-
75
- /**
76
- * Resolve a state name to its directory. Case-insensitive in (a config's
77
- * `readyState = "ready"` means the `ready/` directory), the directory's real
78
- * name out — and a name matching no directory is a loud error, because a
79
- * typo'd state must not silently create a directory no `ls` would show.
80
- */
81
- function resolveState(name: string, existing: string[]): string {
82
- if (existing.includes(name)) return name
83
- const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
84
- if (matches.length === 1) return matches[0] as string
85
- if (matches.length > 1) {
86
- throw new Error(
87
- `file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
88
- )
89
- }
90
- throw new Error(
91
- `file ticket source: unknown state "${name}" — this tracker's states are ` +
92
- `the directories: ${existing.join(', ')}`,
74
+ return orderStates(
75
+ [
76
+ ...new Set([
77
+ ...LIFECYCLE_DIRS.filter(
78
+ (name) => !directories.some((dir) => dir.toLowerCase() === name && dir !== name),
79
+ ),
80
+ ...directories.filter((name) => !name.startsWith('.')),
81
+ ]),
82
+ ],
83
+ LIFECYCLE_DIRS,
93
84
  )
94
85
  }
95
86
 
96
- const frontmatterSchema = z.strictObject({
97
- id: z.string().min(1),
98
- title: z.string().min(1),
99
- labels: z.array(z.string()).default([]),
100
- /** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
101
- * every pre-existing ticket file valid. */
102
- blockedBy: z.array(z.string()).optional(),
103
- /** Stable external-create adoption key. It travels with the ticket across
104
- * state-directory moves, so retries find it in any lifecycle state. */
105
- idempotencyKey: z.string().min(1).optional(),
106
- })
107
- type Frontmatter = z.infer<typeof frontmatterSchema>
108
-
109
- const OPEN_FENCE = '+++\n'
110
- const CLOSE_FENCE = '\n+++\n'
111
-
112
- /** A malformed record that a complete tracker scan may safely exclude. This is
113
- * deliberately narrower than ordinary Error: filesystem failures, layout
114
- * violations, duplicate ids, and unexpected exceptions must still abort the
115
- * listing. */
116
- export class TicketFileValidationError extends Error {
117
- override readonly name = 'TicketFileValidationError'
118
- }
119
-
120
- function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
121
- if (!raw.startsWith(OPEN_FENCE)) {
122
- throw new TicketFileValidationError(
123
- `${path}: malformed ticket file — missing opening "+++" fence`,
124
- )
125
- }
126
- const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
127
- if (close === -1) {
128
- throw new TicketFileValidationError(
129
- `${path}: malformed ticket file — missing closing "+++" fence`,
130
- )
131
- }
132
- let parsed: unknown
133
- try {
134
- parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
135
- } catch (error) {
136
- const message = error instanceof Error ? error.message : String(error)
137
- throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
138
- }
139
- const result = frontmatterSchema.safeParse(parsed)
140
- if (!result.success) {
141
- const issues = result.error.issues
142
- .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
143
- .join('; ')
144
- throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
145
- }
146
- return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
147
- }
148
-
149
- function serializeTicketFile(front: Frontmatter, body: string): string {
150
- const data: Record<string, unknown> = { id: front.id, title: front.title }
151
- if (front.labels.length > 0) data.labels = front.labels
152
- // Only when nonempty: a file that never declared blockers must round-trip
153
- // byte-identically rather than sprouting `blockedBy = []` on every write.
154
- if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
155
- data.blockedBy = front.blockedBy
156
- }
157
- if (front.idempotencyKey !== undefined) {
158
- data.idempotencyKey = front.idempotencyKey
159
- }
160
- // smol-toml stringify ends with a newline; the closing fence follows it.
161
- return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
162
- }
163
-
164
87
  /** A ticket file found on disk: its id, its state (= its directory), its path. */
165
88
  interface Located {
166
89
  id: string
@@ -170,6 +93,8 @@ interface Located {
170
93
 
171
94
  export class FileTicketSource implements TicketSource {
172
95
  readonly name = 'file'
96
+ readonly requestedIds = fileRequestedIds
97
+ readonly assets: FileTicketAssets
173
98
 
174
99
  private readonly dir: string
175
100
  private readonly clock: Clock
@@ -198,6 +123,22 @@ export class FileTicketSource implements TicketSource {
198
123
  */
199
124
  selfIgnore?: boolean
200
125
  }) {
126
+ this.assets = new FileTicketAssets(opts.dir, async (id) => {
127
+ const found = await this.locate(id)
128
+ if (!found)
129
+ return {
130
+ exists: false,
131
+ front: { id, title: id, labels: [] },
132
+ save: async () => {
133
+ throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
134
+ },
135
+ }
136
+ const loaded = { found, ...(await this.loadAt(found)) }
137
+ return {
138
+ ...loaded,
139
+ save: (front: Frontmatter) => this.rewriteAt(loaded.found, front, loaded.body),
140
+ }
141
+ })
201
142
  this.dir = opts.dir
202
143
  this.clock = opts.clock ?? systemClock
203
144
  const existing = this.statesSync()
@@ -273,7 +214,12 @@ export class FileTicketSource implements TicketSource {
273
214
  throw new Error(`file ticket source: comment on unknown ticket "${id}"`)
274
215
  }
275
216
  const raw = await readFile(found.path, 'utf8')
276
- parseTicketFile(found.path, raw) // surface malformed files before appending
217
+ const parsed = parseTicketFile(found.path, raw)
218
+ if (parsed.front.comments !== undefined) {
219
+ parsed.front.comments.push({ body, createdAt: this.clock().toISOString() })
220
+ await this.rewriteAt(found, parsed.front, parsed.body)
221
+ return
222
+ }
277
223
  const separator = raw.endsWith('\n') ? '\n' : '\n\n'
278
224
  const stamp = this.clock().toISOString()
279
225
  await writeFile(found.path, `${raw}${separator}## Comment (${stamp})\n\n${body}\n`)
@@ -301,6 +247,7 @@ export class FileTicketSource implements TicketSource {
301
247
  /** Writes `<createState>/file-<n>.md` with the next free n (gaps reused),
302
248
  * or adopts the ticket carrying the same idempotency key. */
303
249
  async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
250
+ if (opts.requestedId !== undefined) validateFileTicketId(opts.requestedId)
304
251
  await this.ensureLayout()
305
252
  // Resolve before anything is read or written: an unknown state touches no file.
306
253
  const targetState =
@@ -332,7 +279,8 @@ export class FileTicketSource implements TicketSource {
332
279
  let n = 1
333
280
  while (taken.has(`file-${n}`)) n += 1
334
281
  const front: Frontmatter = {
335
- id: `file-${n}`,
282
+ id: opts.requestedId ?? `file-${n}`,
283
+ comments: [],
336
284
  title: draft.title,
337
285
  labels: [...(draft.labels ?? [])],
338
286
  ...(draft.blockedBy !== undefined && draft.blockedBy.length > 0
@@ -340,10 +288,50 @@ export class FileTicketSource implements TicketSource {
340
288
  : {}),
341
289
  ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
342
290
  }
343
- await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
291
+ if (taken.has(front.id)) throw new Error(`requested ticket id already occupied: ${front.id}`)
292
+ await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body), {
293
+ flag: 'wx',
294
+ })
344
295
  return this.toTicket(front, draft.body, targetState)
345
296
  }
346
297
 
298
+ async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
299
+ const patch = mutation.kind === 'update' ? validateTicketUpdate(mutation.patch) : undefined
300
+ await this.ensureLayout()
301
+ const target =
302
+ mutation.kind === 'transition' ? resolveState(mutation.state, await this.states()) : undefined
303
+ if (mutation.kind === 'blockers') {
304
+ for (const blocker of new Set(mutation.ids)) {
305
+ if (mutation.operation === 'block' && blocker === id)
306
+ throw new Error(`ticket "${id}" cannot block itself`)
307
+ await this.requireForWrite(blocker, 'blocker')
308
+ }
309
+ }
310
+ // Final locate/read immediately precedes the write. This is best effort,
311
+ // matching unguarded file writes; it does not introduce filesystem locks.
312
+ const loaded = await this.requireForWrite(id, 'revision-checked write')
313
+ checkTicketRevision(this.toTicket(loaded.front, loaded.body, loaded.found.state), expected)
314
+ if (target !== undefined) {
315
+ if (loaded.found.state !== target) await rename(loaded.found.path, this.pathIn(target, id))
316
+ return
317
+ }
318
+ const front = { ...loaded.front }
319
+ let body = loaded.body
320
+ if (patch) {
321
+ if (patch.title !== undefined) front.title = patch.title
322
+ if (patch.labels !== undefined) front.labels = patch.labels
323
+ if (patch.body !== undefined) body = patch.body
324
+ } else if (mutation.kind === 'blockers') {
325
+ const blockers = new Set(front.blockedBy ?? [])
326
+ for (const blocker of mutation.ids) {
327
+ if (mutation.operation === 'block') blockers.add(blocker)
328
+ else blockers.delete(blocker)
329
+ }
330
+ front.blockedBy = [...blockers]
331
+ }
332
+ await this.rewriteAt(loaded.found, front, body)
333
+ }
334
+
347
335
  async update(id: string, patch: TicketUpdate): Promise<void> {
348
336
  const validated = validateTicketUpdate(patch)
349
337
  await this.ensureLayout()
@@ -448,10 +436,7 @@ export class FileTicketSource implements TicketSource {
448
436
  opts: { about?: string } = {},
449
437
  ): Promise<{ state: TicketStateInfo; created: boolean }> {
450
438
  validateStateName(name)
451
- const about = opts.about === undefined ? undefined : opts.about.trim()
452
- if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
453
- throw new Error('file ticket source: a state purpose must be one nonblank line')
454
- }
439
+ const about = validateStatePurpose(opts.about)
455
440
  const existing = await this.states()
456
441
  const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
457
442
  if (clash !== undefined) {
@@ -537,10 +522,7 @@ export class FileTicketSource implements TicketSource {
537
522
  }
538
523
 
539
524
  private safeId(id: string): string {
540
- if (id.includes('/') || id.includes('\\') || id.includes('..')) {
541
- throw new Error(`file ticket source: invalid ticket id "${id}"`)
542
- }
543
- return id
525
+ return validateFileTicketId(id)
544
526
  }
545
527
 
546
528
  /** Find the one state dir holding `<id>.md`; null when absent. */
@@ -632,7 +614,9 @@ export class FileTicketSource implements TicketSource {
632
614
  /** Rewrite only the located file: editable writes can never move lifecycle
633
615
  * state because the existing state-directory path remains authoritative. */
634
616
  private async rewriteAt(found: Located, front: Frontmatter, body: string): Promise<void> {
635
- await writeFile(found.path, serializeTicketFile(front, body))
617
+ const temp = `${found.path}.${crypto.randomUUID()}.tmp`
618
+ await writeFile(temp, serializeTicketFile(front, body))
619
+ await rename(temp, found.path)
636
620
  }
637
621
 
638
622
  private async listAll(): Promise<TicketListing> {
@@ -658,8 +642,18 @@ export class FileTicketSource implements TicketSource {
658
642
 
659
643
  private toTicket(front: Frontmatter, body: string, state: string): Ticket {
660
644
  return {
645
+ revision: ticketRevision({
646
+ title: front.title,
647
+ body,
648
+ labels: front.labels,
649
+ state,
650
+ blockedBy: front.blockedBy,
651
+ ...(front.comments !== undefined ? { comments: front.comments } : {}),
652
+ ...(front.attachments !== undefined ? { attachments: front.attachments } : {}),
653
+ }),
661
654
  ref: { source: this.name, id: front.id, title: front.title },
662
655
  ...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
656
+ ...(front.comments !== undefined ? { comments: structuredClone(front.comments) } : {}),
663
657
  title: front.title,
664
658
  body,
665
659
  state,
@@ -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,7 +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'
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('listReady', LIST_READY_QUERY, {
347
- filter,
348
- first: READY_PAGE_SIZE,
349
- after,
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', GET_ISSUE_QUERY, { id })
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
- return data.issue ? this.toTicket(data.issue) : null
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
- return { ...this.toTicket(issue), ...(blockedBy.length > 0 ? { blockedBy } : {}) }
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,