@defrex/autobuild 0.8.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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,811 @@
1
+ /**
2
+ * Linear TicketSource (SPEC §3.2, §13): GraphQL over an injectable fetch, so
3
+ * tests run offline against canned exchanges. Queries are deliberately
4
+ * minimal — only the fields the Ticket shape needs — and responses are typed
5
+ * as small structural types rather than a generated client.
6
+ *
7
+ * Policy (§13): initiation, pre-build grooming, and outward projections only.
8
+ * Nothing here is called mid-build; the build never reads or edits Linear
9
+ * after dispatch imports the spec.
10
+ */
11
+ import { z } from 'zod'
12
+ import type {
13
+ DependencyState,
14
+ Ticket,
15
+ TicketCreateOptions,
16
+ TicketDraft,
17
+ TicketListing,
18
+ TicketSource,
19
+ TicketUpdate,
20
+ } from '../types'
21
+ import { validateTicketUpdate } from './update'
22
+
23
+ export const LINEAR_API_URL = 'https://api.linear.app/graphql'
24
+
25
+ /** One entry of a GraphQL `errors` array — the fields this adapter reads. */
26
+ interface GqlError {
27
+ message: string
28
+ path?: string[]
29
+ extensions?: { code?: string; type?: string }
30
+ }
31
+
32
+ /**
33
+ * A GraphQL-level failure, carrying the structured errors rather than only a
34
+ * joined message: callers must be able to tell "no such issue" from "rate
35
+ * limited", and a substring match on prose cannot be trusted with that.
36
+ */
37
+ export class LinearGqlError extends Error {
38
+ constructor(
39
+ message: string,
40
+ readonly errors: GqlError[],
41
+ ) {
42
+ super(message)
43
+ this.name = 'LinearGqlError'
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Linear reports an unknown issue identifier as a GraphQL ERROR
49
+ * (`Entity not found: Issue`, extensions.code `INPUT_ERROR`) — NOT as
50
+ * `{issue: null}`, which is what the shape of the query suggests. Verified
51
+ * against the live API; a canned fixture would happily agree with either
52
+ * guess, so this predicate exists because the real thing was asked.
53
+ *
54
+ * Every error in the set must be a not-found for this to be "missing": a
55
+ * response mixing not-found with a rate-limit error is a real failure and
56
+ * must not be quietly read as an absent ticket.
57
+ */
58
+ function isEntityNotFound(error: unknown): boolean {
59
+ return (
60
+ error instanceof LinearGqlError &&
61
+ error.errors.length > 0 &&
62
+ error.errors.every(
63
+ (e) => e.extensions?.code === 'INPUT_ERROR' && /entity not found/i.test(e.message),
64
+ )
65
+ )
66
+ }
67
+
68
+ /** The narrow slice of fetch this adapter needs — injectable for tests. */
69
+ export type LinearFetch = (
70
+ url: string,
71
+ init: { method: 'POST'; headers: Record<string, string>; body: string },
72
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
73
+
74
+ // ── Wire shapes (small structural types per operation) ───────────────────────
75
+
76
+ interface GqlIssueRelation {
77
+ id?: string
78
+ type: string
79
+ issue: { id?: string; identifier: string } | null
80
+ }
81
+
82
+ interface GqlIssue {
83
+ id?: string
84
+ identifier: string
85
+ title: string
86
+ description: string | null
87
+ url: string
88
+ state: { name: string; type: string } | null
89
+ labels: { nodes: Array<{ name: string }> }
90
+ /** Relations where THIS issue is the `relatedIssue` side. A relation
91
+ * `{issue: A, relatedIssue: B, type: "blocks"}` reads "A blocks B", so an
92
+ * issue's blockers are its inverse `blocks` relations, and the blocker is
93
+ * each relation's `issue`. Relation ids are required by the delete API. */
94
+ inverseRelations?: { nodes: GqlIssueRelation[] }
95
+ }
96
+
97
+ /**
98
+ * Linear's workflow state types (the `state.type` taxonomy):
99
+ * `backlog | unstarted | started | completed | canceled`. Resolution is
100
+ * provider-owned (§13) — a blocker is done when Linear says the work is
101
+ * finished, either by completion or by cancelation. Any unrecognized type
102
+ * fails CLOSED (unresolved): a dependency we cannot interpret must hold the
103
+ * ticket and show up in the dispatcher's diagnostics, never wave it through.
104
+ */
105
+ const RESOLVED_STATE_TYPES = new Set(['completed', 'canceled'])
106
+
107
+ /** Native inverse `blocks` relations for one blocked issue. */
108
+ function blockerRelationsOf(issue: GqlIssue): GqlIssueRelation[] {
109
+ return (issue.inverseRelations?.nodes ?? []).filter(
110
+ (relation) => relation.type === 'blocks' && relation.issue !== null,
111
+ )
112
+ }
113
+
114
+ /** The identifiers blocking `issue`; unrelated relation kinds are ignored. */
115
+ function blockersOf(issue: GqlIssue): string[] {
116
+ return [...new Set(blockerRelationsOf(issue).map((relation) => relation.issue!.identifier))]
117
+ }
118
+
119
+ function errorMessage(error: unknown): string {
120
+ return error instanceof Error ? error.message : String(error)
121
+ }
122
+
123
+ function blockerList(ids: string[]): string {
124
+ return ids.length > 0 ? ids.map((id) => `"${id}"`).join(', ') : 'none'
125
+ }
126
+
127
+ /**
128
+ * `issueCreate` and relation creation are separate Linear mutations. Once the
129
+ * former succeeds, every failure must say that an issue now exists: otherwise
130
+ * an operator can reasonably retry the command and create a duplicate. Keep
131
+ * the original failure in both the message (the CLI prints only that) and the
132
+ * structured cause (for programmatic callers).
133
+ */
134
+ function blockerRecordingError(
135
+ issue: GqlIssue,
136
+ requested: string[],
137
+ recorded: string[],
138
+ cause: unknown,
139
+ retryableById: boolean,
140
+ ): Error {
141
+ const recordedSet = new Set(recorded)
142
+ const unrecorded = requested.filter((id) => !recordedSet.has(id))
143
+ const guidance = retryableById
144
+ ? 'Retry with the same idempotency key; Linear will adopt this ticket and reconcile only missing blockers. '
145
+ : 'Do not rerun ticket creation; repair the blockers on the existing ticket. '
146
+ return new Error(
147
+ `linear create: ticket "${issue.identifier}" ${retryableById ? 'already exists' : 'was created'} at ${issue.url}, ` +
148
+ 'but its blockers were not all recorded. ' +
149
+ `Blockers recorded: ${blockerList(recorded)}. ` +
150
+ `Blockers not recorded: ${blockerList(unrecorded)}. ` +
151
+ guidance +
152
+ `Underlying failure: ${errorMessage(cause)}`,
153
+ { cause },
154
+ )
155
+ }
156
+
157
+ interface GqlIssueLabel {
158
+ id: string
159
+ name: string
160
+ }
161
+
162
+ interface GqlTeamInfo {
163
+ teams: {
164
+ nodes: Array<{
165
+ id: string
166
+ states: { nodes: Array<{ id: string; name: string }> }
167
+ labels: { nodes: GqlIssueLabel[] }
168
+ }>
169
+ }
170
+ }
171
+
172
+ interface LinearTeamInfo {
173
+ teamId: string
174
+ stateIds: Map<string, string>
175
+ labelIds: Map<string, string>
176
+ }
177
+
178
+ /** Autobuild blocker sets are intentionally small, but Linear's implicit
179
+ * connection default is only 50. Make the practical bound explicit so reads,
180
+ * idempotency preflight, and deletion all see the same relation window. */
181
+ export const LINEAR_RELATION_PAGE_SIZE = 250
182
+
183
+ const ISSUE_FIELDS =
184
+ 'id identifier title description url state { name type } labels { nodes { name } } ' +
185
+ `inverseRelations(first: ${LINEAR_RELATION_PAGE_SIZE}) { nodes { id type issue { id identifier } } }`
186
+
187
+ const LIST_READY_QUERY = `query ListReady($filter: IssueFilter!) { issues(filter: $filter) { nodes { ${ISSUE_FIELDS} } } }`
188
+ const GET_ISSUE_QUERY = `query GetIssue($id: String!) { issue(id: $id) { ${ISSUE_FIELDS} } }`
189
+ const RESOLVE_ISSUE_QUERY = `query ResolveIssue($id: String!) { issue(id: $id) { id } }`
190
+ const ISSUE_STATE_QUERY = `query IssueState($id: String!) { issue(id: $id) { id state { name type } } }`
191
+ const TEAM_INFO_QUERY = `query TeamInfo($teamKey: String!) { teams(filter: { key: { eq: $teamKey } }) { nodes { id states { nodes { id name } } labels { nodes { id name } } } } }`
192
+ const EXACT_TEAM_LABEL_QUERY = `query ExactTeamLabel($teamId: String!, $name: String!) { team(id: $teamId) { labels(filter: { name: { eq: $name } }) { nodes { id name } } } }`
193
+ const CREATE_LABEL_MUTATION = `mutation CreateIssueLabel($input: IssueLabelCreateInput!) { issueLabelCreate(input: $input) { success issueLabel { id name } } }`
194
+ const UPDATE_STATE_MUTATION = `mutation UpdateState($id: String!, $stateId: String!) { issueUpdate(id: $id, input: { stateId: $stateId }) { success } }`
195
+ const CREATE_COMMENT_MUTATION = `mutation CreateComment($issueId: String!, $body: String!) { commentCreate(input: { issueId: $issueId, body: $body }) { success } }`
196
+ const CREATE_ISSUE_MUTATION = `mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { ${ISSUE_FIELDS} } } }`
197
+ const UPDATE_ISSUE_MUTATION = `mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`
198
+ const CREATE_RELATION_MUTATION = `mutation CreateRelation($issueId: String!, $relatedIssueId: String!) { issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: blocks }) { success } }`
199
+ const DELETE_RELATION_MUTATION = `mutation DeleteRelation($id: String!) { issueRelationDelete(id: $id) { success } }`
200
+ const linearReservedIssueIdSchema = z.uuidv4()
201
+
202
+ export class LinearTicketSource implements TicketSource {
203
+ readonly name = 'linear'
204
+
205
+ private readonly apiKey: string
206
+ private readonly teamKey: string
207
+ private readonly claimedState: string
208
+ private readonly createState: string | undefined
209
+ private readonly fetchFn: LinearFetch
210
+
211
+ /** identifier → Linear UUID, cached per instance. */
212
+ private readonly issueIds = new Map<string, string>()
213
+ /** One team query serves state ids, label ids, and the team id. */
214
+ private teamInfo?: LinearTeamInfo
215
+ private teamInfoLoad?: Promise<LinearTeamInfo>
216
+ /** Exact label name → the one same-instance registry write in progress. */
217
+ private readonly pendingLabelCreates = new Map<string, Promise<string>>()
218
+
219
+ constructor(opts: {
220
+ apiKey: string
221
+ teamKey: string
222
+ fetchFn?: LinearFetch
223
+ /** Workflow state claim() moves the issue to (claim-before-launch, §12). */
224
+ claimedState?: string
225
+ /** Workflow state create() files new issues into; absent = Linear's team
226
+ * default (whatever the team's default state is, e.g. Backlog). */
227
+ createState?: string
228
+ }) {
229
+ this.apiKey = opts.apiKey
230
+ this.teamKey = opts.teamKey
231
+ this.claimedState = opts.claimedState ?? 'In Progress'
232
+ this.createState = opts.createState
233
+ this.fetchFn = opts.fetchFn ?? ((url, init) => fetch(url, init))
234
+ }
235
+
236
+ /** Exact team workflow names in provider order, used by trusted grooming UIs. */
237
+ async workflowStateNames(): Promise<string[]> {
238
+ return [...(await this.getTeamInfo('workflow states')).stateIds.keys()]
239
+ }
240
+
241
+ async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
242
+ const filter: Record<string, unknown> = {
243
+ team: { key: { eq: this.teamKey } },
244
+ }
245
+ if (criteria.state !== undefined) {
246
+ filter.state = { name: { eq: criteria.state } }
247
+ }
248
+ if (criteria.labels && criteria.labels.length > 0) {
249
+ // and-of-somes: every requested label must be present.
250
+ filter.and = criteria.labels.map((label) => ({
251
+ labels: { some: { name: { eq: label } } },
252
+ }))
253
+ }
254
+ const data = await this.gql<{ issues: { nodes: GqlIssue[] } }>('listReady', LIST_READY_QUERY, {
255
+ filter,
256
+ })
257
+ return {
258
+ tickets: data.issues.nodes.map((issue) => this.toTicket(issue)),
259
+ diagnostics: [],
260
+ }
261
+ }
262
+
263
+ async get(id: string): Promise<Ticket | null> {
264
+ let data: { issue: GqlIssue | null }
265
+ try {
266
+ data = await this.gql<{ issue: GqlIssue | null }>('get', GET_ISSUE_QUERY, { id })
267
+ } catch (error) {
268
+ // Linear's `issue(id:)` field is non-null in the live schema: an unknown
269
+ // identifier arrives as INPUT_ERROR / Entity not found rather than the
270
+ // nullable-looking canned `{ issue: null }` shape.
271
+ if (isEntityNotFound(error)) return null
272
+ throw error
273
+ }
274
+ return data.issue ? this.toTicket(data.issue) : null
275
+ }
276
+
277
+ /**
278
+ * Claim-before-launch (SPEC §12): refuse if the issue already sits in the
279
+ * claimed state or Linear classifies it as terminal, else move it there.
280
+ * The check-then-update pair is not transactional — a concurrent writer
281
+ * could claim between the two calls — which is acceptable because the
282
+ * dispatcher is the single writer (§12); the preflight still prevents claim
283
+ * from serving as an implicit reopen operation.
284
+ */
285
+ async claim(id: string): Promise<boolean> {
286
+ let data: {
287
+ issue: { id: string; state: { name: string; type: string } | null } | null
288
+ }
289
+ try {
290
+ data = await this.gql<{
291
+ issue: { id: string; state: { name: string; type: string } | null } | null
292
+ }>('claim', ISSUE_STATE_QUERY, { id })
293
+ } catch (error) {
294
+ if (isEntityNotFound(error)) return false
295
+ throw error
296
+ }
297
+ if (!data.issue) return false
298
+ if (
299
+ data.issue.state?.name === this.claimedState ||
300
+ RESOLVED_STATE_TYPES.has(data.issue.state?.type ?? '')
301
+ ) {
302
+ return false
303
+ }
304
+ this.issueIds.set(id, data.issue.id)
305
+ await this.updateState('claim', data.issue.id, this.claimedState)
306
+ return true
307
+ }
308
+
309
+ async comment(id: string, body: string): Promise<void> {
310
+ const issueId = await this.resolveIssueId('comment', id)
311
+ const data = await this.gql<{ commentCreate: { success: boolean } }>(
312
+ 'comment',
313
+ CREATE_COMMENT_MUTATION,
314
+ { issueId, body },
315
+ )
316
+ if (!data.commentCreate.success) {
317
+ throw new Error(`linear comment: commentCreate failed for "${id}"`)
318
+ }
319
+ }
320
+
321
+ async transition(id: string, state: string): Promise<void> {
322
+ const issueId = await this.resolveIssueId('transition', id)
323
+ await this.updateState('transition', issueId, state)
324
+ }
325
+
326
+ async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
327
+ const reservedId = opts.idempotencyKey
328
+ if (reservedId !== undefined && !linearReservedIssueIdSchema.safeParse(reservedId).success) {
329
+ throw new Error('linear create: idempotency key must be a UUID v4')
330
+ }
331
+ const team = await this.getTeamInfo('create')
332
+ const labelIds = await Promise.all(
333
+ (draft.labels ?? []).map((label) => this.ensureLabelId('create', label, team)),
334
+ )
335
+ const input: Record<string, unknown> = {
336
+ teamId: team.teamId,
337
+ title: draft.title,
338
+ description: draft.body,
339
+ labelIds,
340
+ }
341
+ const createState = opts.state ?? this.createState
342
+ if (createState !== undefined) {
343
+ const stateId = team.stateIds.get(createState)
344
+ if (!stateId) {
345
+ throw new Error(
346
+ `linear create: no workflow state "${createState}" in team ` +
347
+ `${this.teamKey} (known: ${[...team.stateIds.keys()].join(', ')})`,
348
+ )
349
+ }
350
+ input.stateId = stateId
351
+ }
352
+ if (reservedId !== undefined) input.id = reservedId
353
+
354
+ let issue: GqlIssue | null = null
355
+ let createError: unknown
356
+ try {
357
+ const data = await this.gql<{
358
+ issueCreate: { success: boolean; issue: GqlIssue | null }
359
+ }>('create', CREATE_ISSUE_MUTATION, { input })
360
+ if (data.issueCreate.success && data.issueCreate.issue !== null) {
361
+ issue = data.issueCreate.issue
362
+ }
363
+ } catch (error) {
364
+ if (reservedId === undefined) throw error
365
+ createError = error
366
+ }
367
+
368
+ if (issue === null && reservedId !== undefined) {
369
+ // The create may have committed before the caller/store crashed, or a
370
+ // prior attempt may have stopped while recording relations. Adopt the
371
+ // exact reserved issue and send it through the same blocker completion
372
+ // path as a fresh create.
373
+ issue = await this.adoptCreatedIssue(reservedId)
374
+ }
375
+ if (issue === null) {
376
+ if (createError !== undefined) throw createError
377
+ throw new Error(`linear create: issueCreate failed for "${draft.title}"`)
378
+ }
379
+
380
+ return this.completeCreateBlockers(issue, draft.blockedBy ?? [], reservedId !== undefined)
381
+ }
382
+
383
+ async update(id: string, patch: TicketUpdate): Promise<void> {
384
+ const validated = validateTicketUpdate(patch)
385
+ const issueId = await this.resolveIssueId('update', id)
386
+ const input: Record<string, unknown> = {}
387
+
388
+ if (validated.title !== undefined) input.title = validated.title
389
+ if (validated.body !== undefined) input.description = validated.body
390
+ if (validated.labels !== undefined) {
391
+ if (validated.labels.length === 0) {
392
+ input.labelIds = []
393
+ } else {
394
+ const team = await this.getTeamInfo('update')
395
+ input.labelIds = await Promise.all(
396
+ validated.labels.map((label) => this.ensureLabelId('update', label, team)),
397
+ )
398
+ }
399
+ }
400
+
401
+ const data = await this.gql<{ issueUpdate: { success: boolean } }>(
402
+ 'update',
403
+ UPDATE_ISSUE_MUTATION,
404
+ { id: issueId, input },
405
+ )
406
+ if (!data.issueUpdate.success) {
407
+ throw new Error(`linear update: issueUpdate failed for "${id}"`)
408
+ }
409
+ }
410
+
411
+ async addBlocker(id: string, blockerId: string): Promise<void> {
412
+ const target = await this.lookupIssue('addBlocker', id)
413
+ if (id === blockerId) {
414
+ throw new Error(`linear addBlocker: ticket "${id}" cannot block itself`)
415
+ }
416
+
417
+ if (
418
+ blockerRelationsOf(target).some(
419
+ (relation) => relation.issue?.identifier === blockerId || relation.issue?.id === blockerId,
420
+ )
421
+ ) {
422
+ return
423
+ }
424
+
425
+ const targetId = this.requiredIssueId('addBlocker', id, target)
426
+ const blockerUuid = await this.resolveIssueId('addBlocker', blockerId)
427
+ // Identifiers and UUIDs are both accepted by Linear's issue lookup. Catch
428
+ // aliases that refer to the same issue, not only equal CLI strings.
429
+ if (targetId === blockerUuid) {
430
+ throw new Error(`linear addBlocker: ticket "${id}" cannot block itself`)
431
+ }
432
+
433
+ const data = await this.gql<{
434
+ issueRelationCreate: { success: boolean }
435
+ }>('addBlocker', CREATE_RELATION_MUTATION, {
436
+ issueId: blockerUuid,
437
+ relatedIssueId: targetId,
438
+ })
439
+ if (!data.issueRelationCreate.success) {
440
+ throw new Error(
441
+ `linear addBlocker: issueRelationCreate failed — "${blockerId}" was ` +
442
+ `not recorded as blocking "${id}"`,
443
+ )
444
+ }
445
+ }
446
+
447
+ async removeBlocker(id: string, blockerId: string): Promise<void> {
448
+ const target = await this.lookupIssue('removeBlocker', id)
449
+ const matches = blockerRelationsOf(target).filter(
450
+ (relation) => relation.issue?.identifier === blockerId || relation.issue?.id === blockerId,
451
+ )
452
+ if (matches.length === 0) return
453
+
454
+ // Validate every deletion identity before the first mutation so a malformed
455
+ // provider projection cannot produce an avoidable partial removal.
456
+ const relationIds = matches.map((relation) => {
457
+ if (relation.id === undefined) {
458
+ throw new Error(
459
+ `linear removeBlocker: relation for "${blockerId}" blocking "${id}" has no id`,
460
+ )
461
+ }
462
+ return relation.id
463
+ })
464
+ for (const relationId of relationIds) {
465
+ const data = await this.gql<{
466
+ issueRelationDelete: { success: boolean }
467
+ }>('removeBlocker', DELETE_RELATION_MUTATION, { id: relationId })
468
+ if (!data.issueRelationDelete.success) {
469
+ throw new Error(
470
+ `linear removeBlocker: issueRelationDelete failed for relation "${relationId}" ` +
471
+ `("${blockerId}" blocking "${id}")`,
472
+ )
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Dependency nodes (§13). One query per id — Linear's `IssueFilter` has no
479
+ * identifier-`in` filter, and the id sets here are small (the ready set's
480
+ * blockers). Deliberately uncached: a blocker completing between ticks must
481
+ * be visible on the very next pass, so only the identifier→UUID map (which
482
+ * never changes) is cached.
483
+ */
484
+ async dependencyStates(ids: string[]): Promise<DependencyState[]> {
485
+ const states: DependencyState[] = []
486
+ for (const id of ids) {
487
+ let data: { issue: GqlIssue | null }
488
+ try {
489
+ data = await this.gql<{ issue: GqlIssue | null }>('dependencyStates', GET_ISSUE_QUERY, {
490
+ id,
491
+ })
492
+ } catch (error) {
493
+ // An unknown identifier is a MISSING dependency, not a failed check:
494
+ // the dispatcher must be able to say "AUT-99 does not exist" rather
495
+ // than bailing out of the ticket's whole dependency evaluation.
496
+ if (isEntityNotFound(error)) {
497
+ states.push({ id, exists: false, resolved: false, blockedBy: [] })
498
+ continue
499
+ }
500
+ throw error
501
+ }
502
+ if (!data.issue) {
503
+ states.push({ id, exists: false, resolved: false, blockedBy: [] })
504
+ continue
505
+ }
506
+ states.push({
507
+ id,
508
+ exists: true,
509
+ resolved: RESOLVED_STATE_TYPES.has(data.issue.state?.type ?? ''),
510
+ blockedBy: blockersOf(data.issue),
511
+ })
512
+ }
513
+ return states
514
+ }
515
+
516
+ // ── Plumbing ───────────────────────────────────────────────────────────────
517
+
518
+ private async lookupIssue(operation: string, id: string): Promise<GqlIssue> {
519
+ let data: { issue: GqlIssue | null }
520
+ try {
521
+ data = await this.gql<{ issue: GqlIssue | null }>(operation, GET_ISSUE_QUERY, { id })
522
+ } catch (error) {
523
+ if (isEntityNotFound(error)) {
524
+ throw new Error(`linear ${operation}: unknown ticket "${id}"`)
525
+ }
526
+ throw error
527
+ }
528
+ if (data.issue === null) {
529
+ throw new Error(`linear ${operation}: unknown ticket "${id}"`)
530
+ }
531
+
532
+ if (data.issue.id !== undefined) {
533
+ this.issueIds.set(id, data.issue.id)
534
+ this.issueIds.set(data.issue.identifier, data.issue.id)
535
+ for (const relation of data.issue.inverseRelations?.nodes ?? []) {
536
+ if (relation.issue?.id !== undefined) {
537
+ this.issueIds.set(relation.issue.identifier, relation.issue.id)
538
+ }
539
+ }
540
+ }
541
+ return data.issue
542
+ }
543
+
544
+ private requiredIssueId(operation: string, requestedId: string, issue: GqlIssue): string {
545
+ if (issue.id === undefined) {
546
+ throw new Error(`linear ${operation}: ticket "${requestedId}" response has no issue id`)
547
+ }
548
+ return issue.id
549
+ }
550
+
551
+ /** `issueCreate` and relation writes are separate mutations. Both a fresh
552
+ * response and a reserved-id adoption must leave this method only after all
553
+ * requested blockers are present; retries skip relations already observed. */
554
+ private async completeCreateBlockers(
555
+ issue: GqlIssue,
556
+ requestedIds: string[],
557
+ retryableById: boolean,
558
+ ): Promise<Ticket> {
559
+ const requested = [...new Set(requestedIds)]
560
+ if (requested.length === 0) return this.toTicket(issue)
561
+
562
+ const existing = blockerRelationsOf(issue)
563
+ const recorded = requested.filter((id) =>
564
+ existing.some((relation) => relation.issue?.identifier === id || relation.issue?.id === id),
565
+ )
566
+ try {
567
+ const createdId = issue.id
568
+ if (createdId === undefined) {
569
+ throw new Error('linear create: issueCreate returned no id — cannot record blockers')
570
+ }
571
+ this.issueIds.set(issue.identifier, createdId)
572
+ for (const relation of existing) {
573
+ if (relation.issue?.id !== undefined) {
574
+ this.issueIds.set(relation.issue.identifier, relation.issue.id)
575
+ }
576
+ }
577
+ for (const blockerId of requested) {
578
+ if (recorded.includes(blockerId)) continue
579
+ // Direction matters and is the inverse of how it reads aloud: the
580
+ // BLOCKER is `issueId` and the new issue is `relatedIssueId`, because
581
+ // Linear's `blocks` relation reads "issueId blocks relatedIssueId".
582
+ const blockerUuid = await this.resolveIssueId('create', blockerId)
583
+ const relation = await this.gql<{
584
+ issueRelationCreate: { success: boolean }
585
+ }>('create', CREATE_RELATION_MUTATION, {
586
+ issueId: blockerUuid,
587
+ relatedIssueId: createdId,
588
+ })
589
+ if (!relation.issueRelationCreate.success) {
590
+ throw new Error(
591
+ `linear create: issueRelationCreate failed — "${blockerId}" was ` +
592
+ `not recorded as blocking "${issue.identifier}"`,
593
+ )
594
+ }
595
+ recorded.push(blockerId)
596
+ }
597
+ } catch (error) {
598
+ throw blockerRecordingError(issue, requested, recorded, error, retryableById)
599
+ }
600
+
601
+ // The issue projection predates newly created relations. Preserve native
602
+ // existing blockers and report the requested set now known to be recorded.
603
+ const blockedBy = [...new Set([...blockersOf(issue), ...requested])]
604
+ return { ...this.toTicket(issue), ...(blockedBy.length > 0 ? { blockedBy } : {}) }
605
+ }
606
+
607
+ private async adoptCreatedIssue(id: string): Promise<GqlIssue | null> {
608
+ try {
609
+ const adopted = await this.gql<{ issue: GqlIssue | null }>('create-adopt', GET_ISSUE_QUERY, {
610
+ id,
611
+ })
612
+ return adopted.issue
613
+ } catch {
614
+ // Preserve the original create failure; an adoption probe is recovery,
615
+ // not a reason to hide the operation that actually failed.
616
+ return null
617
+ }
618
+ }
619
+
620
+ private async gql<T>(
621
+ operation: string,
622
+ query: string,
623
+ variables: Record<string, unknown>,
624
+ ): Promise<T> {
625
+ const response = await this.fetchFn(LINEAR_API_URL, {
626
+ method: 'POST',
627
+ headers: {
628
+ Authorization: this.apiKey,
629
+ 'Content-Type': 'application/json',
630
+ },
631
+ body: JSON.stringify({ query, variables }),
632
+ })
633
+ if (!response.ok) {
634
+ throw new Error(`linear ${operation}: HTTP ${response.status}`)
635
+ }
636
+ const payload = (await response.json()) as {
637
+ data?: T | null
638
+ errors?: GqlError[]
639
+ }
640
+ if (payload.errors && payload.errors.length > 0) {
641
+ const messages = payload.errors.map((e) => e.message).join('; ')
642
+ throw new LinearGqlError(`linear ${operation}: GraphQL errors — ${messages}`, payload.errors)
643
+ }
644
+ if (payload.data === undefined || payload.data === null) {
645
+ throw new Error(`linear ${operation}: response has no data`)
646
+ }
647
+ return payload.data
648
+ }
649
+
650
+ private async resolveIssueId(operation: string, id: string): Promise<string> {
651
+ const cached = this.issueIds.get(id)
652
+ if (cached) return cached
653
+ let data: { issue: { id: string } | null }
654
+ try {
655
+ data = await this.gql<{ issue: { id: string } | null }>(operation, RESOLVE_ISSUE_QUERY, {
656
+ id,
657
+ })
658
+ } catch (error) {
659
+ // Live Linear raises a GraphQL error for an unknown identifier rather
660
+ // than returning null — surface this adapter's own actionable message
661
+ // either way, instead of leaking `Entity not found: Issue`.
662
+ if (isEntityNotFound(error)) {
663
+ throw new Error(`linear ${operation}: unknown ticket "${id}"`)
664
+ }
665
+ throw error
666
+ }
667
+ if (!data.issue) {
668
+ throw new Error(`linear ${operation}: unknown ticket "${id}"`)
669
+ }
670
+ this.issueIds.set(id, data.issue.id)
671
+ return data.issue.id
672
+ }
673
+
674
+ /**
675
+ * Resolve a provider label registry entry at the write boundary. Known ids
676
+ * stay on the team cache's fast path; simultaneous requests on one adapter
677
+ * share a mutation, while cross-instance races reconcile by exact name.
678
+ */
679
+ private async ensureLabelId(
680
+ operation: string,
681
+ label: string,
682
+ team: LinearTeamInfo,
683
+ ): Promise<string> {
684
+ const known = team.labelIds.get(label)
685
+ if (known !== undefined) return known
686
+
687
+ const pending = this.pendingLabelCreates.get(label)
688
+ if (pending !== undefined) return pending
689
+
690
+ const creating = this.createLabel(operation, label, team)
691
+ this.pendingLabelCreates.set(label, creating)
692
+ try {
693
+ return await creating
694
+ } finally {
695
+ if (this.pendingLabelCreates.get(label) === creating) {
696
+ this.pendingLabelCreates.delete(label)
697
+ }
698
+ }
699
+ }
700
+
701
+ private async createLabel(
702
+ operation: string,
703
+ label: string,
704
+ team: LinearTeamInfo,
705
+ ): Promise<string> {
706
+ try {
707
+ const data = await this.gql<{
708
+ issueLabelCreate: { success: boolean; issueLabel: GqlIssueLabel | null }
709
+ }>(`${operation}-label`, CREATE_LABEL_MUTATION, {
710
+ input: { name: label, teamId: team.teamId },
711
+ })
712
+ const created = data.issueLabelCreate.issueLabel
713
+ if (!data.issueLabelCreate.success || created === null || created.name !== label) {
714
+ throw new Error(`linear ${operation}: issueLabelCreate failed for "${label}"`)
715
+ }
716
+ team.labelIds.set(label, created.id)
717
+ return created.id
718
+ } catch (error) {
719
+ // Another process may have won after our cached team-label snapshot.
720
+ // Only an exact provider lookup can convert the failed mutation into a
721
+ // success; otherwise retain the original mutation failure verbatim.
722
+ const winner = await this.findExactTeamLabel(operation, label, team.teamId)
723
+ if (winner !== null) {
724
+ team.labelIds.set(label, winner.id)
725
+ return winner.id
726
+ }
727
+ throw error
728
+ }
729
+ }
730
+
731
+ private async findExactTeamLabel(
732
+ operation: string,
733
+ label: string,
734
+ teamId: string,
735
+ ): Promise<GqlIssueLabel | null> {
736
+ try {
737
+ const data = await this.gql<{
738
+ team: { labels: { nodes: GqlIssueLabel[] } } | null
739
+ }>(`${operation}-label-race`, EXACT_TEAM_LABEL_QUERY, { teamId, name: label })
740
+ return data.team?.labels.nodes.find((candidate) => candidate.name === label) ?? null
741
+ } catch {
742
+ return null
743
+ }
744
+ }
745
+
746
+ /** State/label ids by name and the team id — fetched once, cached. */
747
+ private async getTeamInfo(operation: string): Promise<LinearTeamInfo> {
748
+ if (this.teamInfo) return this.teamInfo
749
+ if (this.teamInfoLoad === undefined) {
750
+ this.teamInfoLoad = (async () => {
751
+ const data = await this.gql<GqlTeamInfo>(operation, TEAM_INFO_QUERY, {
752
+ teamKey: this.teamKey,
753
+ })
754
+ const team = data.teams.nodes[0]
755
+ if (!team) {
756
+ throw new Error(`linear ${operation}: no team with key "${this.teamKey}"`)
757
+ }
758
+ return {
759
+ teamId: team.id,
760
+ stateIds: new Map(team.states.nodes.map((state) => [state.name, state.id])),
761
+ labelIds: new Map(team.labels.nodes.map((label) => [label.name, label.id])),
762
+ }
763
+ })()
764
+ }
765
+
766
+ const loading = this.teamInfoLoad
767
+ try {
768
+ this.teamInfo = await loading
769
+ return this.teamInfo
770
+ } finally {
771
+ if (this.teamInfoLoad === loading) this.teamInfoLoad = undefined
772
+ }
773
+ }
774
+
775
+ private async updateState(operation: string, issueId: string, stateName: string): Promise<void> {
776
+ const team = await this.getTeamInfo(operation)
777
+ const stateId = team.stateIds.get(stateName)
778
+ if (!stateId) {
779
+ throw new Error(
780
+ `linear ${operation}: no workflow state "${stateName}" in team ` +
781
+ `${this.teamKey} (known: ${[...team.stateIds.keys()].join(', ')})`,
782
+ )
783
+ }
784
+ const data = await this.gql<{ issueUpdate: { success: boolean } }>(
785
+ operation,
786
+ UPDATE_STATE_MUTATION,
787
+ { id: issueId, stateId },
788
+ )
789
+ if (!data.issueUpdate.success) {
790
+ throw new Error(`linear ${operation}: issueUpdate to "${stateName}" failed`)
791
+ }
792
+ }
793
+
794
+ private toTicket(issue: GqlIssue): Ticket {
795
+ const blockedBy = blockersOf(issue)
796
+ return {
797
+ ref: {
798
+ source: this.name,
799
+ id: issue.identifier,
800
+ url: issue.url,
801
+ title: issue.title,
802
+ },
803
+ ...(issue.id !== undefined ? { creationKey: issue.id } : {}),
804
+ title: issue.title,
805
+ body: issue.description ?? '',
806
+ state: issue.state?.name,
807
+ labels: issue.labels.nodes.map((label) => label.name),
808
+ ...(blockedBy.length > 0 ? { blockedBy } : {}),
809
+ }
810
+ }
811
+ }