@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 +227 -0
- package/dist/src/fixture-transport.d.ts +61 -0
- package/dist/src/fixture-transport.js +56 -0
- package/dist/src/fixtures.d.ts +109 -0
- package/dist/src/fixtures.js +155 -0
- package/dist/src/index.d.ts +47 -0
- package/dist/src/index.js +492 -0
- package/dist/src/linear-issues-source.d.ts +82 -0
- package/dist/src/linear-issues-source.js +361 -0
- package/package.json +28 -0
|
@@ -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,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear issues task source — the canonical external-HTTP-API adapter.
|
|
3
|
+
*
|
|
4
|
+
* Everything goes through one GraphQL endpoint (`https://api.linear.app/graphql`)
|
|
5
|
+
* via an injectable fetch-shaped transport, so the entire adapter is testable
|
|
6
|
+
* from fixtures with zero live API calls. See the package README for the
|
|
7
|
+
* walk-through; this file is deliberately self-contained (no SDK dependency).
|
|
8
|
+
*/
|
|
9
|
+
import type { RegisteredTaskSource, TaskRecord, TaskSourceUpdate } from "@rig/contracts";
|
|
10
|
+
/** The exact request shape the adapter sends. */
|
|
11
|
+
export interface LinearTransportRequest {
|
|
12
|
+
method: "POST";
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
/** The minimal response surface the adapter reads. `Response` satisfies it. */
|
|
17
|
+
export interface LinearTransportResponse {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
status: number;
|
|
20
|
+
text(): Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A fetch-shaped function. Global `fetch` is assignable as-is; tests inject a
|
|
24
|
+
* fixture-driven fake (see `createFixtureTransport` in `fixture-transport.ts`).
|
|
25
|
+
*/
|
|
26
|
+
export type LinearTransport = (url: string, init: LinearTransportRequest) => Promise<LinearTransportResponse>;
|
|
27
|
+
export interface LinearIssuesOptions {
|
|
28
|
+
/** Linear team key (the "ENG" in ENG-123). Issues are listed for this team. */
|
|
29
|
+
teamKey: string;
|
|
30
|
+
/**
|
|
31
|
+
* Linear API key. Defaults to `process.env.LINEAR_API_KEY`, resolved at
|
|
32
|
+
* request time. Personal API keys (`lin_api_…`) are sent raw in the
|
|
33
|
+
* `Authorization` header; OAuth tokens (`lin_oauth_…`) get a `Bearer` prefix.
|
|
34
|
+
*/
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
/** Filter: workflow state names (e.g. `["Todo", "In Progress"]`). */
|
|
37
|
+
states?: readonly string[];
|
|
38
|
+
/** Filter: assignee email (anything containing "@") or display name. */
|
|
39
|
+
assignee?: string;
|
|
40
|
+
/** Filter: label names. An issue must carry every listed label (AND). */
|
|
41
|
+
labels?: readonly string[];
|
|
42
|
+
/** GraphQL endpoint override. Defaults to the public Linear API. */
|
|
43
|
+
endpoint?: string;
|
|
44
|
+
/** fetch-shaped transport. Defaults to global `fetch`; tests inject a fake. */
|
|
45
|
+
transport?: LinearTransport;
|
|
46
|
+
/** Issues per GraphQL page (Linear caps pages at 250). Defaults to 50. */
|
|
47
|
+
pageSize?: number;
|
|
48
|
+
/** Maximum issue-list rows before Rig fails loudly instead of silently truncating. Defaults to 1,000. */
|
|
49
|
+
listLimit?: number;
|
|
50
|
+
/** Notify the host that issue-backed task state changed and snapshots should refresh. */
|
|
51
|
+
onTaskChanged?: (event: {
|
|
52
|
+
teamKey: string;
|
|
53
|
+
id: string;
|
|
54
|
+
status?: TaskRecord["status"];
|
|
55
|
+
reason: "linear-issue-updated";
|
|
56
|
+
}) => void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Linear workflow-state *type* → Rig task status.
|
|
60
|
+
* triage / backlog / unstarted → ready
|
|
61
|
+
* started → in_progress
|
|
62
|
+
* completed → completed
|
|
63
|
+
* canceled → cancelled
|
|
64
|
+
* Unknown types fall back to "open" rather than failing the whole list.
|
|
65
|
+
*/
|
|
66
|
+
export declare function rigStatusForLinearStateType(stateType: string | undefined): TaskRecord["status"];
|
|
67
|
+
/**
|
|
68
|
+
* Rig task status → Linear workflow-state type for lifecycle writeback.
|
|
69
|
+
* Returns null when Linear has no workflow equivalent (e.g. "blocked") — the
|
|
70
|
+
* adapter then leaves the workflow state untouched and relies on comments.
|
|
71
|
+
*/
|
|
72
|
+
export declare function linearStateTypeForRigStatus(status: TaskRecord["status"]): "unstarted" | "started" | "completed" | "canceled" | null;
|
|
73
|
+
export declare const RIG_METADATA_START = "<!-- rig:metadata:start -->";
|
|
74
|
+
export declare const RIG_METADATA_END = "<!-- rig:metadata:end -->";
|
|
75
|
+
/** Insert or replace the Rig-owned metadata block in an issue description. */
|
|
76
|
+
export declare function updateRigOwnedMetadataBlock(body: string, metadata: Record<string, unknown>): string;
|
|
77
|
+
export interface LinearIssuesTaskSource extends RegisteredTaskSource {
|
|
78
|
+
get(id: string): Promise<TaskRecord | undefined>;
|
|
79
|
+
updateStatus(id: string, status: TaskRecord["status"]): Promise<void>;
|
|
80
|
+
updateTask(id: string, update: TaskSourceUpdate): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
export declare function createLinearIssuesTaskSource(opts: LinearIssuesOptions): LinearIssuesTaskSource;
|