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