@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.
- package/README.md +24 -12
- package/dist/batch-source.d.ts +63 -0
- package/dist/batch-source.d.ts.map +1 -0
- package/dist/batch-source.js +149 -0
- package/dist/batch-source.js.map +1 -0
- package/dist/comments.d.ts +36 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +104 -0
- package/dist/comments.js.map +1 -0
- package/dist/documents.d.ts +1 -15
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +37 -27
- package/dist/documents.js.map +1 -1
- package/dist/format-action.d.ts +25 -0
- package/dist/format-action.d.ts.map +1 -0
- package/dist/format-action.js +250 -0
- package/dist/format-action.js.map +1 -0
- package/dist/index.d.ts +119 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +231 -59
- package/dist/index.js.map +1 -1
- package/dist/issue-graph.d.ts +37 -0
- package/dist/issue-graph.d.ts.map +1 -0
- package/dist/issue-graph.js +125 -0
- package/dist/issue-graph.js.map +1 -0
- package/dist/issues.d.ts +27 -2
- package/dist/issues.d.ts.map +1 -1
- package/dist/issues.js +33 -10
- package/dist/issues.js.map +1 -1
- package/dist/labels.d.ts +48 -1
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +72 -24
- package/dist/labels.js.map +1 -1
- package/dist/linear-client.d.ts +51 -0
- package/dist/linear-client.d.ts.map +1 -1
- package/dist/linear-client.js +233 -34
- package/dist/linear-client.js.map +1 -1
- package/dist/oauth.d.ts +52 -12
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +91 -34
- package/dist/oauth.js.map +1 -1
- package/dist/recording-client.d.ts +20 -2
- package/dist/recording-client.d.ts.map +1 -1
- package/dist/recording-client.js +39 -1
- package/dist/recording-client.js.map +1 -1
- package/dist/responding-client.d.ts +49 -0
- package/dist/responding-client.d.ts.map +1 -0
- package/dist/responding-client.js +47 -0
- package/dist/responding-client.js.map +1 -0
- package/dist/teams.d.ts +20 -0
- package/dist/teams.d.ts.map +1 -0
- package/dist/teams.js +32 -0
- package/dist/teams.js.map +1 -0
- package/package.json +8 -10
- package/src/batch-source.ts +208 -0
- package/src/comments.ts +126 -0
- package/src/documents.ts +162 -0
- package/src/format-action.ts +279 -0
- package/src/index.ts +617 -0
- package/src/issue-graph.ts +169 -0
- package/src/issues.ts +142 -0
- package/src/labels.ts +254 -0
- package/src/linear-client.ts +448 -0
- package/src/oauth.ts +255 -0
- package/src/recording-client.ts +141 -0
- package/src/responding-client.ts +106 -0
- 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
|
+
}
|