@dahrk/linear 0.1.0 → 0.1.1

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.
Files changed (62) hide show
  1. package/README.md +24 -12
  2. package/dist/comments.d.ts +36 -0
  3. package/dist/comments.d.ts.map +1 -0
  4. package/dist/comments.js +104 -0
  5. package/dist/comments.js.map +1 -0
  6. package/dist/documents.d.ts +1 -15
  7. package/dist/documents.d.ts.map +1 -1
  8. package/dist/documents.js +37 -27
  9. package/dist/documents.js.map +1 -1
  10. package/dist/format-action.d.ts +25 -0
  11. package/dist/format-action.d.ts.map +1 -0
  12. package/dist/format-action.js +250 -0
  13. package/dist/format-action.js.map +1 -0
  14. package/dist/index.d.ts +113 -11
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +182 -58
  17. package/dist/index.js.map +1 -1
  18. package/dist/issue-graph.d.ts +37 -0
  19. package/dist/issue-graph.d.ts.map +1 -0
  20. package/dist/issue-graph.js +125 -0
  21. package/dist/issue-graph.js.map +1 -0
  22. package/dist/issues.d.ts +3 -2
  23. package/dist/issues.d.ts.map +1 -1
  24. package/dist/issues.js +5 -10
  25. package/dist/issues.js.map +1 -1
  26. package/dist/labels.d.ts +47 -0
  27. package/dist/labels.d.ts.map +1 -1
  28. package/dist/labels.js +71 -23
  29. package/dist/labels.js.map +1 -1
  30. package/dist/linear-client.d.ts +51 -0
  31. package/dist/linear-client.d.ts.map +1 -1
  32. package/dist/linear-client.js +234 -34
  33. package/dist/linear-client.js.map +1 -1
  34. package/dist/oauth.d.ts +11 -10
  35. package/dist/oauth.d.ts.map +1 -1
  36. package/dist/oauth.js +35 -32
  37. package/dist/oauth.js.map +1 -1
  38. package/dist/recording-client.d.ts +20 -2
  39. package/dist/recording-client.d.ts.map +1 -1
  40. package/dist/recording-client.js +39 -1
  41. package/dist/recording-client.js.map +1 -1
  42. package/dist/responding-client.d.ts +49 -0
  43. package/dist/responding-client.d.ts.map +1 -0
  44. package/dist/responding-client.js +47 -0
  45. package/dist/responding-client.js.map +1 -0
  46. package/dist/teams.d.ts +20 -0
  47. package/dist/teams.d.ts.map +1 -0
  48. package/dist/teams.js +32 -0
  49. package/dist/teams.js.map +1 -0
  50. package/package.json +8 -10
  51. package/src/comments.ts +126 -0
  52. package/src/documents.ts +162 -0
  53. package/src/format-action.ts +279 -0
  54. package/src/index.ts +556 -0
  55. package/src/issue-graph.ts +169 -0
  56. package/src/issues.ts +93 -0
  57. package/src/labels.ts +254 -0
  58. package/src/linear-client.ts +449 -0
  59. package/src/oauth.ts +194 -0
  60. package/src/recording-client.ts +141 -0
  61. package/src/responding-client.ts +106 -0
  62. package/src/teams.ts +44 -0
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The real Agent Session client over `@linear/sdk` (build spec section 14). This is the
3
+ * LIVE path - it makes authenticated GraphQL calls to Linear - so it is only constructed
4
+ * when a Connection carries a bearer token; the offline harness uses the recording client.
5
+ *
6
+ * Verified against @linear/sdk v86: `createAgentActivity({ agentSessionId, content, signal?,
7
+ * signalMetadata?, ephemeral? })` (content is an untyped JSONObject - the typed payload shapes
8
+ * are in Linear's agent-interaction docs), `updateAgentSession(id, { plan, externalUrls })`,
9
+ * `agentSession(id)` (resolves the issue), `updateIssue(id, { stateId, delegateId })`,
10
+ * `team(id).states({ filter })`, `viewer`, and `createAttachment({ issueId, url, title, ... })`.
11
+ * There is no app-settable session `state`: Linear derives it from activities and signals, so
12
+ * `setState` is a no-op here.
13
+ */
14
+ import { AgentActivitySignal, AgentActivityType, LinearClient } from "@linear/sdk";
15
+ import { filterExternalUrls } from "./index.js";
16
+ import { formatToolAction } from "./format-action.js";
17
+ import type {
18
+ Activity,
19
+ AgentSessionClient,
20
+ DocumentInput,
21
+ DocumentRef,
22
+ ElicitOption,
23
+ IssueEngagement,
24
+ IssueStateType,
25
+ MoveToReviewOutcome,
26
+ PrAttachment,
27
+ StartIssueOutcome,
28
+ } from "./index.js";
29
+
30
+ /** One query, resolving the disengagement ground truth for a batch of issues. `includeArchived`
31
+ * keeps soft-deleted issues in the result (so they read as archived, not deleted); an id absent
32
+ * from the result was hard-deleted. */
33
+ const ISSUE_ENGAGEMENT_QUERY = /* GraphQL */ `
34
+ query DahrkIssueEngagement($ids: [ID!]!) {
35
+ issues(filter: { id: { in: $ids } }, includeArchived: true, first: 250) {
36
+ nodes {
37
+ id
38
+ archivedAt
39
+ delegateId
40
+ assignee { id }
41
+ state { type }
42
+ }
43
+ }
44
+ }
45
+ `;
46
+
47
+ /** DHK-63: proactively open an agent session on an issue / a comment. Returns the new session id off
48
+ * the AgentSessionPayload so the hub can bind a pre-brief run to it. */
49
+ const SESSION_CREATE_ON_ISSUE = /* GraphQL */ `
50
+ mutation DahrkAgentSessionCreateOnIssue($input: AgentSessionCreateOnIssue!) {
51
+ agentSessionCreateOnIssue(input: $input) {
52
+ agentSession { id }
53
+ }
54
+ }
55
+ `;
56
+
57
+ const SESSION_CREATE_ON_COMMENT = /* GraphQL */ `
58
+ mutation DahrkAgentSessionCreateOnComment($input: AgentSessionCreateOnComment!) {
59
+ agentSessionCreateOnComment(input: $input) {
60
+ agentSession { id }
61
+ }
62
+ }
63
+ `;
64
+
65
+ interface IssueEngagementRow {
66
+ id: string;
67
+ archivedAt?: string | null;
68
+ delegateId?: string | null;
69
+ assignee?: { id?: string | null } | null;
70
+ state?: { type?: string | null } | null;
71
+ }
72
+
73
+ /** The subset of a Linear workflow state that state-selection needs. Kept structural so the live
74
+ * SDK's `WorkflowState` nodes satisfy it and the pure selectors are unit-testable without the SDK. */
75
+ export interface StateLike {
76
+ id: string;
77
+ name: string;
78
+ position: number;
79
+ type: string;
80
+ }
81
+
82
+ /** The configured review-state name. `DAHRK_` is the name; the legacy `SKAKEL_` prefix is still
83
+ * honoured so a host provisioned before the rename keeps working (DHK-440). This package has no
84
+ * dependency on the hub, so it cannot reuse the hub's `envRenamed`. */
85
+ function reviewStateNameEnv(): string | undefined {
86
+ return process.env.DAHRK_REVIEW_STATE_NAME ?? process.env.SKAKEL_REVIEW_STATE_NAME;
87
+ }
88
+
89
+ /** Find the review-target state the way `moveIssueToReview` does, so `startIssue` can EXCLUDE it.
90
+ * Linear has no "review" state type, so it is matched by name: an explicit configured name
91
+ * (`DAHRK_REVIEW_STATE_NAME`) when set, else the first review-ish name. */
92
+ export function findReviewState<T extends StateLike>(states: readonly T[], reviewName?: string): T | undefined {
93
+ const wanted = (reviewName ?? "").trim().toLowerCase();
94
+ return wanted
95
+ ? states.find((s) => s.name.toLowerCase() === wanted)
96
+ : states.find((s) => /review|in review|qa/i.test(s.name));
97
+ }
98
+
99
+ /**
100
+ * Pick the team's working ("In Progress") state for a just-started run. A team can have MORE THAN
101
+ * ONE `started`-typed state (e.g. both "In Progress" and "In Review" are typed `started`), so the
102
+ * naive "lowest-position started state" is ambiguous and can wrongly land the ticket in In Review.
103
+ * Prefer an explicit working-name match (`/in progress|doing|started/i`), else the lowest-position
104
+ * `started` state that is NOT the review state. Returns `undefined` when there is no usable working
105
+ * state (so the caller reports a no-op rather than moving the ticket to review).
106
+ */
107
+ export function selectStartState<T extends StateLike>(states: readonly T[], reviewName?: string): T | undefined {
108
+ const started = states.filter((s) => s.type === "started").sort((a, b) => a.position - b.position);
109
+ if (started.length === 0) return undefined;
110
+ const review = findReviewState(states, reviewName);
111
+ const candidates = started.filter((s) => !review || s.id !== review.id);
112
+ if (candidates.length === 0) return undefined;
113
+ return candidates.find((s) => /in progress|doing|started/i.test(s.name)) ?? candidates[0];
114
+ }
115
+
116
+ /** Returns true when `delegateId` should be written on the issue.
117
+ * Skipping when already equal avoids the "assigned to you" notification on every re-run (DHK-263). */
118
+ export function shouldSetDelegate(issueDelegateId: string | undefined, viewerId: string): boolean {
119
+ return issueDelegateId !== viewerId;
120
+ }
121
+
122
+ /** A document as the issue-documents lookup sees it. Structural, so `findDocumentByTitle` is unit
123
+ * testable without standing up the SDK. */
124
+ export interface DocumentLike {
125
+ id: string;
126
+ /** Optional so the live SDK's non-null `title` is assignable and a null from the API cannot throw. */
127
+ title?: string;
128
+ url?: string;
129
+ }
130
+
131
+ /** The slice of a Linear issue the document lookup needs. */
132
+ export interface IssueDocuments {
133
+ documents(args: { first: number }): Promise<{ nodes: DocumentLike[] }>;
134
+ }
135
+
136
+ /**
137
+ * The document already attached to this issue under `title`, or null when there is none. This is
138
+ * what gives `createDocument` its upsert: Linear keys documents by id only, so a title match on the
139
+ * issue is our stand-in.
140
+ *
141
+ * Titles are compared trimmed and case-insensitively, so a human who lightly re-cases the title does
142
+ * not cause the next run to fork a second copy. The read is bounded and unpaginated at 50, matching
143
+ * `linearDocumentSource.issueDocuments` in `documents.ts`: an issue carrying more than 50 documents
144
+ * silently drops the overflow, which is a deliberate limit rather than a bug.
145
+ */
146
+ export async function findDocumentByTitle(issue: IssueDocuments, title: string): Promise<DocumentLike | null> {
147
+ const wanted = title.trim().toLowerCase();
148
+ if (!wanted) return null;
149
+ const conn = await issue.documents({ first: 50 });
150
+ return conn.nodes.find((d) => (d.title ?? "").trim().toLowerCase() === wanted) ?? null;
151
+ }
152
+
153
+ /** Build the (untyped) activity content payload Linear expects for each activity type. */
154
+ function contentOf(a: Activity): Record<string, unknown> {
155
+ switch (a.type) {
156
+ case "thought":
157
+ return { type: AgentActivityType.Thought, body: a.text };
158
+ case "response":
159
+ return { type: AgentActivityType.Response, body: a.text };
160
+ case "error":
161
+ return { type: AgentActivityType.Error, body: a.text };
162
+ case "action": {
163
+ // DHK-382: render as a human verb + humanised parameter ("Ran · grep ...") instead of the
164
+ // tool name plus raw JSON. formatToolAction is total and defensive: a malformed/truncated
165
+ // preview degrades to the bare verb rather than throwing or posting raw JSON.
166
+ const { action, parameter } = formatToolAction(a.tool, a.text);
167
+ // DHK-385: fold the tool's outcome under the call as the action's `result` (markdown) when the
168
+ // observation has arrived, so the step is one self-contained activity instead of a call plus a
169
+ // separate observation row. Omitted while the action is still in flight (no result yet).
170
+ return { type: AgentActivityType.Action, action, parameter, ...(a.result ? { result: a.result } : {}) };
171
+ }
172
+ }
173
+ }
174
+
175
+ /** Normalise an elicitation option into Linear's `{ label?, value }` shape. */
176
+ function optionOf(o: ElicitOption): { label?: string; value: string } {
177
+ return typeof o === "string" ? { value: o } : { ...(o.label ? { label: o.label } : {}), value: o.value };
178
+ }
179
+
180
+ export function createLinearClient(token: string): AgentSessionClient {
181
+ const client = new LinearClient({ accessToken: token });
182
+ // viewer.id is stable for the life of a Connection token; resolve it once and reuse.
183
+ let cachedAppUserId: string | undefined;
184
+ return {
185
+ async postActivity(sessionId, activity) {
186
+ await client.createAgentActivity({
187
+ agentSessionId: sessionId,
188
+ content: contentOf(activity),
189
+ // Only thought/action may be ephemeral; the caller guarantees this, Linear ignores it otherwise.
190
+ ...(activity.ephemeral ? { ephemeral: true } : {}),
191
+ });
192
+ },
193
+ async raiseElicitation(sessionId, prompt, options) {
194
+ await client.createAgentActivity({
195
+ agentSessionId: sessionId,
196
+ content: { type: AgentActivityType.Elicitation, body: prompt },
197
+ signal: AgentActivitySignal.Select,
198
+ ...(options ? { signalMetadata: { options: options.map(optionOf) } } : {}),
199
+ });
200
+ },
201
+ async suggestRepositories(issueId, candidates) {
202
+ // Linear ranks the candidates we already have access to; we keep its order and never re-sort by
203
+ // confidence ourselves (ordering only - the human picks). `hostname` is required by the API.
204
+ const payload = await client.issueRepositorySuggestions(
205
+ candidates.map((c) => ({ hostname: c.hostname ?? "", repositoryFullName: c.repositoryFullName })),
206
+ issueId,
207
+ );
208
+ return payload.suggestions.map((s) => ({
209
+ repositoryFullName: s.repositoryFullName,
210
+ ...(s.hostname ? { hostname: s.hostname } : {}),
211
+ confidence: s.confidence,
212
+ }));
213
+ },
214
+ async addIssueLabel(issueId, name) {
215
+ // Get-or-create the label by name (issueAddLabel needs an id), then attach it. Idempotent:
216
+ // createIssueLabel only runs when the name is absent, and issueAddLabel no-ops on a label the
217
+ // issue already carries, so a repeat stamp is a safe no-op.
218
+ const labels = await client.issueLabels();
219
+ while (labels.pageInfo.hasNextPage) await labels.fetchNext();
220
+ let labelId = labels.nodes.find((l) => l.name === name)?.id;
221
+ if (!labelId) {
222
+ const created = await client.createIssueLabel({ name });
223
+ labelId = (await created.issueLabel)?.id;
224
+ }
225
+ if (labelId) await client.issueAddLabel(issueId, labelId);
226
+ },
227
+ async requestAuth(sessionId, prompt, auth) {
228
+ // The `auth` signal renders Linear's native account-linking UI from an elicitation; the user
229
+ // completes the link at `auth.url`, then the agent resumes on the follow-up `prompted` webhook.
230
+ await client.createAgentActivity({
231
+ agentSessionId: sessionId,
232
+ content: { type: AgentActivityType.Elicitation, body: prompt },
233
+ signal: AgentActivitySignal.Auth,
234
+ signalMetadata: {
235
+ url: auth.url,
236
+ ...(auth.userId ? { userId: auth.userId } : {}),
237
+ ...(auth.providerName ? { providerName: auth.providerName } : {}),
238
+ },
239
+ });
240
+ },
241
+ async startIssue(sessionId): Promise<StartIssueOutcome> {
242
+ // Resolve the issue from the session itself, so no issue id has to be threaded through the engine.
243
+ const session = await client.agentSession(sessionId);
244
+ const issue = await session.issue;
245
+ if (!issue) return { moved: false, reason: "no-issue" };
246
+ // Self-delegate (idempotent: assignment already sets the app as delegate; @mention does not).
247
+ // Split from the state move below so a failure of one does not sink the other (a rejected
248
+ // delegate write must not block moving the ticket to In Progress, and vice-versa).
249
+ const me = await client.viewer;
250
+ // DHK-263: only write delegateId when not already delegated to us. Re-writing when equal
251
+ // regenerates the "assigned to you" notification on every re-run of an already-delegated issue.
252
+ if (shouldSetDelegate(issue.delegateId, me.id)) {
253
+ await client.updateIssue(issue.id, { delegateId: me.id }).catch((e: unknown) => {
254
+ console.warn(`startIssue self-delegate failed for ${sessionId}: ${e instanceof Error ? e.message : String(e)}`);
255
+ });
256
+ }
257
+ // Move to the team's working state, but only from a not-yet-started status.
258
+ const current = await issue.state;
259
+ if (!current || current.type === "started" || current.type === "completed" || current.type === "canceled") {
260
+ return { moved: false, reason: "already-active" };
261
+ }
262
+ const team = await issue.team;
263
+ if (!team) return { moved: false, reason: "no-team" };
264
+ // Fetch states UNFILTERED and disambiguate in JS. This is a SEMANTIC need, not an SDK-filter
265
+ // workaround: the team has TWO `started`-typed states (In Progress AND In Review), so even a
266
+ // working server-side `filter: { type: { eq: "started" } }` returns both - the wrong answer.
267
+ // (Verified on @linear/sdk 86.0.0, DHK-579: that filter now returns both `In Progress` and
268
+ // `In Review`.) selectStartState excludes the review state and prefers the working one
269
+ // (DHK-258). Do NOT "simplify" this to a server-side filter - it would re-introduce the bug.
270
+ const states = [...(await team.states()).nodes];
271
+ const pick = selectStartState(states, reviewStateNameEnv());
272
+ if (!pick) return { moved: false, reason: "no-started-state" };
273
+ try {
274
+ await client.updateIssue(issue.id, { stateId: pick.id });
275
+ } catch (e: unknown) {
276
+ console.warn(`startIssue state move failed for ${sessionId}: ${e instanceof Error ? e.message : String(e)}`);
277
+ return { moved: false, reason: "update-failed" };
278
+ }
279
+ return { moved: true, stateName: pick.name };
280
+ },
281
+ async moveIssueToReview(sessionId): Promise<MoveToReviewOutcome> {
282
+ const session = await client.agentSession(sessionId);
283
+ const issue = await session.issue;
284
+ if (!issue) return { moved: false, reason: "no-issue" };
285
+ // Never disturb a closed issue.
286
+ const current = await issue.state;
287
+ if (current && (current.type === "completed" || current.type === "canceled")) {
288
+ return { moved: false, reason: "already-there" };
289
+ }
290
+ const team = await issue.team;
291
+ if (!team) return { moved: false, reason: "no-team" };
292
+ // Linear has no "review" state TYPE, so match by name (shared with `startIssue`'s exclusion via
293
+ // `findReviewState`): an explicit configured name, else the first review-ish name. No match =
294
+ // leave the issue in `started` (never auto-complete), so a human keeps iterating and can
295
+ // re-summon @Dahrk on the same PR. DHK-283: a no-match is now RETURNED as `no-review-state`
296
+ // (not a silent void) so the caller can surface it, since a team with no review-ish state would
297
+ // otherwise sit In Progress unremarked.
298
+ const states = [...(await team.states()).nodes].sort((a, b) => a.position - b.position);
299
+ const pick = findReviewState(states, reviewStateNameEnv());
300
+ if (!pick) return { moved: false, reason: "no-review-state" };
301
+ // Already in the review state: nothing to do, and benign (a re-run of an already-reviewed issue).
302
+ if (current && pick.id === current.id) return { moved: false, reason: "already-there" };
303
+ try {
304
+ await client.updateIssue(issue.id, { stateId: pick.id });
305
+ } catch (e: unknown) {
306
+ console.warn(`moveIssueToReview state move failed for ${sessionId}: ${e instanceof Error ? e.message : String(e)}`);
307
+ return { moved: false, reason: "update-failed" };
308
+ }
309
+ return { moved: true, stateName: pick.name };
310
+ },
311
+ async commentOnIssue(sessionId, body) {
312
+ // A normal top-level issue comment (not an agent activity), so a run summary lands in the
313
+ // issue's comment thread where a human reads it, rather than only in the agent session panel.
314
+ const session = await client.agentSession(sessionId);
315
+ const issue = await session.issue;
316
+ if (!issue) return;
317
+ await client.createComment({ issueId: issue.id, body });
318
+ },
319
+ async attachPr(sessionId, pr: PrAttachment) {
320
+ // Idempotent by URL: Linear's `attachmentCreate` upserts on (issueId, url) - a second call with
321
+ // the same `pr.url` on the same issue UPDATES the existing attachment rather than creating a
322
+ // duplicate. This is what lets the host loop retry `open-pr` (e.g. after a crash/replay) without
323
+ // littering the issue with duplicate PR attachments; the reporter relies on the same invariant.
324
+ const session = await client.agentSession(sessionId);
325
+ const issue = await session.issue;
326
+ if (!issue) return;
327
+ await client.createAttachment({
328
+ issueId: issue.id,
329
+ url: pr.url,
330
+ title: pr.title,
331
+ ...(pr.subtitle ? { subtitle: pr.subtitle } : {}),
332
+ ...(pr.iconUrl ? { iconUrl: pr.iconUrl } : {}),
333
+ });
334
+ },
335
+ async createDocument(sessionId, doc: DocumentInput): Promise<DocumentRef> {
336
+ // Resolve the issue from the session so no issue id has to be threaded through the engine,
337
+ // mirroring attachPr. `documentCreate` with an issueId links the document to the issue.
338
+ const session = await client.agentSession(sessionId);
339
+ const issue = await session.issue;
340
+
341
+ // Idempotent by (issueId, title). Unlike `attachmentCreate`, Linear's `documentCreate` has NO
342
+ // upsert semantics - title is not a key - so we emulate one: look for a document already on
343
+ // this issue with the same title and UPDATE it rather than creating a second. Without this, a
344
+ // continuation run (which re-runs the workflow from stage 0 with a fresh runId, and so a fresh
345
+ // `attach-document:<runId>` journal key) stacks another copy of the same report on the issue.
346
+ // The caveat this buys: a human-authored document on the same issue that happens to carry the
347
+ // workflow's title is overwritten. Title collision on one issue is the accepted trade for not
348
+ // littering the issue with near-duplicate reports; Linear keeps document history either way.
349
+ if (issue) {
350
+ const existing = await findDocumentByTitle(issue, doc.title);
351
+ if (existing) {
352
+ await client.updateDocument(existing.id, { title: doc.title, content: doc.content });
353
+ return { id: existing.id, url: existing.url ?? "" };
354
+ }
355
+ }
356
+
357
+ const payload = await client.createDocument({
358
+ title: doc.title,
359
+ content: doc.content,
360
+ ...(issue ? { issueId: issue.id } : {}),
361
+ });
362
+ const created = await payload.document;
363
+ return { id: created?.id ?? payload.documentId ?? "", url: created?.url ?? "" };
364
+ },
365
+ async setPlan(sessionId, items) {
366
+ // Linear expects the plan as a full array of { content, status } steps (replaced wholesale),
367
+ // NOT a wrapper object. Our PlanItem.status values (pending|inProgress|completed|canceled)
368
+ // already match Linear's enum; show the human title (falling back to the stage id) as content.
369
+ await client.updateAgentSession(sessionId, {
370
+ plan: items.map((i) => ({ content: i.title ?? i.stageId, status: i.status })),
371
+ });
372
+ },
373
+ async setExternalUrls(sessionId, urls) {
374
+ // Linear rejects non-http(s) URLs (e.g. internal skakel:// run refs) and requires each URL to be
375
+ // unique; filterExternalUrls drops the rest. The array is replaced wholesale, so no-op on empty
376
+ // rather than clobber any existing links with nothing.
377
+ const valid = filterExternalUrls(urls);
378
+ if (valid.length === 0) return;
379
+ await client.updateAgentSession(sessionId, { externalUrls: valid });
380
+ },
381
+ async setState() {
382
+ // Linear derives the agent-session state from activities/signals; not app-settable.
383
+ },
384
+ async readIssueEngagement(issueIds): Promise<Map<string, IssueEngagement>> {
385
+ const out = new Map<string, IssueEngagement>();
386
+ const ids = [...new Set(issueIds.filter((id) => id))];
387
+ if (ids.length === 0) return out;
388
+ // One rawRequest per <=100-id chunk (a single query per chunk, not per issue), so a sweep over
389
+ // many in-flight runs stays rate-limit friendly. An id that Linear does not return is left out
390
+ // of the map, which the caller reads as "deleted".
391
+ for (let i = 0; i < ids.length; i += 100) {
392
+ const chunk = ids.slice(i, i + 100);
393
+ const res = await client.client.rawRequest<
394
+ { issues?: { nodes?: IssueEngagementRow[] } },
395
+ { ids: string[] }
396
+ >(ISSUE_ENGAGEMENT_QUERY, { ids: chunk });
397
+ // A GraphQL error that did not throw must NOT be read as "no issues" (which the caller would
398
+ // treat as every issue deleted). Surface it so the sweep skips this connection this tick.
399
+ if (!res.data && res.errors?.length) {
400
+ throw new Error(`readIssueEngagement: ${res.errors.map((e) => e?.message ?? "error").join("; ")}`);
401
+ }
402
+ for (const n of res.data?.issues?.nodes ?? []) {
403
+ if (!n.id) continue;
404
+ out.set(n.id, {
405
+ present: true,
406
+ archived: Boolean(n.archivedAt),
407
+ ...(n.state?.type ? { stateType: n.state.type as IssueStateType } : {}),
408
+ ...(n.assignee?.id ? { assigneeId: n.assignee.id } : {}),
409
+ ...(n.delegateId ? { delegateId: n.delegateId } : {}),
410
+ });
411
+ }
412
+ }
413
+ return out;
414
+ },
415
+ async appUserId(): Promise<string> {
416
+ if (cachedAppUserId === undefined) cachedAppUserId = (await client.viewer).id;
417
+ return cachedAppUserId;
418
+ },
419
+ async createSessionOnIssue(issueId): Promise<string> {
420
+ // DHK-63: proactively open an agent session on an issue (not delegated/@mentioned). The typed SDK
421
+ // does not surface these mutations, so raw GraphQL, selecting the new session id off the returned
422
+ // AgentSessionPayload. Surface a GraphQL error rather than returning a blank id.
423
+ const res = await client.client.rawRequest<
424
+ { agentSessionCreateOnIssue?: { agentSession?: { id?: string } } },
425
+ { input: { issueId: string } }
426
+ >(SESSION_CREATE_ON_ISSUE, { input: { issueId } });
427
+ const id = res.data?.agentSessionCreateOnIssue?.agentSession?.id;
428
+ if (!id) {
429
+ throw new Error(
430
+ `agentSessionCreateOnIssue: ${res.errors?.map((e) => e?.message ?? "error").join("; ") ?? "no session id returned"}`,
431
+ );
432
+ }
433
+ return id;
434
+ },
435
+ async createSessionOnComment(commentId): Promise<string> {
436
+ const res = await client.client.rawRequest<
437
+ { agentSessionCreateOnComment?: { agentSession?: { id?: string } } },
438
+ { input: { commentId: string } }
439
+ >(SESSION_CREATE_ON_COMMENT, { input: { commentId } });
440
+ const id = res.data?.agentSessionCreateOnComment?.agentSession?.id;
441
+ if (!id) {
442
+ throw new Error(
443
+ `agentSessionCreateOnComment: ${res.errors?.map((e) => e?.message ?? "error").join("; ") ?? "no session id returned"}`,
444
+ );
445
+ }
446
+ return id;
447
+ },
448
+ };
449
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Linear OAuth 2.0 helpers for the agent install + token rotation (build spec section 14).
3
+ *
4
+ * An agent becomes assignable/mentionable in a workspace only while the app is INSTALLED via
5
+ * the authorization-code flow with `actor=app` and the `app:assignable` / `app:mentionable`
6
+ * scopes (admin consent). That flow also yields the access + refresh tokens the hub posts with.
7
+ * Access tokens last ~24h, so the hub refreshes them with the refresh token (see the hub's
8
+ * `resolveClient`). Token grants use plain `fetch`; post-token reads use the typed LinearClient.
9
+ */
10
+ import { LinearClient } from "@linear/sdk";
11
+
12
+ const AUTHORIZE_URL = "https://linear.app/oauth/authorize";
13
+ const TOKEN_URL = "https://api.linear.app/oauth/token";
14
+
15
+ /** The agent scopes: read/write plus the two that make the app assignable + mentionable. */
16
+ export const DEFAULT_AGENT_SCOPES = ["read", "write", "app:assignable", "app:mentionable"] as const;
17
+
18
+ export interface LinearTokens {
19
+ accessToken: string;
20
+ /** Absent on some refresh responses; callers keep the prior refresh token when so. */
21
+ refreshToken?: string;
22
+ /** When the access token expires (computed from `expires_in`). */
23
+ expiresAt: Date;
24
+ scope?: string;
25
+ }
26
+
27
+ /**
28
+ * Build the Linear consent URL. The admin opens this; approving installs the agent (with
29
+ * `actor=app`) and redirects to `redirectUri?code=...&state=...`. `state` is opaque here - the
30
+ * caller signs it (CSRF) and recovers the connection from it in the callback.
31
+ */
32
+ export function authorizeUrl(params: {
33
+ clientId: string;
34
+ redirectUri: string;
35
+ state: string;
36
+ scopes?: readonly string[];
37
+ actor?: "app" | "user";
38
+ /** `"consent"` (default) forces Linear's consent screen every time - right for the `actor=app`
39
+ * install (re-grants and re-issues a refresh token, and lets an admin connect another workspace).
40
+ * `"auto"` omits the `prompt` param so Linear only prompts when scopes are not already granted -
41
+ * right for the `actor=user` sign-in, so a returning user is not made to re-authorise on every login. */
42
+ prompt?: "consent" | "auto";
43
+ }): string {
44
+ const u = new URL(AUTHORIZE_URL);
45
+ u.searchParams.set("client_id", params.clientId);
46
+ u.searchParams.set("redirect_uri", params.redirectUri);
47
+ u.searchParams.set("response_type", "code");
48
+ u.searchParams.set("scope", (params.scopes ?? DEFAULT_AGENT_SCOPES).join(","));
49
+ u.searchParams.set("state", params.state);
50
+ u.searchParams.set("actor", params.actor ?? "app");
51
+ if ((params.prompt ?? "consent") === "consent") u.searchParams.set("prompt", "consent");
52
+ return u.toString();
53
+ }
54
+
55
+ interface TokenResponse {
56
+ access_token?: string;
57
+ refresh_token?: string;
58
+ expires_in?: number;
59
+ scope?: string;
60
+ error?: string;
61
+ error_description?: string;
62
+ }
63
+
64
+ async function postToken(body: Record<string, string>, now: () => Date): Promise<LinearTokens> {
65
+ const res = await fetch(TOKEN_URL, {
66
+ method: "POST",
67
+ headers: { "content-type": "application/x-www-form-urlencoded" },
68
+ body: new URLSearchParams(body).toString(),
69
+ });
70
+ const json = (await res.json().catch(() => ({}))) as TokenResponse;
71
+ if (!res.ok || !json.access_token) {
72
+ const detail = json.error_description || json.error || `${res.status}`;
73
+ throw new Error(`linear token endpoint failed: ${detail}`);
74
+ }
75
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 86_399;
76
+ return {
77
+ accessToken: json.access_token,
78
+ ...(json.refresh_token ? { refreshToken: json.refresh_token } : {}),
79
+ expiresAt: new Date(now().getTime() + expiresIn * 1000),
80
+ ...(json.scope ? { scope: json.scope } : {}),
81
+ };
82
+ }
83
+
84
+ /** Exchange the callback `code` for access + refresh tokens (one-time, at install). */
85
+ export function exchangeCode(
86
+ params: { clientId: string; clientSecret: string; code: string; redirectUri: string },
87
+ now: () => Date = () => new Date(),
88
+ ): Promise<LinearTokens> {
89
+ return postToken(
90
+ {
91
+ grant_type: "authorization_code",
92
+ client_id: params.clientId,
93
+ client_secret: params.clientSecret,
94
+ code: params.code,
95
+ redirect_uri: params.redirectUri,
96
+ },
97
+ now,
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Personal API keys start with `lin_api_` and must be sent as `apiKey` (not Bearer accessToken).
103
+ * OAuth access tokens use `accessToken` so LinearClient adds the Bearer prefix automatically.
104
+ */
105
+ function makeLinearClient(token: string): LinearClient {
106
+ return token.startsWith("lin_api_")
107
+ ? new LinearClient({ apiKey: token })
108
+ : new LinearClient({ accessToken: token });
109
+ }
110
+
111
+ /**
112
+ * Resolve the workspace (organization) id the access token belongs to. Used at install to backfill
113
+ * a connection's `workspaceIds` from the token, so an operator need not paste the org id by hand and
114
+ * intake can match the workspace on the very first webhook.
115
+ */
116
+ export async function fetchOrganizationId(accessToken: string): Promise<string> {
117
+ const client = makeLinearClient(accessToken);
118
+ const org = await client.organization;
119
+ return org.id;
120
+ }
121
+
122
+ /** The agent identity + bound workspace a token resolves to, for the connection Probe. */
123
+ export interface LinearProbe {
124
+ /** `email` (verified, owned by Linear) is the cross-provider account join key; `admin` is captured
125
+ * now for later permission gating (unused at launch). Both are non-null on Linear's `User` type but
126
+ * optional here so a partial/legacy probe response never throws. */
127
+ viewer?: { id: string; name?: string; email?: string; admin?: boolean };
128
+ organization?: { id: string; name?: string; urlKey?: string };
129
+ }
130
+
131
+ /**
132
+ * Probe a stored access token: query `viewer` (the app/agent user) and `organization` (the bound
133
+ * workspace) so the portal can confirm a connection's token is valid and show which workspace it
134
+ * binds to. Throws on an auth failure / GraphQL error, which the caller renders as "token invalid".
135
+ */
136
+ export async function probeToken(accessToken: string): Promise<LinearProbe> {
137
+ const client = makeLinearClient(accessToken);
138
+ const [viewer, org] = await Promise.all([client.viewer, client.organization]);
139
+ const probe: LinearProbe = {};
140
+ if (viewer?.id) {
141
+ probe.viewer = {
142
+ id: viewer.id,
143
+ ...(viewer.name ? { name: viewer.name } : {}),
144
+ ...(viewer.email ? { email: viewer.email } : {}),
145
+ ...(typeof viewer.admin === "boolean" ? { admin: viewer.admin } : {}),
146
+ };
147
+ }
148
+ if (org?.id) {
149
+ probe.organization = {
150
+ id: org.id,
151
+ ...(org.name ? { name: org.name } : {}),
152
+ ...(org.urlKey ? { urlKey: org.urlKey } : {}),
153
+ };
154
+ }
155
+ return probe;
156
+ }
157
+
158
+ /** Rotate the access token using the stored refresh token (the hub calls this near expiry / on 401). */
159
+ export function refreshTokens(
160
+ params: { clientId: string; clientSecret: string; refreshToken: string },
161
+ now: () => Date = () => new Date(),
162
+ ): Promise<LinearTokens> {
163
+ return postToken(
164
+ {
165
+ grant_type: "refresh_token",
166
+ client_id: params.clientId,
167
+ client_secret: params.clientSecret,
168
+ refresh_token: params.refreshToken,
169
+ },
170
+ now,
171
+ );
172
+ }
173
+
174
+ /**
175
+ * Mint an `app` actor token via the `client_credentials` grant: headless (no user, no redirect, no
176
+ * callback, no refresh token), scoped to the app's workspace, valid ~30 days. Linear's documented
177
+ * renewal is simply to mint a fresh one on a 401/expiry. Requires the app's "Client credentials"
178
+ * toggle to be enabled. This is the durable token path for a headless agent, vs the interactive
179
+ * authorization-code flow (24h token + fragile refresh + "already installed" install UX).
180
+ */
181
+ export function mintAppToken(
182
+ params: { clientId: string; clientSecret: string; scopes?: readonly string[] },
183
+ now: () => Date = () => new Date(),
184
+ ): Promise<LinearTokens> {
185
+ return postToken(
186
+ {
187
+ grant_type: "client_credentials",
188
+ client_id: params.clientId,
189
+ client_secret: params.clientSecret,
190
+ scope: (params.scopes ?? DEFAULT_AGENT_SCOPES).join(","),
191
+ },
192
+ now,
193
+ );
194
+ }