@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.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- 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
|
+
}
|