@vornrun/connector-linear 0.1.0
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/dist/index.d.ts +26 -0
- package/dist/index.js +362 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as _vornrun_connector_sdk from '@vornrun/connector-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Linear's GraphQL API, called directly.
|
|
5
|
+
*
|
|
6
|
+
* Linear publishes `@linear/sdk`, and this deliberately does not use it. The
|
|
7
|
+
* SDK is a generated client over the whole schema — megabytes of types for the
|
|
8
|
+
* four queries and three mutations below — and `npx` pays that download on
|
|
9
|
+
* every launch. The repo's rule is to prefer a maintained vendor client; this
|
|
10
|
+
* is the exception it allows, recorded here so nobody has to guess whether it
|
|
11
|
+
* was a decision.
|
|
12
|
+
*
|
|
13
|
+
* Every call takes `fetch` as an argument so tests never touch the network.
|
|
14
|
+
*/
|
|
15
|
+
type FetchLike = typeof fetch;
|
|
16
|
+
|
|
17
|
+
interface LinearConnectorOptions {
|
|
18
|
+
version?: string;
|
|
19
|
+
/** Injected in tests, so nothing reaches the network. */
|
|
20
|
+
fetchImpl?: FetchLike;
|
|
21
|
+
}
|
|
22
|
+
declare function createLinearConnector(options?: LinearConnectorOptions): _vornrun_connector_sdk.Connector;
|
|
23
|
+
|
|
24
|
+
declare const linearConnector: _vornrun_connector_sdk.Connector;
|
|
25
|
+
|
|
26
|
+
export { type LinearConnectorOptions, createLinearConnector, linearConnector };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
|
|
6
|
+
// src/connector.ts
|
|
7
|
+
import { defineConnector } from "@vornrun/connector-sdk";
|
|
8
|
+
|
|
9
|
+
// src/client.ts
|
|
10
|
+
var LINEAR_API = "https://api.linear.app/graphql";
|
|
11
|
+
var TIMEOUT_MS = 15e3;
|
|
12
|
+
var ISSUE_FIELDS = `
|
|
13
|
+
id
|
|
14
|
+
identifier
|
|
15
|
+
title
|
|
16
|
+
description
|
|
17
|
+
url
|
|
18
|
+
createdAt
|
|
19
|
+
updatedAt
|
|
20
|
+
state { name type }
|
|
21
|
+
labels { nodes { name } }
|
|
22
|
+
assignee { name }
|
|
23
|
+
team { key }
|
|
24
|
+
`;
|
|
25
|
+
async function linearGraphQL(options) {
|
|
26
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
27
|
+
const res = await doFetch(LINEAR_API, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
Authorization: options.apiKey
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({ query: options.query, variables: options.variables ?? {} }),
|
|
34
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = await res.text().catch(() => "");
|
|
38
|
+
throw new Error(`Linear API ${res.status}: ${body.slice(0, 200)}`);
|
|
39
|
+
}
|
|
40
|
+
const payload = await res.json();
|
|
41
|
+
if (payload.errors?.length) {
|
|
42
|
+
throw new Error(`Linear GraphQL error: ${payload.errors.map((e) => e.message).join("; ")}`);
|
|
43
|
+
}
|
|
44
|
+
if (!payload.data) throw new Error("Linear API returned no data");
|
|
45
|
+
return payload.data;
|
|
46
|
+
}
|
|
47
|
+
async function resolveIssueId(apiKey, identifier, fetchImpl) {
|
|
48
|
+
const data = await linearGraphQL({
|
|
49
|
+
apiKey,
|
|
50
|
+
...fetchImpl && { fetchImpl },
|
|
51
|
+
query: `query IssueIdByIdentifier($identifier: String!) {
|
|
52
|
+
issues(filter: { identifier: { eq: $identifier } }, first: 1) { nodes { id } }
|
|
53
|
+
}`,
|
|
54
|
+
variables: { identifier }
|
|
55
|
+
});
|
|
56
|
+
return data.issues.nodes[0]?.id ?? null;
|
|
57
|
+
}
|
|
58
|
+
async function resolveIssueWithTeam(apiKey, identifier, fetchImpl) {
|
|
59
|
+
const data = await linearGraphQL({
|
|
60
|
+
apiKey,
|
|
61
|
+
...fetchImpl && { fetchImpl },
|
|
62
|
+
query: `query IssueWithTeam($identifier: String!) {
|
|
63
|
+
issues(filter: { identifier: { eq: $identifier } }, first: 1) {
|
|
64
|
+
nodes { id team { id key } }
|
|
65
|
+
}
|
|
66
|
+
}`,
|
|
67
|
+
variables: { identifier }
|
|
68
|
+
});
|
|
69
|
+
const node = data.issues.nodes[0];
|
|
70
|
+
return node ? { id: node.id, teamId: node.team.id, teamKey: node.team.key } : null;
|
|
71
|
+
}
|
|
72
|
+
async function resolveTeamId(apiKey, teamKey, fetchImpl) {
|
|
73
|
+
const data = await linearGraphQL({
|
|
74
|
+
apiKey,
|
|
75
|
+
...fetchImpl && { fetchImpl },
|
|
76
|
+
query: `query TeamIdByKey($key: String!) {
|
|
77
|
+
teams(filter: { key: { eq: $key } }, first: 1) { nodes { id } }
|
|
78
|
+
}`,
|
|
79
|
+
variables: { key: teamKey }
|
|
80
|
+
});
|
|
81
|
+
return data.teams.nodes[0]?.id ?? null;
|
|
82
|
+
}
|
|
83
|
+
async function resolveCompletedStateId(apiKey, teamId, fetchImpl) {
|
|
84
|
+
const data = await linearGraphQL({
|
|
85
|
+
apiKey,
|
|
86
|
+
...fetchImpl && { fetchImpl },
|
|
87
|
+
query: `query CompletedStates($teamId: ID!) {
|
|
88
|
+
workflowStates(
|
|
89
|
+
filter: { team: { id: { eq: $teamId } }, type: { eq: "completed" } }
|
|
90
|
+
orderBy: position
|
|
91
|
+
first: 50
|
|
92
|
+
) { nodes { id type position } }
|
|
93
|
+
}`,
|
|
94
|
+
variables: { teamId }
|
|
95
|
+
});
|
|
96
|
+
const nodes = data.workflowStates.nodes;
|
|
97
|
+
if (nodes.length === 0) return null;
|
|
98
|
+
return nodes.slice().sort((a, b) => a.position - b.position)[0].id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/connector.ts
|
|
102
|
+
var DEFAULT_LIMIT = 50;
|
|
103
|
+
function required(config, key, env) {
|
|
104
|
+
const value = String(config[key] ?? "").trim();
|
|
105
|
+
if (!value) throw new Error(`${env} is required`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function text(value) {
|
|
109
|
+
const trimmed = String(value ?? "").trim();
|
|
110
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
111
|
+
}
|
|
112
|
+
function issueToItem(issue) {
|
|
113
|
+
return {
|
|
114
|
+
externalId: issue.identifier,
|
|
115
|
+
url: issue.url,
|
|
116
|
+
title: issue.title,
|
|
117
|
+
description: issue.description ?? "",
|
|
118
|
+
status: issue.state.type,
|
|
119
|
+
labels: issue.labels.nodes.map((label) => label.name),
|
|
120
|
+
...issue.assignee?.name && { assignee: issue.assignee.name },
|
|
121
|
+
updatedAt: issue.updatedAt,
|
|
122
|
+
// Everything else a workflow might template, under the key the SDK
|
|
123
|
+
// flattens into {{trigger.item.<key>}}.
|
|
124
|
+
data: {
|
|
125
|
+
createdAt: issue.createdAt,
|
|
126
|
+
stateName: issue.state.name,
|
|
127
|
+
teamKey: issue.team.key
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function createLinearConnector(options = {}) {
|
|
132
|
+
const fetchImpl = options.fetchImpl;
|
|
133
|
+
async function fetchIssues(context) {
|
|
134
|
+
const config = context.config;
|
|
135
|
+
const apiKey = required(config, "apiKey", "LINEAR_API_KEY");
|
|
136
|
+
const teamKey = text(config.teamKey);
|
|
137
|
+
const stateType = text(config.stateType);
|
|
138
|
+
const limit = Number(config.limit ?? DEFAULT_LIMIT) || DEFAULT_LIMIT;
|
|
139
|
+
const filter = {};
|
|
140
|
+
if (teamKey) filter.team = { key: { eq: teamKey } };
|
|
141
|
+
if (stateType) filter.state = { type: { eq: stateType } };
|
|
142
|
+
if (context.since) filter.updatedAt = { gte: context.since };
|
|
143
|
+
const data = await linearGraphQL({
|
|
144
|
+
apiKey,
|
|
145
|
+
...fetchImpl && { fetchImpl },
|
|
146
|
+
query: `
|
|
147
|
+
query ListIssues($filter: IssueFilter, $first: Int!) {
|
|
148
|
+
issues(filter: $filter, first: $first, orderBy: updatedAt) {
|
|
149
|
+
nodes { ${ISSUE_FIELDS} }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
`,
|
|
153
|
+
variables: {
|
|
154
|
+
filter: Object.keys(filter).length > 0 ? filter : void 0,
|
|
155
|
+
first: limit
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return data.issues.nodes.map(issueToItem);
|
|
159
|
+
}
|
|
160
|
+
return defineConnector({
|
|
161
|
+
id: "linear",
|
|
162
|
+
name: "Linear",
|
|
163
|
+
...options.version && { version: options.version },
|
|
164
|
+
description: "Trigger workflows from Linear issues, and comment or close them from a step.",
|
|
165
|
+
// Linear's own mark.
|
|
166
|
+
icon: {
|
|
167
|
+
viewBox: "0 0 24 24",
|
|
168
|
+
paths: [
|
|
169
|
+
"M3.035 12.943c.207 1.98 1.07 3.904 2.587 5.421 1.517 1.517 3.441 2.38 5.42 2.587z",
|
|
170
|
+
"M3 11.494L12.492 20.986c.806-.045 1.606-.198 2.378-.459L3.459 9.115A9.6 9.6 0 003 11.494z",
|
|
171
|
+
"M3.867 8.11l12.009 12.009a9.6 9.6 0 001.773-1.123L4.99 6.337a9.6 9.6 0 00-1.123 1.773z",
|
|
172
|
+
"M5.663 5.595c3.518-3.474 9.186-3.46 12.687.04 3.501 3.501 3.515 9.169.041 12.687z"
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
config: [
|
|
176
|
+
{
|
|
177
|
+
key: "apiKey",
|
|
178
|
+
env: "LINEAR_API_KEY",
|
|
179
|
+
label: "Linear API key",
|
|
180
|
+
// Stored encrypted by Vorn and never printed by the CLI.
|
|
181
|
+
secret: true,
|
|
182
|
+
required: true,
|
|
183
|
+
description: "Create a personal API key at linear.app/settings/api."
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
key: "teamKey",
|
|
187
|
+
env: "LINEAR_TEAM_KEY",
|
|
188
|
+
label: "Team key",
|
|
189
|
+
description: "Upper-case key such as ENG. Leave blank for every team you can see."
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
key: "stateType",
|
|
193
|
+
env: "LINEAR_STATE_TYPE",
|
|
194
|
+
label: "State",
|
|
195
|
+
description: "One of backlog, unstarted, started, completed, canceled. Blank for all states."
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: "limit",
|
|
199
|
+
env: "LINEAR_LIMIT",
|
|
200
|
+
label: "Maximum per poll",
|
|
201
|
+
default: String(DEFAULT_LIMIT)
|
|
202
|
+
}
|
|
203
|
+
],
|
|
204
|
+
triggers: [
|
|
205
|
+
{
|
|
206
|
+
type: "issueCreated",
|
|
207
|
+
label: "An issue is created or changed",
|
|
208
|
+
description: "Fires for each issue the query returns that Vorn has not seen at this time.",
|
|
209
|
+
// Issues carry updatedAt, so the watermark advances on it rather than
|
|
210
|
+
// re-reading everything the filter still matches.
|
|
211
|
+
dedupe: "timestamp",
|
|
212
|
+
// What each Linear state type should become as a Vorn task. Without
|
|
213
|
+
// these every issue imports as `todo`, including ones closed a year
|
|
214
|
+
// ago.
|
|
215
|
+
statusMapping: [
|
|
216
|
+
{ upstream: "backlog", suggestedLocal: "todo" },
|
|
217
|
+
{ upstream: "unstarted", suggestedLocal: "todo" },
|
|
218
|
+
{ upstream: "started", suggestedLocal: "in_progress" },
|
|
219
|
+
{ upstream: "completed", suggestedLocal: "done" },
|
|
220
|
+
{ upstream: "canceled", suggestedLocal: "cancelled" }
|
|
221
|
+
],
|
|
222
|
+
defaultWorkflow: { name: "Linear: issues", defaultCronFromMinutes: 5 },
|
|
223
|
+
fetch: fetchIssues
|
|
224
|
+
}
|
|
225
|
+
],
|
|
226
|
+
actions: [
|
|
227
|
+
{
|
|
228
|
+
type: "commentOnIssue",
|
|
229
|
+
label: "Comment on an issue",
|
|
230
|
+
description: "Post a comment on a Linear issue.",
|
|
231
|
+
// Two identical calls make two comments.
|
|
232
|
+
idempotent: false,
|
|
233
|
+
inputs: [
|
|
234
|
+
{ key: "identifier", label: "Issue", required: true, description: "e.g. ENG-123" },
|
|
235
|
+
{ key: "body", label: "Comment", required: true }
|
|
236
|
+
],
|
|
237
|
+
outputs: [{ key: "url", description: "Where to read the comment" }],
|
|
238
|
+
async run(args, { config }) {
|
|
239
|
+
const apiKey = required(config, "apiKey", "LINEAR_API_KEY");
|
|
240
|
+
const identifier = text(args.identifier);
|
|
241
|
+
const body = text(args.body);
|
|
242
|
+
if (!identifier) throw new Error("identifier is required (e.g. ENG-123)");
|
|
243
|
+
if (!body) throw new Error("body is required");
|
|
244
|
+
const issueId = await resolveIssueId(apiKey, identifier, fetchImpl);
|
|
245
|
+
if (!issueId) throw new Error(`Issue ${identifier} not found`);
|
|
246
|
+
const data = await linearGraphQL({
|
|
247
|
+
apiKey,
|
|
248
|
+
...fetchImpl && { fetchImpl },
|
|
249
|
+
query: `mutation CreateComment($input: CommentCreateInput!) {
|
|
250
|
+
commentCreate(input: $input) { success comment { id url } }
|
|
251
|
+
}`,
|
|
252
|
+
variables: { input: { issueId, body } }
|
|
253
|
+
});
|
|
254
|
+
if (!data.commentCreate.success) throw new Error("Linear refused to create the comment");
|
|
255
|
+
return { url: data.commentCreate.comment.url };
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
type: "createIssue",
|
|
260
|
+
label: "Create an issue",
|
|
261
|
+
description: "Open a new Linear issue and return its identifier and url.",
|
|
262
|
+
idempotent: false,
|
|
263
|
+
inputs: [
|
|
264
|
+
{ key: "title", label: "Title", required: true },
|
|
265
|
+
{ key: "description", label: "Description" },
|
|
266
|
+
{ key: "teamKey", label: "Team key", description: "Defaults to the connection\u2019s team." }
|
|
267
|
+
],
|
|
268
|
+
outputs: [
|
|
269
|
+
{ key: "identifier", description: "e.g. ENG-123" },
|
|
270
|
+
{ key: "url", description: "Where to open it" }
|
|
271
|
+
],
|
|
272
|
+
async run(args, { config }) {
|
|
273
|
+
const cfg = config;
|
|
274
|
+
const apiKey = required(cfg, "apiKey", "LINEAR_API_KEY");
|
|
275
|
+
const title = text(args.title);
|
|
276
|
+
if (!title) throw new Error("title is required");
|
|
277
|
+
const teamKey = text(args.teamKey) ?? text(cfg.teamKey);
|
|
278
|
+
if (!teamKey) {
|
|
279
|
+
throw new Error("teamKey is required: set one on the connection or pass it here.");
|
|
280
|
+
}
|
|
281
|
+
const teamId = await resolveTeamId(apiKey, teamKey, fetchImpl);
|
|
282
|
+
if (!teamId) throw new Error(`Team ${teamKey} not found`);
|
|
283
|
+
const input = { teamId, title };
|
|
284
|
+
const description = text(args.description);
|
|
285
|
+
if (description) input.description = description;
|
|
286
|
+
const data = await linearGraphQL({
|
|
287
|
+
apiKey,
|
|
288
|
+
...fetchImpl && { fetchImpl },
|
|
289
|
+
query: `mutation CreateIssue($input: IssueCreateInput!) {
|
|
290
|
+
issueCreate(input: $input) { success issue { id identifier url } }
|
|
291
|
+
}`,
|
|
292
|
+
variables: { input }
|
|
293
|
+
});
|
|
294
|
+
if (!data.issueCreate.success) throw new Error("Linear refused to create the issue");
|
|
295
|
+
return {
|
|
296
|
+
identifier: data.issueCreate.issue.identifier,
|
|
297
|
+
url: data.issueCreate.issue.url
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
type: "closeIssue",
|
|
303
|
+
label: "Close an issue",
|
|
304
|
+
description: "Move an issue to the first completed state its team defines.",
|
|
305
|
+
// Closing an issue that is already closed lands it in the same place.
|
|
306
|
+
idempotent: true,
|
|
307
|
+
inputs: [
|
|
308
|
+
{ key: "identifier", label: "Issue", required: true, description: "e.g. ENG-123" }
|
|
309
|
+
],
|
|
310
|
+
outputs: [{ key: "state", description: "The state it now holds" }],
|
|
311
|
+
async run(args, { config }) {
|
|
312
|
+
const apiKey = required(config, "apiKey", "LINEAR_API_KEY");
|
|
313
|
+
const identifier = text(args.identifier);
|
|
314
|
+
if (!identifier) throw new Error("identifier is required (e.g. ENG-123)");
|
|
315
|
+
const issue = await resolveIssueWithTeam(apiKey, identifier, fetchImpl);
|
|
316
|
+
if (!issue) throw new Error(`Issue ${identifier} not found`);
|
|
317
|
+
const stateId = await resolveCompletedStateId(apiKey, issue.teamId, fetchImpl);
|
|
318
|
+
if (!stateId) {
|
|
319
|
+
throw new Error(`Team ${issue.teamKey} has no completed state to move it to.`);
|
|
320
|
+
}
|
|
321
|
+
const data = await linearGraphQL({
|
|
322
|
+
apiKey,
|
|
323
|
+
...fetchImpl && { fetchImpl },
|
|
324
|
+
query: `mutation CloseIssue($id: String!, $input: IssueUpdateInput!) {
|
|
325
|
+
issueUpdate(id: $id, input: $input) { success issue { id state { name } } }
|
|
326
|
+
}`,
|
|
327
|
+
variables: { id: issue.id, input: { stateId } }
|
|
328
|
+
});
|
|
329
|
+
if (!data.issueUpdate.success) throw new Error("Linear refused to close the issue");
|
|
330
|
+
return { state: data.issueUpdate.issue.state.name };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/entry.ts
|
|
338
|
+
import { realpathSync } from "fs";
|
|
339
|
+
import { fileURLToPath } from "url";
|
|
340
|
+
import { serveConnector } from "@vornrun/connector-sdk";
|
|
341
|
+
function isEntryPoint(moduleUrl, entry = process.argv[1]) {
|
|
342
|
+
if (!entry) return false;
|
|
343
|
+
try {
|
|
344
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(moduleUrl));
|
|
345
|
+
} catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function serveIfEntryPoint(connector, moduleUrl, serve = serveConnector) {
|
|
350
|
+
if (!isEntryPoint(moduleUrl)) return false;
|
|
351
|
+
void serve(connector);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/index.ts
|
|
356
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
357
|
+
var linearConnector = createLinearConnector({ version });
|
|
358
|
+
serveIfEntryPoint(linearConnector, import.meta.url);
|
|
359
|
+
export {
|
|
360
|
+
createLinearConnector,
|
|
361
|
+
linearConnector
|
|
362
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vornrun/connector-linear",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Trigger workflows from Linear issues.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/vorn-run/connectors.git",
|
|
10
|
+
"directory": "packages/ado"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"vorn-connector-linear": "dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm --target node22 --clean",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@vornrun/connector-sdk": "^0.5.7",
|
|
26
|
+
"zod": "^4.4.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.10.2",
|
|
30
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
31
|
+
"tsup": "^8.5.1",
|
|
32
|
+
"typescript": "^6.0.3",
|
|
33
|
+
"vitest": "^4.1.10"
|
|
34
|
+
},
|
|
35
|
+
"vorn": {
|
|
36
|
+
"category": "Development",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"linear",
|
|
39
|
+
"issues",
|
|
40
|
+
"tickets",
|
|
41
|
+
"engineering",
|
|
42
|
+
"backlog"
|
|
43
|
+
],
|
|
44
|
+
"auth": "Uses a Linear personal API key, created at linear.app/settings/api."
|
|
45
|
+
}
|
|
46
|
+
}
|