@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.
@@ -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,28 @@
1
+ {
2
+ "name": "@h-rig/linear-plugin",
3
+ "version": "0.0.6-alpha.100",
4
+ "type": "module",
5
+ "description": "Rig package",
6
+ "license": "UNLICENSED",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/src/index.d.ts",
14
+ "import": "./dist/src/index.js"
15
+ }
16
+ },
17
+ "engines": {
18
+ "bun": ">=1.3.11"
19
+ },
20
+ "main": "./dist/src/index.js",
21
+ "module": "./dist/src/index.js",
22
+ "types": "./dist/src/index.d.ts",
23
+ "dependencies": {
24
+ "@rig/contracts": "npm:@h-rig/contracts@0.0.6-alpha.100",
25
+ "@rig/core": "npm:@h-rig/core@0.0.6-alpha.100",
26
+ "effect": "4.0.0-beta.78"
27
+ }
28
+ }