@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.
- package/README.md +24 -12
- 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 +113 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +182 -58
- 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 +3 -2
- package/dist/issues.d.ts.map +1 -1
- package/dist/issues.js +5 -10
- package/dist/issues.js.map +1 -1
- package/dist/labels.d.ts +47 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +71 -23
- 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 +234 -34
- package/dist/linear-client.js.map +1 -1
- package/dist/oauth.d.ts +11 -10
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +35 -32
- 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/comments.ts +126 -0
- package/src/documents.ts +162 -0
- package/src/format-action.ts +279 -0
- package/src/index.ts +556 -0
- package/src/issue-graph.ts +169 -0
- package/src/issues.ts +93 -0
- package/src/labels.ts +254 -0
- package/src/linear-client.ts +449 -0
- package/src/oauth.ts +194 -0
- package/src/recording-client.ts +141 -0
- package/src/responding-client.ts +106 -0
- 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
|
+
}
|