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

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,61 @@
1
+ /**
2
+ * Fixture-driven fake transport for testing the Linear adapter (and plugins
3
+ * built on the same pattern) without any live API calls.
4
+ *
5
+ * The adapter names every GraphQL operation (`RigListIssues`,
6
+ * `RigTeamStates`, …), so fixtures route on the operation name instead of
7
+ * fragile query-string matching. Each route returns either:
8
+ * - a plain object → wrapped as `{ data: <object> }` with HTTP 200
9
+ * - `graphqlErrors([...])` → `{ errors: [...] }` with HTTP 200
10
+ * - `httpFailure(status, body)` → a raw non-200 HTTP response
11
+ */
12
+ import type { LinearTransport } from "./linear-issues-source";
13
+ export interface RecordedLinearCall {
14
+ url: string;
15
+ /** Raw Authorization header the adapter sent. */
16
+ authorization: string | undefined;
17
+ /** GraphQL operation name parsed from the request body. */
18
+ operation: string;
19
+ query: string;
20
+ variables: Record<string, unknown>;
21
+ }
22
+ declare const HTTP_FAILURE: unique symbol;
23
+ declare const GRAPHQL_ERRORS: unique symbol;
24
+ interface HttpFailureFixture {
25
+ [HTTP_FAILURE]: true;
26
+ status: number;
27
+ body: string;
28
+ }
29
+ interface GraphQLErrorsFixture {
30
+ [GRAPHQL_ERRORS]: true;
31
+ errors: ReadonlyArray<{
32
+ message: string;
33
+ extensions?: {
34
+ code?: string;
35
+ };
36
+ }>;
37
+ }
38
+ /** Make a route fail at the HTTP layer (e.g. `httpFailure(401, "Unauthorized")`). */
39
+ export declare function httpFailure(status: number, body: string): HttpFailureFixture;
40
+ /** Make a route return a GraphQL-level errors payload with HTTP 200. */
41
+ export declare function graphqlErrors(errors: ReadonlyArray<{
42
+ message: string;
43
+ extensions?: {
44
+ code?: string;
45
+ };
46
+ }>): GraphQLErrorsFixture;
47
+ export type FixtureRoute = (variables: Record<string, unknown>, call: RecordedLinearCall) => unknown;
48
+ export interface FixtureTransport {
49
+ transport: LinearTransport;
50
+ /** Every request the adapter made, in order. Assert payloads against this. */
51
+ calls: RecordedLinearCall[];
52
+ /** Calls filtered to one operation name. */
53
+ callsFor(operation: string): RecordedLinearCall[];
54
+ }
55
+ /**
56
+ * Build a transport whose responses come from fixtures keyed by GraphQL
57
+ * operation name. Unrouted operations fail loudly so tests never silently
58
+ * exercise a request they didn't mean to allow.
59
+ */
60
+ export declare function createFixtureTransport(routes: Record<string, FixtureRoute>): FixtureTransport;
61
+ export {};
@@ -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,109 @@
1
+ /**
2
+ * Shared GraphQL fixtures for the test suite — shaped exactly like real
3
+ * Linear API responses (the `data` payload of each operation). No live API
4
+ * calls happen anywhere in this package's tests.
5
+ */
6
+ export declare const TEAM_KEY = "ENG";
7
+ export declare const STATE_IDS: {
8
+ readonly triage: "11111111-1111-4111-8111-111111111101";
9
+ readonly backlog: "11111111-1111-4111-8111-111111111102";
10
+ readonly todo: "11111111-1111-4111-8111-111111111103";
11
+ readonly inProgress: "11111111-1111-4111-8111-111111111104";
12
+ readonly inReview: "11111111-1111-4111-8111-111111111105";
13
+ readonly done: "11111111-1111-4111-8111-111111111106";
14
+ readonly canceled: "11111111-1111-4111-8111-111111111107";
15
+ };
16
+ /** Response payload for the RigTeamStates operation. */
17
+ export declare const teamStatesFixture: {
18
+ teams: {
19
+ nodes: {
20
+ id: string;
21
+ key: string;
22
+ states: {
23
+ nodes: ({
24
+ id: "11111111-1111-4111-8111-111111111101";
25
+ name: string;
26
+ type: string;
27
+ position: number;
28
+ } | {
29
+ id: "11111111-1111-4111-8111-111111111102";
30
+ name: string;
31
+ type: string;
32
+ position: number;
33
+ } | {
34
+ id: "11111111-1111-4111-8111-111111111103";
35
+ name: string;
36
+ type: string;
37
+ position: number;
38
+ } | {
39
+ id: "11111111-1111-4111-8111-111111111105";
40
+ name: string;
41
+ type: string;
42
+ position: number;
43
+ } | {
44
+ id: "11111111-1111-4111-8111-111111111104";
45
+ name: string;
46
+ type: string;
47
+ position: number;
48
+ } | {
49
+ id: "11111111-1111-4111-8111-111111111106";
50
+ name: string;
51
+ type: string;
52
+ position: number;
53
+ } | {
54
+ id: "11111111-1111-4111-8111-111111111107";
55
+ name: string;
56
+ type: string;
57
+ position: number;
58
+ })[];
59
+ };
60
+ }[];
61
+ };
62
+ };
63
+ export declare const ISSUE_UUIDS: {
64
+ readonly eng1: "33333333-3333-4333-8333-333333333301";
65
+ readonly eng2: "33333333-3333-4333-8333-333333333302";
66
+ readonly eng3: "33333333-3333-4333-8333-333333333303";
67
+ readonly eng4: "33333333-3333-4333-8333-333333333304";
68
+ readonly eng5: "33333333-3333-4333-8333-333333333305";
69
+ readonly eng6: "33333333-3333-4333-8333-333333333306";
70
+ readonly eng7: "33333333-3333-4333-8333-333333333307";
71
+ };
72
+ export interface FixtureIssue {
73
+ id: string;
74
+ identifier: string;
75
+ title: string;
76
+ description: string | null;
77
+ priority: number | null;
78
+ priorityLabel: string | null;
79
+ url: string;
80
+ state: {
81
+ id: string;
82
+ name: string;
83
+ type: string;
84
+ };
85
+ labels: {
86
+ nodes: Array<{
87
+ name: string;
88
+ }>;
89
+ };
90
+ assignee: {
91
+ name: string;
92
+ email: string;
93
+ } | null;
94
+ }
95
+ export declare const issueFixtures: FixtureIssue[];
96
+ /** Single-page RigListIssues payload containing every fixture issue. */
97
+ export declare function listIssuesPage(issues: readonly FixtureIssue[], pageInfo?: {
98
+ hasNextPage: boolean;
99
+ endCursor?: string | null;
100
+ }): {
101
+ issues: {
102
+ pageInfo: {
103
+ hasNextPage: boolean;
104
+ endCursor?: string | null;
105
+ };
106
+ nodes: readonly FixtureIssue[];
107
+ };
108
+ };
109
+ export declare function issueByIdentifier(identifier: string): FixtureIssue;
@@ -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,47 @@
1
+ /**
2
+ * `@rig/linear-plugin` — Linear as a Rig task source.
3
+ *
4
+ * This package is the canonical example of a plugin that adapts an external
5
+ * HTTP API into Rig's task-source contract. The README walks through the
6
+ * anatomy; `linear-issues-source.ts` holds the adapter, this file holds the
7
+ * `definePlugin` wiring (metadata channel + executable runtime channel).
8
+ *
9
+ * Usage in rig.config.ts:
10
+ *
11
+ * import { defineConfig } from "@rig/core";
12
+ * import linear from "@rig/linear-plugin";
13
+ *
14
+ * export default defineConfig({
15
+ * plugins: [linear()],
16
+ * taskSource: {
17
+ * kind: "linear",
18
+ * options: { teamKey: "ENG", states: ["Todo"], labels: ["rig"] },
19
+ * },
20
+ * // ...
21
+ * });
22
+ *
23
+ * The API key comes from LINEAR_API_KEY (or `linear({ apiKey })`) — never
24
+ * from rig.config.ts, which is committed.
25
+ */
26
+ import { type RigPluginWithRuntime } from "@rig/core";
27
+ import { type LinearIssuesOptions, type LinearTransport } from "./linear-issues-source";
28
+ export { createLinearIssuesTaskSource, linearStateTypeForRigStatus, rigStatusForLinearStateType, updateRigOwnedMetadataBlock, RIG_METADATA_START, RIG_METADATA_END, } from "./linear-issues-source";
29
+ export type { LinearIssuesOptions, LinearIssuesTaskSource, LinearTransport, LinearTransportRequest, LinearTransportResponse, } from "./linear-issues-source";
30
+ export { createFixtureTransport, graphqlErrors, httpFailure, } from "./fixture-transport";
31
+ export type { FixtureTransport, FixtureRoute, RecordedLinearCall } from "./fixture-transport";
32
+ /**
33
+ * Process-level options passed where the plugin is instantiated (rig.config.ts).
34
+ * Per-project source configuration (team key, filters) lives in
35
+ * `taskSource.options` instead — see the factory below.
36
+ */
37
+ export interface LinearPluginOptions {
38
+ /** Linear API key. Defaults to process.env.LINEAR_API_KEY at request time. */
39
+ apiKey?: string;
40
+ /** GraphQL endpoint override (self-hosted proxies, test servers). */
41
+ endpoint?: string;
42
+ /** fetch-shaped transport override — tests inject a fixture transport here. */
43
+ transport?: LinearTransport;
44
+ /** Notify the host that issue-backed task state changed. */
45
+ onTaskChanged?: LinearIssuesOptions["onTaskChanged"];
46
+ }
47
+ export default function linearPlugin(opts?: LinearPluginOptions): RigPluginWithRuntime;