@h-rig/linear-plugin 0.0.6-alpha.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # @rig/linear-plugin
2
+
3
+ Linear as a Rig task source — and the canonical example of a plugin that
4
+ adapts an **external HTTP API** into Rig's task-source contract.
5
+
6
+ The two first-party adapters in `@rig/standard-plugin` cover a CLI wrapper
7
+ (`github-issues` shells out to `gh`) and the local filesystem (`files`). This
8
+ package covers the third shape you'll actually build most often: a remote
9
+ API spoken over HTTP, with credentials, pagination, write-back mutations, and
10
+ tests that never touch the network. If you are writing a Jira/Asana/Shortcut/
11
+ internal-queue adapter, copy this package.
12
+
13
+ ```
14
+ packages/linear-plugin/
15
+ ├── package.json ← workspace package, mirrors standard-plugin
16
+ ├── tsconfig.json
17
+ └── src/
18
+ ├── index.ts ← definePlugin wiring (metadata + runtime channels)
19
+ ├── linear-issues-source.ts ← the adapter: GraphQL client, mapping, write-back
20
+ ├── fixture-transport.ts ← fixture-driven fake transport for tests
21
+ ├── fixtures.ts ← canned Linear GraphQL payloads
22
+ ├── contract.test.ts ← @rig/plugin-testkit contract suite + factory wiring
23
+ └── linear-issues-source.test.ts ← mapping, lifecycle, pagination, error surfaces
24
+ ```
25
+
26
+ ## Using it
27
+
28
+ ```typescript
29
+ // rig.config.ts
30
+ import { defineConfig } from "@rig/core";
31
+ import linear from "@rig/linear-plugin";
32
+
33
+ export default defineConfig({
34
+ plugins: [linear()],
35
+ taskSource: {
36
+ kind: "linear",
37
+ options: {
38
+ teamKey: "ENG", // required — the "ENG" in ENG-123
39
+ states: ["Todo", "In Progress"], // optional: workflow state names
40
+ assignee: "alex@example.com", // optional: email or display name
41
+ labels: ["rig"], // optional: issue must carry ALL listed labels
42
+ // pageSize: 50, // optional: issues per GraphQL page (max 250)
43
+ // listLimit: 1000, // optional: fail-loud ceiling, no silent truncation
44
+ },
45
+ },
46
+ // ... workspace, runtime, etc.
47
+ });
48
+ ```
49
+
50
+ Authentication comes from the environment, never from the committed config:
51
+
52
+ ```bash
53
+ export LINEAR_API_KEY=lin_api_... # personal API key (Settings → API)
54
+ ```
55
+
56
+ Personal API keys (`lin_api_…`) are sent raw in the `Authorization` header;
57
+ OAuth access tokens (`lin_oauth_…`) are automatically sent as `Bearer <token>`.
58
+ You can also pass the key programmatically: `linear({ apiKey })`.
59
+
60
+ ## Anatomy of an external-API task source
61
+
62
+ ### 1. Two channels, one `definePlugin` call
63
+
64
+ Rig plugins declare *what they contribute* as Effect-Schema-validated metadata
65
+ and ship *executable code* on a sibling runtime channel — schemas can't carry
66
+ functions. `src/index.ts`:
67
+
68
+ ```typescript
69
+ export default function linearPlugin(opts: LinearPluginOptions = {}) {
70
+ return definePlugin(
71
+ // Channel 1: metadata. Validated against the RigPlugin schema at load time.
72
+ {
73
+ name: "rig-linear",
74
+ version: "0.1.0",
75
+ contributes: {
76
+ taskSources: [{ id: "linear:issues", kind: "linear", description: "…" }],
77
+ },
78
+ },
79
+ // Channel 2: runtime. The factory turns a TaskSourceConfig into a live source.
80
+ {
81
+ taskSources: [{ id: "linear:issues", kind: "linear", factory(config) { … } }],
82
+ },
83
+ );
84
+ }
85
+ ```
86
+
87
+ `definePlugin` enforces referential integrity: every factory must match a
88
+ metadata entry by `id` with the same `kind`, and (once a plugin opts into the
89
+ runtime channel) every metadata entry must be backed by a factory. Drift
90
+ throws at config-load time, not at dispatch time.
91
+
92
+ At boot, `buildTaskSourceRegistry(config, pluginHost)` in `@rig/runtime` reads
93
+ `config.taskSource.kind` (`"linear"`), resolves the factory through the plugin
94
+ host (which also rejects duplicate kinds across plugins), and instantiates the
95
+ source.
96
+
97
+ ### 2. Plugin-specific config rides in `options`
98
+
99
+ `TaskSourceConfig`'s top-level fields (`path`, `owner`, `repo`, …) are
100
+ reserved for the built-in adapters. Everything Linear-specific lives in the
101
+ documented `options` extension bag — the factory pulls its own fields out and
102
+ validates them with actionable errors:
103
+
104
+ ```typescript
105
+ const teamKey = optionString(config, "teamKey");
106
+ if (!teamKey) {
107
+ throw new Error('task source linear: options.teamKey is required — …');
108
+ }
109
+ ```
110
+
111
+ Process-level concerns (API key, endpoint override, transport injection)
112
+ arrive through the plugin function's own options instead, because they belong
113
+ to the environment, not to the committed project config.
114
+
115
+ ### 3. An injectable transport is the whole testing story
116
+
117
+ The adapter never calls `fetch` directly. It calls a fetch-*shaped* function
118
+ it was constructed with:
119
+
120
+ ```typescript
121
+ export type LinearTransport = (
122
+ url: string,
123
+ init: { method: "POST"; headers: Record<string, string>; body: string },
124
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
125
+
126
+ const transport: LinearTransport = opts.transport ?? fetch; // global fetch satisfies it
127
+ ```
128
+
129
+ That one seam is why this package's test suite makes **zero live API calls**.
130
+ `fixture-transport.ts` exports `createFixtureTransport(routes)`, which routes
131
+ requests by GraphQL *operation name* (every query/mutation in the adapter is
132
+ deliberately named: `RigListIssues`, `RigTeamStates`, `RigIssueUpdate`, …),
133
+ records every call for payload assertions, and fails loudly on any operation
134
+ a test didn't explicitly allow:
135
+
136
+ ```typescript
137
+ const fixture = createFixtureTransport({
138
+ RigListIssues: () => listIssuesPage(issueFixtures),
139
+ RigIssueUpdate: () => ({ issueUpdate: { success: true } }),
140
+ });
141
+ const source = createLinearIssuesTaskSource({
142
+ teamKey: "ENG",
143
+ apiKey: "lin_api_test",
144
+ transport: fixture.transport,
145
+ });
146
+ // …act…
147
+ expect(fixture.callsFor("RigIssueUpdate")[0].variables.input).toEqual({ stateId: … });
148
+ ```
149
+
150
+ `graphqlErrors([...])` and `httpFailure(status, body)` simulate the two
151
+ failure layers a GraphQL API has (200-with-errors vs. non-200 HTTP).
152
+
153
+ ### 4. Mapping external records to `TaskRecord`
154
+
155
+ `TaskRecord` requires `id`, `deps`, `status` and is open-ended beyond that.
156
+ The mapping decisions here:
157
+
158
+ | TaskRecord field | Source |
159
+ |---|---|
160
+ | `id` | Linear identifier (`ENG-123`) — human-facing, like GitHub issue numbers |
161
+ | `status` | Linear workflow-state **type** (see table below) |
162
+ | `title`, `body`/`description` | issue title/description (`body` is Rig convention; `description` mirrors Linear) |
163
+ | `priority`, `priorityLabel` | Linear priority (0–4) and its label |
164
+ | `labels` | label names; `scope:`/`role:`/`validator:` prefixes get the same treatment as the GitHub adapter |
165
+ | `externalRef` | `linear:<identifier>` |
166
+ | `sourceIssueId` | the identifier |
167
+ | `linearIssueId` | Linear's internal UUID — kept so mutations can skip a lookup |
168
+ | `url`, `raw` | issue URL, full raw node |
169
+
170
+ Status mapping is keyed off Linear's fixed state-type taxonomy, not the
171
+ user-renamable state names:
172
+
173
+ | Linear state type | Rig status |
174
+ |---|---|
175
+ | `triage`, `backlog`, `unstarted` | `ready` |
176
+ | `started` | `in_progress` |
177
+ | `completed` | `completed` |
178
+ | `canceled` | `cancelled` |
179
+ | anything unknown | `open` (fail-soft) |
180
+
181
+ ### 5. Lifecycle write-back
182
+
183
+ Rig drives the source through `updateStatus(id, status)` and
184
+ `updateTask(id, update)` (status/title/body/comment/metadata). The Linear
185
+ implementation mirrors the GitHub adapter's responsibilities with GraphQL
186
+ mutations:
187
+
188
+ - **Status** → resolve the team's workflow states (`RigTeamStates`, cached per
189
+ source instance), pick the lowest-`position` state whose `type` matches the
190
+ Rig status (`in_progress` → `started`, `closed`/`completed` → `completed`,
191
+ `cancelled` → `canceled`, `ready` → `unstarted`), then `issueUpdate` with
192
+ `stateId`. Statuses Linear has no workflow concept for (`blocked`,
193
+ `failed`, `needs_attention`) deliberately leave the state untouched — they
194
+ surface through comments instead of mangling the board.
195
+ - **Title / body / metadata** → folded into the same single `issueUpdate`
196
+ call. `update.metadata` is rendered into the Rig-owned
197
+ `<!-- rig:metadata:start/end -->` block (same markers as the GitHub
198
+ adapter, so operator surfaces parse both identically), replacing a previous
199
+ block in the existing description when present.
200
+ - **Comment** → `commentCreate` after the update.
201
+ - Task ids are accepted as either the identifier (`ENG-123`) or Linear's UUID;
202
+ identifiers are resolved with one extra `issue(id:)` query.
203
+
204
+ Every write fires the optional `onTaskChanged` callback so the host can
205
+ refresh snapshots — same pattern as the GitHub adapter.
206
+
207
+ ### 6. Error surfaces are part of the contract
208
+
209
+ - No key configured → `"Linear task source: no API key configured. Pass apiKey in plugin options or set the LINEAR_API_KEY environment variable."` — thrown before any request.
210
+ - Bad token (HTTP 401 or a GraphQL `AUTHENTICATION_ERROR`) → `"Linear API authentication failed (…). Check that the configured API key (options.apiKey / LINEAR_API_KEY) is a valid Linear key…"`.
211
+ - Other GraphQL errors → surfaced with the server's message.
212
+ - Non-200 HTTP → status + body excerpt.
213
+ - More matching issues than `listLimit` → a loud refusal to truncate, telling
214
+ you exactly which knob to turn — never a silently short task list.
215
+
216
+ ## Running the tests
217
+
218
+ ```bash
219
+ cd packages/linear-plugin
220
+ bun test # 39 tests, no network
221
+ bunx tsc --noEmit # typecheck
222
+ ```
223
+
224
+ `contract.test.ts` runs `pluginContractTests` from `@rig/plugin-testkit`
225
+ (schema validity, namespaced ids, duplicate-id detection) plus factory-wiring
226
+ assertions; `linear-issues-source.test.ts` covers mapping, filters,
227
+ pagination, lifecycle mutation payloads, and every error surface above.
@@ -0,0 +1,56 @@
1
+ // @bun
2
+ // packages/linear-plugin/src/fixture-transport.ts
3
+ var HTTP_FAILURE = Symbol("linear-fixture-http-failure");
4
+ var GRAPHQL_ERRORS = Symbol("linear-fixture-graphql-errors");
5
+ function httpFailure(status, body) {
6
+ return { [HTTP_FAILURE]: true, status, body };
7
+ }
8
+ function graphqlErrors(errors) {
9
+ return { [GRAPHQL_ERRORS]: true, errors };
10
+ }
11
+ function isHttpFailure(value) {
12
+ return typeof value === "object" && value !== null && HTTP_FAILURE in value;
13
+ }
14
+ function isGraphQLErrors(value) {
15
+ return typeof value === "object" && value !== null && GRAPHQL_ERRORS in value;
16
+ }
17
+ function jsonResponse(status, body) {
18
+ return { ok: status >= 200 && status < 300, status, text: async () => body };
19
+ }
20
+ function createFixtureTransport(routes) {
21
+ const calls = [];
22
+ const transport = async (url, init) => {
23
+ const body = JSON.parse(init.body);
24
+ const operation = /(?:query|mutation)\s+([A-Za-z0-9_]+)/.exec(body.query)?.[1] ?? "<anonymous>";
25
+ const call = {
26
+ url,
27
+ authorization: init.headers["Authorization"],
28
+ operation,
29
+ query: body.query,
30
+ variables: body.variables ?? {}
31
+ };
32
+ calls.push(call);
33
+ const route = routes[operation];
34
+ if (!route) {
35
+ throw new Error(`createFixtureTransport: no fixture route for operation "${operation}"`);
36
+ }
37
+ const result = route(call.variables, call);
38
+ if (isHttpFailure(result)) {
39
+ return jsonResponse(result.status, result.body);
40
+ }
41
+ if (isGraphQLErrors(result)) {
42
+ return jsonResponse(200, JSON.stringify({ errors: result.errors }));
43
+ }
44
+ return jsonResponse(200, JSON.stringify({ data: result }));
45
+ };
46
+ return {
47
+ transport,
48
+ calls,
49
+ callsFor: (operation) => calls.filter((c) => c.operation === operation)
50
+ };
51
+ }
52
+ export {
53
+ httpFailure,
54
+ graphqlErrors,
55
+ createFixtureTransport
56
+ };
@@ -0,0 +1,155 @@
1
+ // @bun
2
+ // packages/linear-plugin/src/fixtures.ts
3
+ var TEAM_KEY = "ENG";
4
+ var STATE_IDS = {
5
+ triage: "11111111-1111-4111-8111-111111111101",
6
+ backlog: "11111111-1111-4111-8111-111111111102",
7
+ todo: "11111111-1111-4111-8111-111111111103",
8
+ inProgress: "11111111-1111-4111-8111-111111111104",
9
+ inReview: "11111111-1111-4111-8111-111111111105",
10
+ done: "11111111-1111-4111-8111-111111111106",
11
+ canceled: "11111111-1111-4111-8111-111111111107"
12
+ };
13
+ var teamStatesFixture = {
14
+ teams: {
15
+ nodes: [
16
+ {
17
+ id: "22222222-2222-4222-8222-222222222201",
18
+ key: TEAM_KEY,
19
+ states: {
20
+ nodes: [
21
+ { id: STATE_IDS.triage, name: "Triage", type: "triage", position: 0 },
22
+ { id: STATE_IDS.backlog, name: "Backlog", type: "backlog", position: 1 },
23
+ { id: STATE_IDS.todo, name: "Todo", type: "unstarted", position: 2 },
24
+ { id: STATE_IDS.inReview, name: "In Review", type: "started", position: 4 },
25
+ { id: STATE_IDS.inProgress, name: "In Progress", type: "started", position: 3 },
26
+ { id: STATE_IDS.done, name: "Done", type: "completed", position: 5 },
27
+ { id: STATE_IDS.canceled, name: "Canceled", type: "canceled", position: 6 }
28
+ ]
29
+ }
30
+ }
31
+ ]
32
+ }
33
+ };
34
+ var ISSUE_UUIDS = {
35
+ eng1: "33333333-3333-4333-8333-333333333301",
36
+ eng2: "33333333-3333-4333-8333-333333333302",
37
+ eng3: "33333333-3333-4333-8333-333333333303",
38
+ eng4: "33333333-3333-4333-8333-333333333304",
39
+ eng5: "33333333-3333-4333-8333-333333333305",
40
+ eng6: "33333333-3333-4333-8333-333333333306",
41
+ eng7: "33333333-3333-4333-8333-333333333307"
42
+ };
43
+ var issueFixtures = [
44
+ {
45
+ id: ISSUE_UUIDS.eng1,
46
+ identifier: "ENG-1",
47
+ title: "Untriaged crash report",
48
+ description: null,
49
+ priority: 0,
50
+ priorityLabel: "No priority",
51
+ url: "https://linear.app/acme/issue/ENG-1",
52
+ state: { id: STATE_IDS.triage, name: "Triage", type: "triage" },
53
+ labels: { nodes: [] },
54
+ assignee: null
55
+ },
56
+ {
57
+ id: ISSUE_UUIDS.eng2,
58
+ identifier: "ENG-2",
59
+ title: "Someday refactor",
60
+ description: "Park it in the backlog.",
61
+ priority: 4,
62
+ priorityLabel: "Low",
63
+ url: "https://linear.app/acme/issue/ENG-2",
64
+ state: { id: STATE_IDS.backlog, name: "Backlog", type: "backlog" },
65
+ labels: { nodes: [{ name: "tech-debt" }] },
66
+ assignee: null
67
+ },
68
+ {
69
+ id: ISSUE_UUIDS.eng3,
70
+ identifier: "ENG-3",
71
+ title: "Add retry to webhook dispatcher",
72
+ description: `Retries with backoff.
73
+
74
+ See the incident doc.`,
75
+ priority: 2,
76
+ priorityLabel: "High",
77
+ url: "https://linear.app/acme/issue/ENG-3",
78
+ state: { id: STATE_IDS.todo, name: "Todo", type: "unstarted" },
79
+ labels: {
80
+ nodes: [
81
+ { name: "rig" },
82
+ { name: "scope:packages/server/**" },
83
+ { name: "role:builder" },
84
+ { name: "validator:typecheck" }
85
+ ]
86
+ },
87
+ assignee: { name: "Alex", email: "alex@example.com" }
88
+ },
89
+ {
90
+ id: ISSUE_UUIDS.eng4,
91
+ identifier: "ENG-4",
92
+ title: "Migrate auth tokens",
93
+ description: "In flight.",
94
+ priority: 1,
95
+ priorityLabel: "Urgent",
96
+ url: "https://linear.app/acme/issue/ENG-4",
97
+ state: { id: STATE_IDS.inProgress, name: "In Progress", type: "started" },
98
+ labels: { nodes: [{ name: "rig" }] },
99
+ assignee: { name: "Alex", email: "alex@example.com" }
100
+ },
101
+ {
102
+ id: ISSUE_UUIDS.eng5,
103
+ identifier: "ENG-5",
104
+ title: "Ship the dashboard",
105
+ description: "Done last sprint.",
106
+ priority: 3,
107
+ priorityLabel: "Medium",
108
+ url: "https://linear.app/acme/issue/ENG-5",
109
+ state: { id: STATE_IDS.done, name: "Done", type: "completed" },
110
+ labels: { nodes: [] },
111
+ assignee: null
112
+ },
113
+ {
114
+ id: ISSUE_UUIDS.eng6,
115
+ identifier: "ENG-6",
116
+ title: "Dead-end spike",
117
+ description: null,
118
+ priority: null,
119
+ priorityLabel: null,
120
+ url: "https://linear.app/acme/issue/ENG-6",
121
+ state: { id: STATE_IDS.canceled, name: "Canceled", type: "canceled" },
122
+ labels: { nodes: [] },
123
+ assignee: null
124
+ },
125
+ {
126
+ id: ISSUE_UUIDS.eng7,
127
+ identifier: "ENG-7",
128
+ title: "Issue in a state type this adapter has never heard of",
129
+ description: null,
130
+ priority: null,
131
+ priorityLabel: null,
132
+ url: "https://linear.app/acme/issue/ENG-7",
133
+ state: { id: "11111111-1111-4111-8111-111111111199", name: "Weird", type: "someday-new-type" },
134
+ labels: { nodes: [] },
135
+ assignee: null
136
+ }
137
+ ];
138
+ function listIssuesPage(issues, pageInfo = { hasNextPage: false, endCursor: null }) {
139
+ return { issues: { pageInfo, nodes: issues } };
140
+ }
141
+ function issueByIdentifier(identifier) {
142
+ const issue = issueFixtures.find((i) => i.identifier === identifier);
143
+ if (!issue)
144
+ throw new Error(`fixture issue not found: ${identifier}`);
145
+ return issue;
146
+ }
147
+ export {
148
+ teamStatesFixture,
149
+ listIssuesPage,
150
+ issueFixtures,
151
+ issueByIdentifier,
152
+ TEAM_KEY,
153
+ STATE_IDS,
154
+ ISSUE_UUIDS
155
+ };
@@ -0,0 +1,492 @@
1
+ // @bun
2
+ // packages/linear-plugin/src/index.ts
3
+ import { definePlugin } from "@rig/core";
4
+
5
+ // packages/linear-plugin/src/linear-issues-source.ts
6
+ var ISSUE_FIELDS = `
7
+ id
8
+ identifier
9
+ title
10
+ description
11
+ priority
12
+ priorityLabel
13
+ url
14
+ state { id name type }
15
+ labels { nodes { name } }
16
+ assignee { name email }`;
17
+ var LIST_ISSUES_QUERY = `query RigListIssues($filter: IssueFilter, $first: Int!, $after: String) {
18
+ issues(filter: $filter, first: $first, after: $after) {
19
+ pageInfo { hasNextPage endCursor }
20
+ nodes {${ISSUE_FIELDS}
21
+ }
22
+ }
23
+ }`;
24
+ var GET_ISSUE_QUERY = `query RigGetIssue($id: String!) {
25
+ issue(id: $id) {${ISSUE_FIELDS}
26
+ }
27
+ }`;
28
+ var RESOLVE_ISSUE_QUERY = `query RigResolveIssue($id: String!) {
29
+ issue(id: $id) { id }
30
+ }`;
31
+ var ISSUE_DESCRIPTION_QUERY = `query RigIssueDescription($id: String!) {
32
+ issue(id: $id) { description }
33
+ }`;
34
+ var TEAM_STATES_QUERY = `query RigTeamStates($teamKey: String!) {
35
+ teams(filter: { key: { eq: $teamKey } }, first: 1) {
36
+ nodes {
37
+ id
38
+ key
39
+ states { nodes { id name type position } }
40
+ }
41
+ }
42
+ }`;
43
+ var ISSUE_UPDATE_MUTATION = `mutation RigIssueUpdate($id: String!, $input: IssueUpdateInput!) {
44
+ issueUpdate(id: $id, input: $input) { success }
45
+ }`;
46
+ var COMMENT_CREATE_MUTATION = `mutation RigCommentCreate($input: CommentCreateInput!) {
47
+ commentCreate(input: $input) { success }
48
+ }`;
49
+ function rigStatusForLinearStateType(stateType) {
50
+ switch (stateType) {
51
+ case "triage":
52
+ case "backlog":
53
+ case "unstarted":
54
+ return "ready";
55
+ case "started":
56
+ return "in_progress";
57
+ case "completed":
58
+ return "completed";
59
+ case "canceled":
60
+ return "cancelled";
61
+ default:
62
+ return "open";
63
+ }
64
+ }
65
+ function linearStateTypeForRigStatus(status) {
66
+ switch (status) {
67
+ case "ready":
68
+ return "unstarted";
69
+ case "in_progress":
70
+ case "under_review":
71
+ case "ci_fixing":
72
+ case "merging":
73
+ return "started";
74
+ case "closed":
75
+ case "completed":
76
+ return "completed";
77
+ case "cancelled":
78
+ return "canceled";
79
+ case "blocked":
80
+ case "failed":
81
+ case "needs_attention":
82
+ case "open":
83
+ return null;
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ var RIG_METADATA_START = "<!-- rig:metadata:start -->";
89
+ var RIG_METADATA_END = "<!-- rig:metadata:end -->";
90
+ function yamlScalar(value) {
91
+ if (Array.isArray(value)) {
92
+ return value.length === 0 ? "[]" : `
93
+ ${value.map((entry) => ` - ${String(entry)}`).join(`
94
+ `)}`;
95
+ }
96
+ if (value && typeof value === "object")
97
+ return JSON.stringify(value);
98
+ return String(value);
99
+ }
100
+ function escapeRegExp(value) {
101
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
+ }
103
+ function updateRigOwnedMetadataBlock(body, metadata) {
104
+ const rendered = [
105
+ RIG_METADATA_START,
106
+ ...Object.entries(metadata).map(([key, value]) => Array.isArray(value) ? `${key}:${yamlScalar(value)}` : `${key}: ${yamlScalar(value)}`),
107
+ RIG_METADATA_END
108
+ ].join(`
109
+ `);
110
+ const pattern = new RegExp(`${escapeRegExp(RIG_METADATA_START)}\\s*[\\s\\S]*?\\s*${escapeRegExp(RIG_METADATA_END)}`);
111
+ if (pattern.test(body))
112
+ return body.replace(pattern, rendered);
113
+ return body.trim().length > 0 ? `${body.trimEnd()}
114
+
115
+ ${rendered}
116
+ ` : `${rendered}
117
+ `;
118
+ }
119
+ function labelNamesFor(issue) {
120
+ return (issue.labels?.nodes ?? []).map((label) => label.name);
121
+ }
122
+ function issueToTask(issue) {
123
+ const labelNames = labelNamesFor(issue);
124
+ const scope = labelNames.filter((l) => l.startsWith("scope:")).map((l) => l.slice("scope:".length));
125
+ const roleLabel = labelNames.find((l) => l.startsWith("role:"));
126
+ const validators = labelNames.filter((l) => l.startsWith("validator:")).map((l) => l.slice("validator:".length));
127
+ const description = issue.description ?? "";
128
+ return {
129
+ id: issue.identifier,
130
+ deps: [],
131
+ status: rigStatusForLinearStateType(issue.state?.type),
132
+ title: issue.title,
133
+ body: description,
134
+ description,
135
+ ...scope.length > 0 ? { scope } : {},
136
+ ...roleLabel ? { role: roleLabel.slice("role:".length) } : {},
137
+ ...validators.length > 0 ? { validators } : {},
138
+ ...issue.priority !== null && issue.priority !== undefined ? { priority: issue.priority } : {},
139
+ ...issue.priorityLabel ? { priorityLabel: issue.priorityLabel } : {},
140
+ ...issue.url ? { url: issue.url } : {},
141
+ labels: labelNames,
142
+ externalRef: `linear:${issue.identifier}`,
143
+ sourceIssueId: issue.identifier,
144
+ linearIssueId: issue.id,
145
+ ...issue.state ? { stateName: issue.state.name, stateType: issue.state.type } : {},
146
+ raw: issue
147
+ };
148
+ }
149
+ function buildIssueFilter(opts) {
150
+ const filter = { team: { key: { eq: opts.teamKey } } };
151
+ if (opts.states && opts.states.length > 0) {
152
+ filter.state = { name: { in: [...opts.states] } };
153
+ }
154
+ const assignee = opts.assignee?.trim();
155
+ if (assignee) {
156
+ filter.assignee = assignee.includes("@") ? { email: { eq: assignee } } : { displayName: { eq: assignee } };
157
+ }
158
+ if (opts.labels && opts.labels.length > 0) {
159
+ filter.and = opts.labels.map((name) => ({ labels: { some: { name: { eq: name } } } }));
160
+ }
161
+ return filter;
162
+ }
163
+ var DEFAULT_ENDPOINT = "https://api.linear.app/graphql";
164
+ var DEFAULT_PAGE_SIZE = 50;
165
+ var DEFAULT_LIST_LIMIT = 1000;
166
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
167
+ function truncateForError(text) {
168
+ const clean = text.replace(/\s+/g, " ").trim();
169
+ return clean.length > 300 ? `${clean.slice(0, 300)}\u2026` : clean;
170
+ }
171
+ function isAuthErrorPayload(status, errors) {
172
+ if (status === 401)
173
+ return "HTTP 401";
174
+ for (const error of errors ?? []) {
175
+ const code = error.extensions?.code ?? "";
176
+ if (code === "AUTHENTICATION_ERROR" || /authentication/i.test(error.message ?? "")) {
177
+ return error.message ?? code;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+ function createGraphQLClient(opts) {
183
+ const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
184
+ const transport = opts.transport ?? fetch;
185
+ return {
186
+ async request(query, variables) {
187
+ const apiKey = (opts.apiKey ?? process.env.LINEAR_API_KEY)?.trim();
188
+ if (!apiKey) {
189
+ throw new Error("Linear task source: no API key configured. Pass apiKey in plugin options or set the LINEAR_API_KEY environment variable.");
190
+ }
191
+ const authorization = apiKey.startsWith("lin_oauth_") ? `Bearer ${apiKey}` : apiKey;
192
+ let response;
193
+ try {
194
+ response = await transport(endpoint, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ Authorization: authorization
199
+ },
200
+ body: JSON.stringify({ query, variables })
201
+ });
202
+ } catch (error) {
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ throw new Error(`Linear API request failed (network error): ${message}`, { cause: error });
205
+ }
206
+ const text = await response.text();
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(text);
210
+ } catch {
211
+ parsed = undefined;
212
+ }
213
+ const authError = isAuthErrorPayload(response.status, parsed?.errors);
214
+ if (authError) {
215
+ throw new Error(`Linear API authentication failed (${authError}). Check that the configured API key (options.apiKey / LINEAR_API_KEY) is a valid Linear key with access to team "${opts.teamKey}".`);
216
+ }
217
+ if (!response.ok) {
218
+ throw new Error(`Linear API request failed (HTTP ${response.status}): ${truncateForError(text)}`);
219
+ }
220
+ if (parsed?.errors && parsed.errors.length > 0) {
221
+ const messages = parsed.errors.map((e) => e.message ?? "unknown error").join("; ");
222
+ throw new Error(`Linear GraphQL error: ${messages}`);
223
+ }
224
+ if (!parsed || parsed.data === undefined || parsed.data === null) {
225
+ throw new Error(`Linear API returned no data: ${truncateForError(text)}`);
226
+ }
227
+ return parsed.data;
228
+ }
229
+ };
230
+ }
231
+ function createLinearIssuesTaskSource(opts) {
232
+ if (!opts.teamKey?.trim()) {
233
+ throw new Error("createLinearIssuesTaskSource: teamKey is required");
234
+ }
235
+ const teamKey = opts.teamKey.trim();
236
+ const client = createGraphQLClient(opts);
237
+ const pageSize = Math.min(250, Math.max(1, Math.trunc(opts.pageSize ?? DEFAULT_PAGE_SIZE)));
238
+ const listLimit = Math.max(1, Math.trunc(opts.listLimit ?? DEFAULT_LIST_LIMIT));
239
+ let cachedStates = null;
240
+ const fetchTeamStates = async () => {
241
+ if (cachedStates)
242
+ return cachedStates;
243
+ const data = await client.request(TEAM_STATES_QUERY, { teamKey });
244
+ const team = data.teams.nodes[0];
245
+ if (!team) {
246
+ throw new Error(`Linear team not found for key "${teamKey}" \u2014 check taskSource.options.teamKey.`);
247
+ }
248
+ cachedStates = team.states.nodes;
249
+ return cachedStates;
250
+ };
251
+ const resolveStateId = async (stateType) => {
252
+ const states = await fetchTeamStates();
253
+ const candidates = states.filter((s) => s.type === stateType).toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0));
254
+ const target = candidates[0];
255
+ if (!target) {
256
+ throw new Error(`Linear team "${teamKey}" has no workflow state of type "${stateType}" \u2014 cannot map this Rig status to a Linear state.`);
257
+ }
258
+ return target.id;
259
+ };
260
+ const resolveIssueUuid = async (taskId) => {
261
+ if (UUID_PATTERN.test(taskId))
262
+ return taskId;
263
+ const data = await client.request(RESOLVE_ISSUE_QUERY, { id: taskId });
264
+ if (!data.issue) {
265
+ throw new Error(`Linear issue not found: ${taskId}`);
266
+ }
267
+ return data.issue.id;
268
+ };
269
+ const fetchDescription = async (issueUuid) => {
270
+ const data = await client.request(ISSUE_DESCRIPTION_QUERY, { id: issueUuid });
271
+ return data.issue?.description ?? "";
272
+ };
273
+ const applyIssueUpdate = async (taskId, update) => {
274
+ const issueUuid = await resolveIssueUuid(taskId);
275
+ const input = {};
276
+ if (update.status) {
277
+ const stateType = linearStateTypeForRigStatus(update.status);
278
+ if (stateType)
279
+ input.stateId = await resolveStateId(stateType);
280
+ }
281
+ if (update.title?.trim())
282
+ input.title = update.title.trim();
283
+ const nextBody = update.metadata ? updateRigOwnedMetadataBlock(update.body ?? await fetchDescription(issueUuid), update.metadata) : update.body;
284
+ if (nextBody !== undefined)
285
+ input.description = nextBody;
286
+ if (Object.keys(input).length > 0) {
287
+ await client.request(ISSUE_UPDATE_MUTATION, {
288
+ id: issueUuid,
289
+ input
290
+ });
291
+ }
292
+ if (update.comment?.trim()) {
293
+ await client.request(COMMENT_CREATE_MUTATION, {
294
+ input: { issueId: issueUuid, body: update.comment }
295
+ });
296
+ }
297
+ };
298
+ const notifyTaskChanged = (id, status) => {
299
+ opts.onTaskChanged?.({ teamKey, id, ...status ? { status } : {}, reason: "linear-issue-updated" });
300
+ };
301
+ return {
302
+ id: "linear:issues",
303
+ kind: "linear",
304
+ async list() {
305
+ const filter = buildIssueFilter({ ...opts, teamKey });
306
+ const out = [];
307
+ let after = null;
308
+ for (;; ) {
309
+ const data = await client.request(LIST_ISSUES_QUERY, {
310
+ filter,
311
+ first: pageSize,
312
+ after
313
+ });
314
+ out.push(...data.issues.nodes.map(issueToTask));
315
+ if (out.length > listLimit) {
316
+ throw new Error(`Linear issue list for team ${teamKey} exceeded the configured limit (${listLimit}); refusing to silently truncate matching issues. Increase taskSource.options.listLimit or narrow states/labels/assignee.`);
317
+ }
318
+ if (!data.issues.pageInfo.hasNextPage)
319
+ break;
320
+ after = data.issues.pageInfo.endCursor ?? null;
321
+ if (!after)
322
+ break;
323
+ }
324
+ return out;
325
+ },
326
+ async get(id) {
327
+ try {
328
+ const data = await client.request(GET_ISSUE_QUERY, { id });
329
+ return data.issue ? issueToTask(data.issue) : undefined;
330
+ } catch (error) {
331
+ const message = error instanceof Error ? error.message : String(error);
332
+ if (/authentication|API key/i.test(message))
333
+ throw error;
334
+ if (/not found|entity not found/i.test(message))
335
+ return;
336
+ throw error;
337
+ }
338
+ },
339
+ async updateStatus(id, status) {
340
+ const stateType = linearStateTypeForRigStatus(status);
341
+ if (stateType) {
342
+ const issueUuid = await resolveIssueUuid(id);
343
+ const stateId = await resolveStateId(stateType);
344
+ await client.request(ISSUE_UPDATE_MUTATION, {
345
+ id: issueUuid,
346
+ input: { stateId }
347
+ });
348
+ }
349
+ notifyTaskChanged(id, status);
350
+ },
351
+ async updateTask(id, update) {
352
+ await applyIssueUpdate(id, update);
353
+ notifyTaskChanged(id, update.status);
354
+ }
355
+ };
356
+ }
357
+ // packages/linear-plugin/src/fixture-transport.ts
358
+ var HTTP_FAILURE = Symbol("linear-fixture-http-failure");
359
+ var GRAPHQL_ERRORS = Symbol("linear-fixture-graphql-errors");
360
+ function httpFailure(status, body) {
361
+ return { [HTTP_FAILURE]: true, status, body };
362
+ }
363
+ function graphqlErrors(errors) {
364
+ return { [GRAPHQL_ERRORS]: true, errors };
365
+ }
366
+ function isHttpFailure(value) {
367
+ return typeof value === "object" && value !== null && HTTP_FAILURE in value;
368
+ }
369
+ function isGraphQLErrors(value) {
370
+ return typeof value === "object" && value !== null && GRAPHQL_ERRORS in value;
371
+ }
372
+ function jsonResponse(status, body) {
373
+ return { ok: status >= 200 && status < 300, status, text: async () => body };
374
+ }
375
+ function createFixtureTransport(routes) {
376
+ const calls = [];
377
+ const transport = async (url, init) => {
378
+ const body = JSON.parse(init.body);
379
+ const operation = /(?:query|mutation)\s+([A-Za-z0-9_]+)/.exec(body.query)?.[1] ?? "<anonymous>";
380
+ const call = {
381
+ url,
382
+ authorization: init.headers["Authorization"],
383
+ operation,
384
+ query: body.query,
385
+ variables: body.variables ?? {}
386
+ };
387
+ calls.push(call);
388
+ const route = routes[operation];
389
+ if (!route) {
390
+ throw new Error(`createFixtureTransport: no fixture route for operation "${operation}"`);
391
+ }
392
+ const result = route(call.variables, call);
393
+ if (isHttpFailure(result)) {
394
+ return jsonResponse(result.status, result.body);
395
+ }
396
+ if (isGraphQLErrors(result)) {
397
+ return jsonResponse(200, JSON.stringify({ errors: result.errors }));
398
+ }
399
+ return jsonResponse(200, JSON.stringify({ data: result }));
400
+ };
401
+ return {
402
+ transport,
403
+ calls,
404
+ callsFor: (operation) => calls.filter((c) => c.operation === operation)
405
+ };
406
+ }
407
+
408
+ // packages/linear-plugin/src/index.ts
409
+ var TASK_SOURCE_ID = "linear:issues";
410
+ var TASK_SOURCE_KIND = "linear";
411
+ var TASK_SOURCE_DESCRIPTION = "Linear issues via the Linear GraphQL API";
412
+ function optionString(config, field) {
413
+ const value = config.options?.[field];
414
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
415
+ }
416
+ function optionStringArray(config, field) {
417
+ const value = config.options?.[field];
418
+ if (!Array.isArray(value))
419
+ return;
420
+ const strings = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
421
+ return strings.length > 0 ? strings : undefined;
422
+ }
423
+ function optionNumber(config, field) {
424
+ const value = config.options?.[field];
425
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
426
+ }
427
+ function linearPlugin(opts = {}) {
428
+ return definePlugin({
429
+ name: "rig-linear",
430
+ version: "0.1.0",
431
+ contributes: {
432
+ taskSources: [
433
+ {
434
+ id: TASK_SOURCE_ID,
435
+ kind: TASK_SOURCE_KIND,
436
+ description: TASK_SOURCE_DESCRIPTION
437
+ }
438
+ ]
439
+ }
440
+ }, {
441
+ taskSources: [
442
+ {
443
+ id: TASK_SOURCE_ID,
444
+ kind: TASK_SOURCE_KIND,
445
+ description: TASK_SOURCE_DESCRIPTION,
446
+ factory(config) {
447
+ const teamKey = optionString(config, "teamKey");
448
+ if (!teamKey) {
449
+ throw new Error('task source linear: options.teamKey is required \u2014 e.g. taskSource: { kind: "linear", options: { teamKey: "ENG" } }');
450
+ }
451
+ const options = { teamKey };
452
+ if (opts.apiKey)
453
+ options.apiKey = opts.apiKey;
454
+ if (opts.endpoint)
455
+ options.endpoint = opts.endpoint;
456
+ if (opts.transport)
457
+ options.transport = opts.transport;
458
+ if (opts.onTaskChanged)
459
+ options.onTaskChanged = opts.onTaskChanged;
460
+ const states = optionStringArray(config, "states");
461
+ if (states)
462
+ options.states = states;
463
+ const assignee = optionString(config, "assignee");
464
+ if (assignee)
465
+ options.assignee = assignee;
466
+ const labels = optionStringArray(config, "labels") ?? (config.labels && config.labels.length > 0 ? [...config.labels] : undefined);
467
+ if (labels)
468
+ options.labels = labels;
469
+ const pageSize = optionNumber(config, "pageSize");
470
+ if (pageSize !== undefined)
471
+ options.pageSize = pageSize;
472
+ const listLimit = optionNumber(config, "listLimit");
473
+ if (listLimit !== undefined)
474
+ options.listLimit = listLimit;
475
+ return createLinearIssuesTaskSource(options);
476
+ }
477
+ }
478
+ ]
479
+ });
480
+ }
481
+ export {
482
+ updateRigOwnedMetadataBlock,
483
+ rigStatusForLinearStateType,
484
+ linearStateTypeForRigStatus,
485
+ httpFailure,
486
+ graphqlErrors,
487
+ linearPlugin as default,
488
+ createLinearIssuesTaskSource,
489
+ createFixtureTransport,
490
+ RIG_METADATA_START,
491
+ RIG_METADATA_END
492
+ };
@@ -0,0 +1,361 @@
1
+ // @bun
2
+ // packages/linear-plugin/src/linear-issues-source.ts
3
+ var ISSUE_FIELDS = `
4
+ id
5
+ identifier
6
+ title
7
+ description
8
+ priority
9
+ priorityLabel
10
+ url
11
+ state { id name type }
12
+ labels { nodes { name } }
13
+ assignee { name email }`;
14
+ var LIST_ISSUES_QUERY = `query RigListIssues($filter: IssueFilter, $first: Int!, $after: String) {
15
+ issues(filter: $filter, first: $first, after: $after) {
16
+ pageInfo { hasNextPage endCursor }
17
+ nodes {${ISSUE_FIELDS}
18
+ }
19
+ }
20
+ }`;
21
+ var GET_ISSUE_QUERY = `query RigGetIssue($id: String!) {
22
+ issue(id: $id) {${ISSUE_FIELDS}
23
+ }
24
+ }`;
25
+ var RESOLVE_ISSUE_QUERY = `query RigResolveIssue($id: String!) {
26
+ issue(id: $id) { id }
27
+ }`;
28
+ var ISSUE_DESCRIPTION_QUERY = `query RigIssueDescription($id: String!) {
29
+ issue(id: $id) { description }
30
+ }`;
31
+ var TEAM_STATES_QUERY = `query RigTeamStates($teamKey: String!) {
32
+ teams(filter: { key: { eq: $teamKey } }, first: 1) {
33
+ nodes {
34
+ id
35
+ key
36
+ states { nodes { id name type position } }
37
+ }
38
+ }
39
+ }`;
40
+ var ISSUE_UPDATE_MUTATION = `mutation RigIssueUpdate($id: String!, $input: IssueUpdateInput!) {
41
+ issueUpdate(id: $id, input: $input) { success }
42
+ }`;
43
+ var COMMENT_CREATE_MUTATION = `mutation RigCommentCreate($input: CommentCreateInput!) {
44
+ commentCreate(input: $input) { success }
45
+ }`;
46
+ function rigStatusForLinearStateType(stateType) {
47
+ switch (stateType) {
48
+ case "triage":
49
+ case "backlog":
50
+ case "unstarted":
51
+ return "ready";
52
+ case "started":
53
+ return "in_progress";
54
+ case "completed":
55
+ return "completed";
56
+ case "canceled":
57
+ return "cancelled";
58
+ default:
59
+ return "open";
60
+ }
61
+ }
62
+ function linearStateTypeForRigStatus(status) {
63
+ switch (status) {
64
+ case "ready":
65
+ return "unstarted";
66
+ case "in_progress":
67
+ case "under_review":
68
+ case "ci_fixing":
69
+ case "merging":
70
+ return "started";
71
+ case "closed":
72
+ case "completed":
73
+ return "completed";
74
+ case "cancelled":
75
+ return "canceled";
76
+ case "blocked":
77
+ case "failed":
78
+ case "needs_attention":
79
+ case "open":
80
+ return null;
81
+ default:
82
+ return null;
83
+ }
84
+ }
85
+ var RIG_METADATA_START = "<!-- rig:metadata:start -->";
86
+ var RIG_METADATA_END = "<!-- rig:metadata:end -->";
87
+ function yamlScalar(value) {
88
+ if (Array.isArray(value)) {
89
+ return value.length === 0 ? "[]" : `
90
+ ${value.map((entry) => ` - ${String(entry)}`).join(`
91
+ `)}`;
92
+ }
93
+ if (value && typeof value === "object")
94
+ return JSON.stringify(value);
95
+ return String(value);
96
+ }
97
+ function escapeRegExp(value) {
98
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
100
+ function updateRigOwnedMetadataBlock(body, metadata) {
101
+ const rendered = [
102
+ RIG_METADATA_START,
103
+ ...Object.entries(metadata).map(([key, value]) => Array.isArray(value) ? `${key}:${yamlScalar(value)}` : `${key}: ${yamlScalar(value)}`),
104
+ RIG_METADATA_END
105
+ ].join(`
106
+ `);
107
+ const pattern = new RegExp(`${escapeRegExp(RIG_METADATA_START)}\\s*[\\s\\S]*?\\s*${escapeRegExp(RIG_METADATA_END)}`);
108
+ if (pattern.test(body))
109
+ return body.replace(pattern, rendered);
110
+ return body.trim().length > 0 ? `${body.trimEnd()}
111
+
112
+ ${rendered}
113
+ ` : `${rendered}
114
+ `;
115
+ }
116
+ function labelNamesFor(issue) {
117
+ return (issue.labels?.nodes ?? []).map((label) => label.name);
118
+ }
119
+ function issueToTask(issue) {
120
+ const labelNames = labelNamesFor(issue);
121
+ const scope = labelNames.filter((l) => l.startsWith("scope:")).map((l) => l.slice("scope:".length));
122
+ const roleLabel = labelNames.find((l) => l.startsWith("role:"));
123
+ const validators = labelNames.filter((l) => l.startsWith("validator:")).map((l) => l.slice("validator:".length));
124
+ const description = issue.description ?? "";
125
+ return {
126
+ id: issue.identifier,
127
+ deps: [],
128
+ status: rigStatusForLinearStateType(issue.state?.type),
129
+ title: issue.title,
130
+ body: description,
131
+ description,
132
+ ...scope.length > 0 ? { scope } : {},
133
+ ...roleLabel ? { role: roleLabel.slice("role:".length) } : {},
134
+ ...validators.length > 0 ? { validators } : {},
135
+ ...issue.priority !== null && issue.priority !== undefined ? { priority: issue.priority } : {},
136
+ ...issue.priorityLabel ? { priorityLabel: issue.priorityLabel } : {},
137
+ ...issue.url ? { url: issue.url } : {},
138
+ labels: labelNames,
139
+ externalRef: `linear:${issue.identifier}`,
140
+ sourceIssueId: issue.identifier,
141
+ linearIssueId: issue.id,
142
+ ...issue.state ? { stateName: issue.state.name, stateType: issue.state.type } : {},
143
+ raw: issue
144
+ };
145
+ }
146
+ function buildIssueFilter(opts) {
147
+ const filter = { team: { key: { eq: opts.teamKey } } };
148
+ if (opts.states && opts.states.length > 0) {
149
+ filter.state = { name: { in: [...opts.states] } };
150
+ }
151
+ const assignee = opts.assignee?.trim();
152
+ if (assignee) {
153
+ filter.assignee = assignee.includes("@") ? { email: { eq: assignee } } : { displayName: { eq: assignee } };
154
+ }
155
+ if (opts.labels && opts.labels.length > 0) {
156
+ filter.and = opts.labels.map((name) => ({ labels: { some: { name: { eq: name } } } }));
157
+ }
158
+ return filter;
159
+ }
160
+ var DEFAULT_ENDPOINT = "https://api.linear.app/graphql";
161
+ var DEFAULT_PAGE_SIZE = 50;
162
+ var DEFAULT_LIST_LIMIT = 1000;
163
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
164
+ function truncateForError(text) {
165
+ const clean = text.replace(/\s+/g, " ").trim();
166
+ return clean.length > 300 ? `${clean.slice(0, 300)}\u2026` : clean;
167
+ }
168
+ function isAuthErrorPayload(status, errors) {
169
+ if (status === 401)
170
+ return "HTTP 401";
171
+ for (const error of errors ?? []) {
172
+ const code = error.extensions?.code ?? "";
173
+ if (code === "AUTHENTICATION_ERROR" || /authentication/i.test(error.message ?? "")) {
174
+ return error.message ?? code;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ function createGraphQLClient(opts) {
180
+ const endpoint = opts.endpoint ?? DEFAULT_ENDPOINT;
181
+ const transport = opts.transport ?? fetch;
182
+ return {
183
+ async request(query, variables) {
184
+ const apiKey = (opts.apiKey ?? process.env.LINEAR_API_KEY)?.trim();
185
+ if (!apiKey) {
186
+ throw new Error("Linear task source: no API key configured. Pass apiKey in plugin options or set the LINEAR_API_KEY environment variable.");
187
+ }
188
+ const authorization = apiKey.startsWith("lin_oauth_") ? `Bearer ${apiKey}` : apiKey;
189
+ let response;
190
+ try {
191
+ response = await transport(endpoint, {
192
+ method: "POST",
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ Authorization: authorization
196
+ },
197
+ body: JSON.stringify({ query, variables })
198
+ });
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ throw new Error(`Linear API request failed (network error): ${message}`, { cause: error });
202
+ }
203
+ const text = await response.text();
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(text);
207
+ } catch {
208
+ parsed = undefined;
209
+ }
210
+ const authError = isAuthErrorPayload(response.status, parsed?.errors);
211
+ if (authError) {
212
+ throw new Error(`Linear API authentication failed (${authError}). Check that the configured API key (options.apiKey / LINEAR_API_KEY) is a valid Linear key with access to team "${opts.teamKey}".`);
213
+ }
214
+ if (!response.ok) {
215
+ throw new Error(`Linear API request failed (HTTP ${response.status}): ${truncateForError(text)}`);
216
+ }
217
+ if (parsed?.errors && parsed.errors.length > 0) {
218
+ const messages = parsed.errors.map((e) => e.message ?? "unknown error").join("; ");
219
+ throw new Error(`Linear GraphQL error: ${messages}`);
220
+ }
221
+ if (!parsed || parsed.data === undefined || parsed.data === null) {
222
+ throw new Error(`Linear API returned no data: ${truncateForError(text)}`);
223
+ }
224
+ return parsed.data;
225
+ }
226
+ };
227
+ }
228
+ function createLinearIssuesTaskSource(opts) {
229
+ if (!opts.teamKey?.trim()) {
230
+ throw new Error("createLinearIssuesTaskSource: teamKey is required");
231
+ }
232
+ const teamKey = opts.teamKey.trim();
233
+ const client = createGraphQLClient(opts);
234
+ const pageSize = Math.min(250, Math.max(1, Math.trunc(opts.pageSize ?? DEFAULT_PAGE_SIZE)));
235
+ const listLimit = Math.max(1, Math.trunc(opts.listLimit ?? DEFAULT_LIST_LIMIT));
236
+ let cachedStates = null;
237
+ const fetchTeamStates = async () => {
238
+ if (cachedStates)
239
+ return cachedStates;
240
+ const data = await client.request(TEAM_STATES_QUERY, { teamKey });
241
+ const team = data.teams.nodes[0];
242
+ if (!team) {
243
+ throw new Error(`Linear team not found for key "${teamKey}" \u2014 check taskSource.options.teamKey.`);
244
+ }
245
+ cachedStates = team.states.nodes;
246
+ return cachedStates;
247
+ };
248
+ const resolveStateId = async (stateType) => {
249
+ const states = await fetchTeamStates();
250
+ const candidates = states.filter((s) => s.type === stateType).toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0));
251
+ const target = candidates[0];
252
+ if (!target) {
253
+ throw new Error(`Linear team "${teamKey}" has no workflow state of type "${stateType}" \u2014 cannot map this Rig status to a Linear state.`);
254
+ }
255
+ return target.id;
256
+ };
257
+ const resolveIssueUuid = async (taskId) => {
258
+ if (UUID_PATTERN.test(taskId))
259
+ return taskId;
260
+ const data = await client.request(RESOLVE_ISSUE_QUERY, { id: taskId });
261
+ if (!data.issue) {
262
+ throw new Error(`Linear issue not found: ${taskId}`);
263
+ }
264
+ return data.issue.id;
265
+ };
266
+ const fetchDescription = async (issueUuid) => {
267
+ const data = await client.request(ISSUE_DESCRIPTION_QUERY, { id: issueUuid });
268
+ return data.issue?.description ?? "";
269
+ };
270
+ const applyIssueUpdate = async (taskId, update) => {
271
+ const issueUuid = await resolveIssueUuid(taskId);
272
+ const input = {};
273
+ if (update.status) {
274
+ const stateType = linearStateTypeForRigStatus(update.status);
275
+ if (stateType)
276
+ input.stateId = await resolveStateId(stateType);
277
+ }
278
+ if (update.title?.trim())
279
+ input.title = update.title.trim();
280
+ const nextBody = update.metadata ? updateRigOwnedMetadataBlock(update.body ?? await fetchDescription(issueUuid), update.metadata) : update.body;
281
+ if (nextBody !== undefined)
282
+ input.description = nextBody;
283
+ if (Object.keys(input).length > 0) {
284
+ await client.request(ISSUE_UPDATE_MUTATION, {
285
+ id: issueUuid,
286
+ input
287
+ });
288
+ }
289
+ if (update.comment?.trim()) {
290
+ await client.request(COMMENT_CREATE_MUTATION, {
291
+ input: { issueId: issueUuid, body: update.comment }
292
+ });
293
+ }
294
+ };
295
+ const notifyTaskChanged = (id, status) => {
296
+ opts.onTaskChanged?.({ teamKey, id, ...status ? { status } : {}, reason: "linear-issue-updated" });
297
+ };
298
+ return {
299
+ id: "linear:issues",
300
+ kind: "linear",
301
+ async list() {
302
+ const filter = buildIssueFilter({ ...opts, teamKey });
303
+ const out = [];
304
+ let after = null;
305
+ for (;; ) {
306
+ const data = await client.request(LIST_ISSUES_QUERY, {
307
+ filter,
308
+ first: pageSize,
309
+ after
310
+ });
311
+ out.push(...data.issues.nodes.map(issueToTask));
312
+ if (out.length > listLimit) {
313
+ throw new Error(`Linear issue list for team ${teamKey} exceeded the configured limit (${listLimit}); refusing to silently truncate matching issues. Increase taskSource.options.listLimit or narrow states/labels/assignee.`);
314
+ }
315
+ if (!data.issues.pageInfo.hasNextPage)
316
+ break;
317
+ after = data.issues.pageInfo.endCursor ?? null;
318
+ if (!after)
319
+ break;
320
+ }
321
+ return out;
322
+ },
323
+ async get(id) {
324
+ try {
325
+ const data = await client.request(GET_ISSUE_QUERY, { id });
326
+ return data.issue ? issueToTask(data.issue) : undefined;
327
+ } catch (error) {
328
+ const message = error instanceof Error ? error.message : String(error);
329
+ if (/authentication|API key/i.test(message))
330
+ throw error;
331
+ if (/not found|entity not found/i.test(message))
332
+ return;
333
+ throw error;
334
+ }
335
+ },
336
+ async updateStatus(id, status) {
337
+ const stateType = linearStateTypeForRigStatus(status);
338
+ if (stateType) {
339
+ const issueUuid = await resolveIssueUuid(id);
340
+ const stateId = await resolveStateId(stateType);
341
+ await client.request(ISSUE_UPDATE_MUTATION, {
342
+ id: issueUuid,
343
+ input: { stateId }
344
+ });
345
+ }
346
+ notifyTaskChanged(id, status);
347
+ },
348
+ async updateTask(id, update) {
349
+ await applyIssueUpdate(id, update);
350
+ notifyTaskChanged(id, update.status);
351
+ }
352
+ };
353
+ }
354
+ export {
355
+ updateRigOwnedMetadataBlock,
356
+ rigStatusForLinearStateType,
357
+ linearStateTypeForRigStatus,
358
+ createLinearIssuesTaskSource,
359
+ RIG_METADATA_START,
360
+ RIG_METADATA_END
361
+ };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@h-rig/linear-plugin",
3
+ "version": "0.0.6-alpha.65",
4
+ "type": "module",
5
+ "description": "Rig package",
6
+ "license": "UNLICENSED",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/src/index.js"
14
+ }
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.3.11"
18
+ },
19
+ "main": "./dist/src/index.js",
20
+ "module": "./dist/src/index.js",
21
+ "dependencies": {
22
+ "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.65",
23
+ "@rig/core": "npm:@h-rig/core@0.0.6-alpha.65",
24
+ "effect": "4.0.0-beta.78"
25
+ }
26
+ }