@cat-factory/integrations 0.24.1 → 0.25.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 +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/documents/ConfluenceProvider.d.ts +1 -1
- package/dist/modules/documents/GitHubDocsProvider.d.ts +1 -1
- package/dist/modules/documents/LinearDocumentProvider.d.ts +24 -0
- package/dist/modules/documents/LinearDocumentProvider.d.ts.map +1 -0
- package/dist/modules/documents/LinearDocumentProvider.js +38 -0
- package/dist/modules/documents/LinearDocumentProvider.js.map +1 -0
- package/dist/modules/documents/NotionProvider.d.ts +1 -1
- package/dist/modules/documents/linear-docs.logic.d.ts +35 -0
- package/dist/modules/documents/linear-docs.logic.d.ts.map +1 -0
- package/dist/modules/documents/linear-docs.logic.js +110 -0
- package/dist/modules/documents/linear-docs.logic.js.map +1 -0
- package/dist/modules/shared/linear.client.d.ts +75 -0
- package/dist/modules/shared/linear.client.d.ts.map +1 -0
- package/dist/modules/shared/linear.client.js +192 -0
- package/dist/modules/shared/linear.client.js.map +1 -0
- package/dist/modules/tasks/GitHubIssuesProvider.d.ts +1 -1
- package/dist/modules/tasks/JiraProvider.d.ts +1 -1
- package/dist/modules/tasks/LinearTaskProvider.d.ts +33 -0
- package/dist/modules/tasks/LinearTaskProvider.d.ts.map +1 -0
- package/dist/modules/tasks/LinearTaskProvider.js +99 -0
- package/dist/modules/tasks/LinearTaskProvider.js.map +1 -0
- package/dist/modules/tasks/linear.logic.d.ts +105 -0
- package/dist/modules/tasks/linear.logic.d.ts.map +1 -0
- package/dist/modules/tasks/linear.logic.js +176 -0
- package/dist/modules/tasks/linear.logic.js.map +1 -0
- package/dist/modules/tracker/TicketTrackerService.d.ts +13 -2
- package/dist/modules/tracker/TicketTrackerService.d.ts.map +1 -1
- package/dist/modules/tracker/TicketTrackerService.js +18 -0
- package/dist/modules/tracker/TicketTrackerService.js.map +1 -1
- package/dist/modules/tracker/linear.create.logic.d.ts +16 -0
- package/dist/modules/tracker/linear.create.logic.d.ts.map +1 -0
- package/dist/modules/tracker/linear.create.logic.js +34 -0
- package/dist/modules/tracker/linear.create.logic.js.map +1 -0
- package/dist/modules/tracker/linear.writeback.logic.d.ts +29 -0
- package/dist/modules/tracker/linear.writeback.logic.d.ts.map +1 -0
- package/dist/modules/tracker/linear.writeback.logic.js +47 -0
- package/dist/modules/tracker/linear.writeback.logic.js.map +1 -0
- package/dist/modules/writeback/IssueWritebackService.d.ts +24 -2
- package/dist/modules/writeback/IssueWritebackService.d.ts.map +1 -1
- package/dist/modules/writeback/IssueWritebackService.js +52 -0
- package/dist/modules/writeback/IssueWritebackService.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Shared Linear GraphQL transport. Linear exposes a single GraphQL endpoint, so
|
|
2
|
+
// every Linear consumer (the document source, the task source, ticket filing and
|
|
3
|
+
// PR writeback) talks to the SAME host with the SAME auth scheme — this module is
|
|
4
|
+
// that one place. It is runtime-neutral (global `fetch`, present on both the
|
|
5
|
+
// Cloudflare and Node facades) and host-pinned to `api.linear.app`, following the
|
|
6
|
+
// per-hop redirect guard + capped body read the NotionProvider uses so a hostile
|
|
7
|
+
// redirect can't leak the API key or OOM the isolate.
|
|
8
|
+
//
|
|
9
|
+
// OAuth-ready seam: auth is an opaque object today carrying a personal API key
|
|
10
|
+
// (sent as the raw `authorization` value). When OAuth lands, a `{ token }` variant
|
|
11
|
+
// emits `authorization: Bearer <token>` with no change to any caller — see
|
|
12
|
+
// `linearAuthHeader`.
|
|
13
|
+
export const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
|
|
14
|
+
const LINEAR_API_HOST = 'api.linear.app';
|
|
15
|
+
const USER_AGENT = 'cat-factory';
|
|
16
|
+
/** Bound the redirect chain so the fixed API host can't 302 us elsewhere. */
|
|
17
|
+
const MAX_REDIRECTS = 5;
|
|
18
|
+
/** Hard cap on the bytes read off any response body, to protect the isolate. */
|
|
19
|
+
const MAX_RESPONSE_BYTES = 5_000_000;
|
|
20
|
+
/** Build the `authorization` header for a Linear request (API key raw, OAuth `Bearer`). */
|
|
21
|
+
export function linearAuthHeader(auth) {
|
|
22
|
+
return 'token' in auth ? `Bearer ${auth.token}` : auth.apiKey;
|
|
23
|
+
}
|
|
24
|
+
/** Carries the HTTP status so callers can surface a meaningful error. */
|
|
25
|
+
export class LinearApiError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
constructor(status, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.status = status;
|
|
30
|
+
this.name = 'LinearApiError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate a parsed GraphQL envelope and return its `data`. Pure (no I/O) so both
|
|
35
|
+
* the safe global-fetch client below and the FetchLike-driven tracker/writeback
|
|
36
|
+
* services share one error policy. Throws {@link LinearApiError} on a non-OK
|
|
37
|
+
* status, a top-level `errors[]`, or a missing `data`.
|
|
38
|
+
*/
|
|
39
|
+
export function unwrapLinearData(status, ok, parsed) {
|
|
40
|
+
const envelope = (parsed ?? {});
|
|
41
|
+
if (!ok) {
|
|
42
|
+
const detail = envelope.errors?.map((e) => e.message).join('; ');
|
|
43
|
+
throw new LinearApiError(status, `Linear GraphQL → ${status}${detail ? `: ${detail}` : ''}`);
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(envelope.errors) && envelope.errors.length > 0) {
|
|
46
|
+
const detail = envelope.errors.map((e) => e.message ?? 'unknown error').join('; ');
|
|
47
|
+
throw new LinearApiError(status, `Linear GraphQL error: ${detail}`);
|
|
48
|
+
}
|
|
49
|
+
if (envelope.data == null) {
|
|
50
|
+
throw new LinearApiError(502, 'Linear GraphQL returned no data');
|
|
51
|
+
}
|
|
52
|
+
return envelope.data;
|
|
53
|
+
}
|
|
54
|
+
/** The header set every Linear GraphQL POST sends (API key raw, OAuth `Bearer`). */
|
|
55
|
+
export function linearRequestHeaders(auth) {
|
|
56
|
+
return {
|
|
57
|
+
authorization: linearAuthHeader(auth),
|
|
58
|
+
accept: 'application/json',
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
'user-agent': USER_AGENT,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Run a Linear GraphQL operation through a caller-injected `fetch` (the write path:
|
|
65
|
+
* ticket filing + PR writeback), returning the validated `data`. This is the single
|
|
66
|
+
* place those services build the request + apply the {@link unwrapLinearData} error
|
|
67
|
+
* policy, so the header set and error handling can't drift between call sites. The
|
|
68
|
+
* read/import path uses {@link LinearGraphqlClient} instead (it additionally pins
|
|
69
|
+
* the host + caps the body on the real `fetch`). `fetchImpl` is injected so the
|
|
70
|
+
* services stay unit-testable with a fake transport (mirrors the Jira pattern).
|
|
71
|
+
*/
|
|
72
|
+
export async function postLinearGraphql(fetchImpl, auth, document, variables) {
|
|
73
|
+
const res = await fetchImpl(LINEAR_GRAPHQL_URL, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: linearRequestHeaders(auth),
|
|
76
|
+
body: JSON.stringify({ query: document, variables }),
|
|
77
|
+
});
|
|
78
|
+
return unwrapLinearData(res.status, res.ok, await res.json().catch(() => null));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The Linear API host is fixed, so any redirect must stay on `api.linear.app`
|
|
82
|
+
* over https — a redirect off-host (e.g. to an internal address) is treated as an
|
|
83
|
+
* SSRF attempt and rejected. Mirrors the NotionProvider guard.
|
|
84
|
+
*/
|
|
85
|
+
function assertSafeLinearUrl(url) {
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(url);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new LinearApiError(502, `Linear request URL is invalid: ${url}`);
|
|
92
|
+
}
|
|
93
|
+
if (parsed.protocol !== 'https:') {
|
|
94
|
+
throw new LinearApiError(502, 'Linear request must use https');
|
|
95
|
+
}
|
|
96
|
+
if (parsed.hostname.toLowerCase() !== LINEAR_API_HOST) {
|
|
97
|
+
throw new LinearApiError(502, `Linear redirect to a disallowed host: ${parsed.hostname}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** `fetch` with redirects followed by hand so the host guard runs against EVERY hop. */
|
|
101
|
+
async function safeFetch(url, init) {
|
|
102
|
+
let current = url;
|
|
103
|
+
for (let hop = 0;; hop++) {
|
|
104
|
+
assertSafeLinearUrl(current);
|
|
105
|
+
const res = await fetch(current, { ...init, redirect: 'manual' });
|
|
106
|
+
if (res.status >= 300 && res.status < 400) {
|
|
107
|
+
const location = res.headers.get('location');
|
|
108
|
+
if (!location)
|
|
109
|
+
return res;
|
|
110
|
+
if (hop >= MAX_REDIRECTS)
|
|
111
|
+
throw new LinearApiError(502, 'Linear returned too many redirects');
|
|
112
|
+
current = new URL(location, current).toString();
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Read a response body with a running byte cap so a huge response can't OOM the isolate. */
|
|
119
|
+
async function readCappedText(res, maxBytes) {
|
|
120
|
+
const declared = res.headers.get('content-length');
|
|
121
|
+
if (declared && Number(declared) > maxBytes) {
|
|
122
|
+
throw new LinearApiError(502, 'Linear response too large');
|
|
123
|
+
}
|
|
124
|
+
const body = res.body;
|
|
125
|
+
if (!body)
|
|
126
|
+
return '';
|
|
127
|
+
const reader = body.getReader();
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let total = 0;
|
|
130
|
+
try {
|
|
131
|
+
for (;;) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done)
|
|
134
|
+
break;
|
|
135
|
+
if (!value)
|
|
136
|
+
continue;
|
|
137
|
+
total += value.byteLength;
|
|
138
|
+
if (total > maxBytes) {
|
|
139
|
+
await reader.cancel();
|
|
140
|
+
throw new LinearApiError(502, 'Linear response too large');
|
|
141
|
+
}
|
|
142
|
+
chunks.push(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
reader.releaseLock();
|
|
147
|
+
}
|
|
148
|
+
const merged = new Uint8Array(total);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
for (const c of chunks) {
|
|
151
|
+
merged.set(c, offset);
|
|
152
|
+
offset += c.byteLength;
|
|
153
|
+
}
|
|
154
|
+
return new TextDecoder().decode(merged);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A thin, safe Linear GraphQL client used by the document + task providers (the
|
|
158
|
+
* read/import path). It runs the host-pinned redirect guard + capped read on the
|
|
159
|
+
* real `fetch`. The ticket-filing / writeback services do NOT use this class: they
|
|
160
|
+
* post through their own injected `FetchLike` (for testability, matching the Jira
|
|
161
|
+
* pattern) and share only the pure `unwrapLinearData` / `linearAuthHeader` helpers.
|
|
162
|
+
*/
|
|
163
|
+
export class LinearGraphqlClient {
|
|
164
|
+
auth;
|
|
165
|
+
constructor(auth) {
|
|
166
|
+
this.auth = auth;
|
|
167
|
+
}
|
|
168
|
+
/** Run a GraphQL document and return its validated `data`. */
|
|
169
|
+
async query(document, variables = {}) {
|
|
170
|
+
const res = await safeFetch(LINEAR_GRAPHQL_URL, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
authorization: linearAuthHeader(this.auth),
|
|
174
|
+
accept: 'application/json',
|
|
175
|
+
'content-type': 'application/json',
|
|
176
|
+
'user-agent': USER_AGENT,
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify({ query: document, variables }),
|
|
179
|
+
});
|
|
180
|
+
const text = await readCappedText(res, MAX_RESPONSE_BYTES).catch(() => '');
|
|
181
|
+
const parsed = (() => {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(text);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
return unwrapLinearData(res.status, res.ok, parsed);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=linear.client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linear.client.js","sourceRoot":"","sources":["../../../src/modules/shared/linear.client.ts"],"names":[],"mappings":"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"}
|
|
@@ -8,7 +8,7 @@ export declare class GitHubIssuesProvider implements TaskSourceProvider {
|
|
|
8
8
|
private readonly deps;
|
|
9
9
|
readonly kind: 'github';
|
|
10
10
|
readonly descriptor: {
|
|
11
|
-
source: "github" | "jira";
|
|
11
|
+
source: "github" | "jira" | "linear";
|
|
12
12
|
label: string;
|
|
13
13
|
icon: string;
|
|
14
14
|
credentialFields: {
|
|
@@ -7,7 +7,7 @@ export declare class JiraApiError extends Error {
|
|
|
7
7
|
export declare class JiraProvider implements TaskSourceProvider {
|
|
8
8
|
readonly kind: 'jira';
|
|
9
9
|
readonly descriptor: {
|
|
10
|
-
source: "github" | "jira";
|
|
10
|
+
source: "github" | "jira" | "linear";
|
|
11
11
|
label: string;
|
|
12
12
|
icon: string;
|
|
13
13
|
credentialFields: {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type TaskContent, type TaskCredentials, type TaskSearchResult, type TaskSourceDiagnostic, type TaskSourceProvider, type NormalizedTaskConnection } from '@cat-factory/kernel';
|
|
2
|
+
export declare class LinearTaskProvider implements TaskSourceProvider {
|
|
3
|
+
readonly kind: 'linear';
|
|
4
|
+
readonly descriptor: {
|
|
5
|
+
source: "github" | "jira" | "linear";
|
|
6
|
+
label: string;
|
|
7
|
+
icon: string;
|
|
8
|
+
credentialFields: {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
help?: string | undefined;
|
|
12
|
+
placeholder?: string | undefined;
|
|
13
|
+
secret?: boolean | undefined;
|
|
14
|
+
}[];
|
|
15
|
+
refLabel: string;
|
|
16
|
+
refPlaceholder: string;
|
|
17
|
+
searchable?: boolean | undefined;
|
|
18
|
+
};
|
|
19
|
+
normalizeConnection(input: TaskCredentials): NormalizedTaskConnection;
|
|
20
|
+
parseRef(input: string): string | null;
|
|
21
|
+
fetchTask(credentials: TaskCredentials, externalId: string): Promise<TaskContent>;
|
|
22
|
+
search(credentials: TaskCredentials, query: string): Promise<TaskSearchResult[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Live setup check: read `viewer` (the cheapest authenticated query) with the
|
|
25
|
+
* stored key. A 401/403 from Linear's GraphQL surfaces as auth_failed/forbidden;
|
|
26
|
+
* a thrown fetch (DNS/network) ⇒ unreachable. Resolves (never rejects), per the port.
|
|
27
|
+
*/
|
|
28
|
+
diagnose(input: {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
credentials: TaskCredentials | null;
|
|
31
|
+
}): Promise<TaskSourceDiagnostic>;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=LinearTaskProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LinearTaskProvider.d.ts","sourceRoot":"","sources":["../../../src/modules/tasks/LinearTaskProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,KAAK,wBAAwB,EAC9B,MAAM,qBAAqB,CAAA;AAuB5B,qBAAa,kBAAmB,YAAW,kBAAkB;IAC3D,QAAQ,CAAC,IAAI,EAAG,QAAQ,CAAS;IACjC,QAAQ,CAAC,UAAU;;;;;;;;;;;;;;MAAyB;IAE5C,mBAAmB,CAAC,KAAK,EAAE,eAAe,GAAG,wBAAwB,CAMpE;IAED,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAErC;IAEK,SAAS,CAAC,WAAW,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAMtF;IAEK,MAAM,CAAC,WAAW,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAOrF;IAED;;;;OAIG;IACG,QAAQ,CAAC,KAAK,EAAE;QACpB,WAAW,EAAE,MAAM,CAAA;QACnB,WAAW,EAAE,eAAe,GAAG,IAAI,CAAA;KACpC,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAqDhC;CACF"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ValidationError, } from '@cat-factory/kernel';
|
|
2
|
+
import { LinearApiError, LinearGraphqlClient } from '../shared/linear.client.js';
|
|
3
|
+
import { LINEAR_ISSUE_QUERY, LINEAR_SEARCH_ISSUES_QUERY, LINEAR_TASK_DESCRIPTOR, LINEAR_VIEWER_QUERY, mapLinearIssue, mapLinearSearchResults, parseLinearRef, } from './linear.logic.js';
|
|
4
|
+
// LinearTaskProvider: the task-source provider for Linear. It authenticates with a
|
|
5
|
+
// personal API key against Linear's single GraphQL endpoint (via the shared
|
|
6
|
+
// host-pinned, redirect-safe client) and maps an issue onto the structured
|
|
7
|
+
// {@link TaskContent} — status / assignee / priority / labels + the Markdown
|
|
8
|
+
// description, comments, parent/sub-issues and dependency relations. All
|
|
9
|
+
// Linear-specific pure logic (ref parsing, mapping, the GraphQL documents) lives in
|
|
10
|
+
// `linear.logic` so it is unit-testable; this class is the thin transport.
|
|
11
|
+
//
|
|
12
|
+
// Runtime-neutral: it depends only on the kernel ports + the shared client (global
|
|
13
|
+
// `fetch`), so the Cloudflare and Node facades compose the SAME class.
|
|
14
|
+
export class LinearTaskProvider {
|
|
15
|
+
kind = 'linear';
|
|
16
|
+
descriptor = LINEAR_TASK_DESCRIPTOR;
|
|
17
|
+
normalizeConnection(input) {
|
|
18
|
+
const apiKey = input.apiKey?.trim();
|
|
19
|
+
if (!apiKey) {
|
|
20
|
+
throw new ValidationError('Linear requires a personal API key');
|
|
21
|
+
}
|
|
22
|
+
return { credentials: { apiKey }, label: 'Linear workspace' };
|
|
23
|
+
}
|
|
24
|
+
parseRef(input) {
|
|
25
|
+
return parseLinearRef(input);
|
|
26
|
+
}
|
|
27
|
+
async fetchTask(credentials, externalId) {
|
|
28
|
+
const client = new LinearGraphqlClient({ apiKey: credentials.apiKey });
|
|
29
|
+
const data = await client.query(LINEAR_ISSUE_QUERY, {
|
|
30
|
+
id: externalId,
|
|
31
|
+
});
|
|
32
|
+
return mapLinearIssue(data);
|
|
33
|
+
}
|
|
34
|
+
async search(credentials, query) {
|
|
35
|
+
const client = new LinearGraphqlClient({ apiKey: credentials.apiKey });
|
|
36
|
+
const data = await client.query(LINEAR_SEARCH_ISSUES_QUERY, { term: query });
|
|
37
|
+
return mapLinearSearchResults(data);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Live setup check: read `viewer` (the cheapest authenticated query) with the
|
|
41
|
+
* stored key. A 401/403 from Linear's GraphQL surfaces as auth_failed/forbidden;
|
|
42
|
+
* a thrown fetch (DNS/network) ⇒ unreachable. Resolves (never rejects), per the port.
|
|
43
|
+
*/
|
|
44
|
+
async diagnose(input) {
|
|
45
|
+
const apiKey = input.credentials?.apiKey;
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
return {
|
|
48
|
+
source: 'linear',
|
|
49
|
+
ok: false,
|
|
50
|
+
status: 'not_connected',
|
|
51
|
+
message: 'Linear has no stored credentials. Connect it with a personal API key.',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const client = new LinearGraphqlClient({ apiKey });
|
|
56
|
+
const data = await client.query(LINEAR_VIEWER_QUERY);
|
|
57
|
+
return {
|
|
58
|
+
source: 'linear',
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 'ready',
|
|
61
|
+
message: 'Authenticated to Linear.',
|
|
62
|
+
detail: data.viewer?.name ? `Signed in as ${data.viewer.name}.` : null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err instanceof LinearApiError) {
|
|
67
|
+
if (err.status === 401) {
|
|
68
|
+
return {
|
|
69
|
+
source: 'linear',
|
|
70
|
+
ok: false,
|
|
71
|
+
status: 'auth_failed',
|
|
72
|
+
message: 'Linear rejected the API key (401). Generate a fresh personal API key and reconnect.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (err.status === 403) {
|
|
76
|
+
return {
|
|
77
|
+
source: 'linear',
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 'forbidden',
|
|
80
|
+
message: 'Linear authenticated the key but denied access (403). Check its scopes.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
source: 'linear',
|
|
85
|
+
ok: false,
|
|
86
|
+
status: 'error',
|
|
87
|
+
message: err.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
source: 'linear',
|
|
92
|
+
ok: false,
|
|
93
|
+
status: 'unreachable',
|
|
94
|
+
message: "Couldn't reach Linear. Check network connectivity, then re-check.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=LinearTaskProvider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LinearTaskProvider.js","sourceRoot":"","sources":["../../../src/modules/tasks/LinearTaskProvider.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { TaskContent, TaskDependencyLink, TaskSearchResult, TaskSourceDescriptor } from '@cat-factory/kernel';
|
|
2
|
+
/** What the connect UI renders, and which credentials the provider needs. */
|
|
3
|
+
export declare const LINEAR_TASK_DESCRIPTOR: TaskSourceDescriptor;
|
|
4
|
+
/**
|
|
5
|
+
* Fetch a single issue with everything the structured {@link TaskContent} needs.
|
|
6
|
+
* Linear's `issue(id:)` resolves the human identifier (`ENG-123`) as well as the
|
|
7
|
+
* UUID, so the stored external id is a valid argument.
|
|
8
|
+
*/
|
|
9
|
+
export declare const LINEAR_ISSUE_QUERY = "query Issue($id: String!) {\n issue(id: $id) {\n identifier\n title\n description\n url\n priorityLabel\n state { name type }\n assignee { name }\n labels { nodes { name } }\n parent { identifier }\n children { nodes { identifier } }\n comments { nodes { user { name } createdAt body } }\n relations { nodes { type relatedIssue { identifier } } }\n inverseRelations { nodes { type issue { identifier } } }\n }\n}";
|
|
10
|
+
/** Free-text issue search (used to populate the import picker). */
|
|
11
|
+
export declare const LINEAR_SEARCH_ISSUES_QUERY = "query SearchIssues($term: String!) {\n searchIssues(term: $term, first: 20) {\n nodes { identifier title url state { name } }\n }\n}";
|
|
12
|
+
/** Cheapest authenticated read, for the live "check setup" probe. */
|
|
13
|
+
export declare const LINEAR_VIEWER_QUERY = "query Viewer { viewer { name } }";
|
|
14
|
+
interface LinearIssueNode {
|
|
15
|
+
identifier?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string | null;
|
|
18
|
+
url?: string;
|
|
19
|
+
priorityLabel?: string | null;
|
|
20
|
+
state?: {
|
|
21
|
+
name?: string;
|
|
22
|
+
type?: string;
|
|
23
|
+
} | null;
|
|
24
|
+
assignee?: {
|
|
25
|
+
name?: string;
|
|
26
|
+
} | null;
|
|
27
|
+
labels?: {
|
|
28
|
+
nodes?: {
|
|
29
|
+
name?: string;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
parent?: {
|
|
33
|
+
identifier?: string;
|
|
34
|
+
} | null;
|
|
35
|
+
children?: {
|
|
36
|
+
nodes?: {
|
|
37
|
+
identifier?: string;
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
40
|
+
comments?: {
|
|
41
|
+
nodes?: LinearCommentNode[];
|
|
42
|
+
};
|
|
43
|
+
relations?: {
|
|
44
|
+
nodes?: {
|
|
45
|
+
type?: string;
|
|
46
|
+
relatedIssue?: {
|
|
47
|
+
identifier?: string;
|
|
48
|
+
};
|
|
49
|
+
}[];
|
|
50
|
+
};
|
|
51
|
+
inverseRelations?: {
|
|
52
|
+
nodes?: {
|
|
53
|
+
type?: string;
|
|
54
|
+
issue?: {
|
|
55
|
+
identifier?: string;
|
|
56
|
+
};
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
interface LinearCommentNode {
|
|
61
|
+
user?: {
|
|
62
|
+
name?: string;
|
|
63
|
+
};
|
|
64
|
+
createdAt?: string;
|
|
65
|
+
body?: string | null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a Linear issue identifier from raw user input: a bare identifier
|
|
69
|
+
* (`ENG-123`), or a `linear.app/.../issue/ENG-123` URL. The identifier is
|
|
70
|
+
* upper-cased (Linear keys are canonically upper-case). A URL is only accepted
|
|
71
|
+
* when it is hosted on `linear.app` — mirroring `parseLinearDocRef`, so a foreign
|
|
72
|
+
* URL that merely contains an `/issue/<key>`-looking path can't be mistaken for a
|
|
73
|
+
* Linear reference. Returns null when nothing parses.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseLinearRef(input: string): string | null;
|
|
76
|
+
/**
|
|
77
|
+
* Map an issue's `relations` + `inverseRelations` onto normalized
|
|
78
|
+
* {@link TaskDependencyLink}s. Linear models a "blocks" dependency as a single
|
|
79
|
+
* relation with a direction: in `relations` THIS issue is the source (it `blocks`
|
|
80
|
+
* the related issue); in `inverseRelations` it is the target (so it is `blockedBy`
|
|
81
|
+
* the other issue). Non-blocking relation types (`related`/`duplicate`/`similar`)
|
|
82
|
+
* are recorded as `relates` (the importer skips those for sequencing). Lenient:
|
|
83
|
+
* malformed entries are dropped, and duplicates are de-duped.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mapLinearRelations(issue: LinearIssueNode): TaskDependencyLink[];
|
|
86
|
+
/** Map an `issue` GraphQL payload onto the structured {@link TaskContent}. */
|
|
87
|
+
export declare function mapLinearIssue(data: {
|
|
88
|
+
issue?: LinearIssueNode | null;
|
|
89
|
+
}): TaskContent;
|
|
90
|
+
interface LinearSearchNode {
|
|
91
|
+
identifier?: string;
|
|
92
|
+
title?: string;
|
|
93
|
+
url?: string;
|
|
94
|
+
state?: {
|
|
95
|
+
name?: string;
|
|
96
|
+
} | null;
|
|
97
|
+
}
|
|
98
|
+
/** Map a `searchIssues` payload onto lean {@link TaskSearchResult} hits. */
|
|
99
|
+
export declare function mapLinearSearchResults(data: {
|
|
100
|
+
searchIssues?: {
|
|
101
|
+
nodes?: LinearSearchNode[];
|
|
102
|
+
};
|
|
103
|
+
}): TaskSearchResult[];
|
|
104
|
+
export {};
|
|
105
|
+
//# sourceMappingURL=linear.logic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linear.logic.d.ts","sourceRoot":"","sources":["../../../src/modules/tasks/linear.logic.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Linear-specific pure logic, kept out of the provider so it is unit-testable
|
|
2
|
+
// without a live API: the connect-form descriptor, parsing an issue identifier out
|
|
3
|
+
// of user input, the GraphQL documents, and mapping an issue onto the structured
|
|
4
|
+
// {@link TaskContent}. Linear descriptions / comment bodies are already Markdown,
|
|
5
|
+
// so there is no ADF-style conversion (unlike Jira) — fields pass through normalized.
|
|
6
|
+
/** What the connect UI renders, and which credentials the provider needs. */
|
|
7
|
+
export const LINEAR_TASK_DESCRIPTOR = {
|
|
8
|
+
source: 'linear',
|
|
9
|
+
label: 'Linear',
|
|
10
|
+
icon: 'i-lucide-square-kanban',
|
|
11
|
+
credentialFields: [
|
|
12
|
+
{
|
|
13
|
+
key: 'apiKey',
|
|
14
|
+
label: 'Personal API key',
|
|
15
|
+
secret: true,
|
|
16
|
+
placeholder: 'lin_api_…',
|
|
17
|
+
help: 'Create one at linear.app → Settings → Security & access → Personal API keys',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
refLabel: 'Issue identifier or URL',
|
|
21
|
+
refPlaceholder: 'ENG-123 or https://linear.app/acme/issue/ENG-123',
|
|
22
|
+
searchable: true,
|
|
23
|
+
};
|
|
24
|
+
// ---- GraphQL operations ----------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Fetch a single issue with everything the structured {@link TaskContent} needs.
|
|
27
|
+
* Linear's `issue(id:)` resolves the human identifier (`ENG-123`) as well as the
|
|
28
|
+
* UUID, so the stored external id is a valid argument.
|
|
29
|
+
*/
|
|
30
|
+
export const LINEAR_ISSUE_QUERY = `query Issue($id: String!) {
|
|
31
|
+
issue(id: $id) {
|
|
32
|
+
identifier
|
|
33
|
+
title
|
|
34
|
+
description
|
|
35
|
+
url
|
|
36
|
+
priorityLabel
|
|
37
|
+
state { name type }
|
|
38
|
+
assignee { name }
|
|
39
|
+
labels { nodes { name } }
|
|
40
|
+
parent { identifier }
|
|
41
|
+
children { nodes { identifier } }
|
|
42
|
+
comments { nodes { user { name } createdAt body } }
|
|
43
|
+
relations { nodes { type relatedIssue { identifier } } }
|
|
44
|
+
inverseRelations { nodes { type issue { identifier } } }
|
|
45
|
+
}
|
|
46
|
+
}`;
|
|
47
|
+
/** Free-text issue search (used to populate the import picker). */
|
|
48
|
+
export const LINEAR_SEARCH_ISSUES_QUERY = `query SearchIssues($term: String!) {
|
|
49
|
+
searchIssues(term: $term, first: 20) {
|
|
50
|
+
nodes { identifier title url state { name } }
|
|
51
|
+
}
|
|
52
|
+
}`;
|
|
53
|
+
/** Cheapest authenticated read, for the live "check setup" probe. */
|
|
54
|
+
export const LINEAR_VIEWER_QUERY = `query Viewer { viewer { name } }`;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a Linear issue identifier from raw user input: a bare identifier
|
|
57
|
+
* (`ENG-123`), or a `linear.app/.../issue/ENG-123` URL. The identifier is
|
|
58
|
+
* upper-cased (Linear keys are canonically upper-case). A URL is only accepted
|
|
59
|
+
* when it is hosted on `linear.app` — mirroring `parseLinearDocRef`, so a foreign
|
|
60
|
+
* URL that merely contains an `/issue/<key>`-looking path can't be mistaken for a
|
|
61
|
+
* Linear reference. Returns null when nothing parses.
|
|
62
|
+
*/
|
|
63
|
+
export function parseLinearRef(input) {
|
|
64
|
+
const trimmed = input.trim();
|
|
65
|
+
const KEY = /[A-Za-z][A-Za-z0-9]*-\d+/;
|
|
66
|
+
// A bare identifier (anything that is exactly a Linear key).
|
|
67
|
+
if (new RegExp(`^${KEY.source}$`).test(trimmed))
|
|
68
|
+
return trimmed.toUpperCase();
|
|
69
|
+
// Otherwise it must be a linear.app URL whose path carries an `/issue/<key>`.
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(trimmed);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (url.hostname.toLowerCase() !== 'linear.app')
|
|
78
|
+
return null;
|
|
79
|
+
const issue = url.pathname.match(new RegExp(`/issue/(${KEY.source})`));
|
|
80
|
+
return issue ? issue[1].toUpperCase() : null;
|
|
81
|
+
}
|
|
82
|
+
/** Collapse runaway blank lines in already-Markdown prose. */
|
|
83
|
+
function normalizeMarkdown(text) {
|
|
84
|
+
return (text ?? '')
|
|
85
|
+
.replace(/\r\n/g, '\n')
|
|
86
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Map an issue's `relations` + `inverseRelations` onto normalized
|
|
91
|
+
* {@link TaskDependencyLink}s. Linear models a "blocks" dependency as a single
|
|
92
|
+
* relation with a direction: in `relations` THIS issue is the source (it `blocks`
|
|
93
|
+
* the related issue); in `inverseRelations` it is the target (so it is `blockedBy`
|
|
94
|
+
* the other issue). Non-blocking relation types (`related`/`duplicate`/`similar`)
|
|
95
|
+
* are recorded as `relates` (the importer skips those for sequencing). Lenient:
|
|
96
|
+
* malformed entries are dropped, and duplicates are de-duped.
|
|
97
|
+
*/
|
|
98
|
+
export function mapLinearRelations(issue) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const push = (type, externalId) => {
|
|
102
|
+
if (!externalId)
|
|
103
|
+
return;
|
|
104
|
+
const key = `${type}:${externalId.toUpperCase()}`;
|
|
105
|
+
if (seen.has(key))
|
|
106
|
+
return;
|
|
107
|
+
seen.add(key);
|
|
108
|
+
out.push({ type, externalId: externalId.toUpperCase() });
|
|
109
|
+
};
|
|
110
|
+
for (const rel of issue.relations?.nodes ?? []) {
|
|
111
|
+
const id = rel.relatedIssue?.identifier;
|
|
112
|
+
if (rel.type === 'blocks')
|
|
113
|
+
push('blocks', id);
|
|
114
|
+
else
|
|
115
|
+
push('relates', id);
|
|
116
|
+
}
|
|
117
|
+
for (const rel of issue.inverseRelations?.nodes ?? []) {
|
|
118
|
+
const id = rel.issue?.identifier;
|
|
119
|
+
if (rel.type === 'blocks')
|
|
120
|
+
push('blockedBy', id);
|
|
121
|
+
else
|
|
122
|
+
push('relates', id);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** Map an `issue` GraphQL payload onto the structured {@link TaskContent}. */
|
|
127
|
+
export function mapLinearIssue(data) {
|
|
128
|
+
const issue = data.issue;
|
|
129
|
+
if (!issue?.identifier)
|
|
130
|
+
throw new Error('Linear returned no issue for the requested identifier');
|
|
131
|
+
const childExternalIds = (issue.children?.nodes ?? [])
|
|
132
|
+
.map((c) => c.identifier)
|
|
133
|
+
.filter((id) => !!id);
|
|
134
|
+
const isEpic = childExternalIds.length > 0;
|
|
135
|
+
const comments = (issue.comments?.nodes ?? []).map((c) => ({
|
|
136
|
+
author: c.user?.name ?? '',
|
|
137
|
+
createdAt: c.createdAt ?? '',
|
|
138
|
+
body: normalizeMarkdown(c.body),
|
|
139
|
+
}));
|
|
140
|
+
return {
|
|
141
|
+
externalId: issue.identifier,
|
|
142
|
+
url: issue.url ?? `https://linear.app/issue/${issue.identifier}`,
|
|
143
|
+
title: issue.title ?? '(untitled)',
|
|
144
|
+
status: issue.state?.name ?? '',
|
|
145
|
+
// Linear has no distinct "issue type"; surface epic vs. plain issue.
|
|
146
|
+
type: isEpic ? 'Epic' : 'Issue',
|
|
147
|
+
assignee: issue.assignee?.name ?? null,
|
|
148
|
+
priority: issue.priorityLabel ?? null,
|
|
149
|
+
labels: (issue.labels?.nodes ?? []).map((l) => l.name ?? '').filter(Boolean),
|
|
150
|
+
description: normalizeMarkdown(issue.description),
|
|
151
|
+
comments,
|
|
152
|
+
isEpic,
|
|
153
|
+
parentExternalId: issue.parent?.identifier ?? null,
|
|
154
|
+
childExternalIds,
|
|
155
|
+
links: mapLinearRelations(issue),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/** Map a `searchIssues` payload onto lean {@link TaskSearchResult} hits. */
|
|
159
|
+
export function mapLinearSearchResults(data) {
|
|
160
|
+
const nodes = data.searchIssues?.nodes ?? [];
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const node of nodes) {
|
|
163
|
+
if (!node.identifier)
|
|
164
|
+
continue;
|
|
165
|
+
out.push({
|
|
166
|
+
source: 'linear',
|
|
167
|
+
externalId: node.identifier,
|
|
168
|
+
title: node.title ?? '(untitled)',
|
|
169
|
+
url: node.url ?? `https://linear.app/issue/${node.identifier}`,
|
|
170
|
+
status: node.state?.name ?? '',
|
|
171
|
+
excerpt: '',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=linear.logic.js.map
|