@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 +227 -0
- package/dist/src/fixture-transport.js +56 -0
- package/dist/src/fixtures.js +155 -0
- package/dist/src/index.js +492 -0
- package/dist/src/linear-issues-source.js +361 -0
- package/package.json +26 -0
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
|
+
}
|