@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
package/src/oauth.ts ADDED
@@ -0,0 +1,255 @@
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
+ const REVOKE_URL = "https://api.linear.app/oauth/revoke";
15
+
16
+ /** The agent scopes: read/write plus the two that make the app assignable + mentionable. */
17
+ export const DEFAULT_AGENT_SCOPES = ["read", "write", "app:assignable", "app:mentionable"] as const;
18
+
19
+ export interface LinearTokens {
20
+ accessToken: string;
21
+ /** Present on every authorization-code and refresh response, absent on a client-credentials mint
22
+ * (which issues no refresh token at all). It is NOT optional on a refresh: Linear rotates refresh
23
+ * tokens single-use and always returns the replacement, so a refresh response without one is a
24
+ * protocol violation rather than "keep the one you have" - see {@link refreshTokens}. */
25
+ refreshToken?: string;
26
+ /** When the access token expires (computed from `expires_in`). */
27
+ expiresAt: Date;
28
+ scope?: string;
29
+ }
30
+
31
+ /**
32
+ * Build the Linear consent URL. The admin opens this; approving installs the agent (with
33
+ * `actor=app`) and redirects to `redirectUri?code=...&state=...`. `state` is opaque here - the
34
+ * caller signs it (CSRF) and recovers the connection from it in the callback.
35
+ */
36
+ export function authorizeUrl(params: {
37
+ clientId: string;
38
+ redirectUri: string;
39
+ state: string;
40
+ scopes?: readonly string[];
41
+ actor?: "app" | "user";
42
+ /** `"consent"` (default) forces Linear's consent screen every time - right for the `actor=app`
43
+ * install (re-grants and re-issues a refresh token, and lets an admin connect another workspace).
44
+ * `"auto"` omits the `prompt` param so Linear only prompts when scopes are not already granted -
45
+ * right for the `actor=user` sign-in, so a returning user is not made to re-authorise on every login. */
46
+ prompt?: "consent" | "auto";
47
+ }): string {
48
+ const u = new URL(AUTHORIZE_URL);
49
+ u.searchParams.set("client_id", params.clientId);
50
+ u.searchParams.set("redirect_uri", params.redirectUri);
51
+ u.searchParams.set("response_type", "code");
52
+ u.searchParams.set("scope", (params.scopes ?? DEFAULT_AGENT_SCOPES).join(","));
53
+ u.searchParams.set("state", params.state);
54
+ u.searchParams.set("actor", params.actor ?? "app");
55
+ if ((params.prompt ?? "consent") === "consent") u.searchParams.set("prompt", "consent");
56
+ return u.toString();
57
+ }
58
+
59
+ interface TokenResponse {
60
+ access_token?: string;
61
+ refresh_token?: string;
62
+ expires_in?: number;
63
+ scope?: string;
64
+ error?: string;
65
+ error_description?: string;
66
+ }
67
+
68
+ async function postToken(body: Record<string, string>, now: () => Date): Promise<LinearTokens> {
69
+ const res = await fetch(TOKEN_URL, {
70
+ method: "POST",
71
+ headers: { "content-type": "application/x-www-form-urlencoded" },
72
+ body: new URLSearchParams(body).toString(),
73
+ });
74
+ const json = (await res.json().catch(() => ({}))) as TokenResponse;
75
+ if (!res.ok || !json.access_token) {
76
+ const detail = json.error_description || json.error || `${res.status}`;
77
+ throw new Error(`linear token endpoint failed: ${detail}`);
78
+ }
79
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 86_399;
80
+ return {
81
+ accessToken: json.access_token,
82
+ ...(json.refresh_token ? { refreshToken: json.refresh_token } : {}),
83
+ expiresAt: new Date(now().getTime() + expiresIn * 1000),
84
+ ...(json.scope ? { scope: json.scope } : {}),
85
+ };
86
+ }
87
+
88
+ /** Exchange the callback `code` for access + refresh tokens (one-time, at install). */
89
+ export function exchangeCode(
90
+ params: { clientId: string; clientSecret: string; code: string; redirectUri: string },
91
+ now: () => Date = () => new Date(),
92
+ ): Promise<LinearTokens> {
93
+ return postToken(
94
+ {
95
+ grant_type: "authorization_code",
96
+ client_id: params.clientId,
97
+ client_secret: params.clientSecret,
98
+ code: params.code,
99
+ redirect_uri: params.redirectUri,
100
+ },
101
+ now,
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Personal API keys start with `lin_api_` and must be sent as `apiKey` (not Bearer accessToken).
107
+ * OAuth access tokens use `accessToken` so LinearClient adds the Bearer prefix automatically.
108
+ */
109
+ function makeLinearClient(token: string): LinearClient {
110
+ return token.startsWith("lin_api_")
111
+ ? new LinearClient({ apiKey: token })
112
+ : new LinearClient({ accessToken: token });
113
+ }
114
+
115
+ /**
116
+ * Resolve the workspace (organization) id the access token belongs to. Used at install to backfill
117
+ * a connection's `workspaceIds` from the token, so an operator need not paste the org id by hand and
118
+ * intake can match the workspace on the very first webhook.
119
+ */
120
+ export async function fetchOrganizationId(accessToken: string): Promise<string> {
121
+ const client = makeLinearClient(accessToken);
122
+ const org = await client.organization;
123
+ return org.id;
124
+ }
125
+
126
+ /** The agent identity + bound workspace a token resolves to, for the connection Probe. */
127
+ export interface LinearProbe {
128
+ /** `email` (verified, owned by Linear) is the cross-provider account join key; `admin` is captured
129
+ * now for later permission gating (unused at launch). Both are non-null on Linear's `User` type but
130
+ * optional here so a partial/legacy probe response never throws. */
131
+ viewer?: { id: string; name?: string; email?: string; admin?: boolean };
132
+ organization?: { id: string; name?: string; urlKey?: string };
133
+ }
134
+
135
+ /**
136
+ * Probe a stored access token: query `viewer` (the app/agent user) and `organization` (the bound
137
+ * workspace) so the portal can confirm a connection's token is valid and show which workspace it
138
+ * binds to. Throws on an auth failure / GraphQL error, which the caller renders as "token invalid".
139
+ */
140
+ export async function probeToken(accessToken: string): Promise<LinearProbe> {
141
+ const client = makeLinearClient(accessToken);
142
+ const [viewer, org] = await Promise.all([client.viewer, client.organization]);
143
+ const probe: LinearProbe = {};
144
+ if (viewer?.id) {
145
+ probe.viewer = {
146
+ id: viewer.id,
147
+ ...(viewer.name ? { name: viewer.name } : {}),
148
+ ...(viewer.email ? { email: viewer.email } : {}),
149
+ ...(typeof viewer.admin === "boolean" ? { admin: viewer.admin } : {}),
150
+ };
151
+ }
152
+ if (org?.id) {
153
+ probe.organization = {
154
+ id: org.id,
155
+ ...(org.name ? { name: org.name } : {}),
156
+ ...(org.urlKey ? { urlKey: org.urlKey } : {}),
157
+ };
158
+ }
159
+ return probe;
160
+ }
161
+
162
+ /**
163
+ * Rotate the access token using the stored refresh token (the hub calls this near expiry / on 401).
164
+ *
165
+ * Linear rotates refresh tokens **single-use**: the request consumes the one presented and the response
166
+ * carries "a new valid access token and a new refresh token" (oauth-2-0-authentication.md, "Refresh an
167
+ * access token"). So the replacement is mandatory, and a response without one means the old token has
168
+ * been consumed while we learned nothing - the caller MUST NOT carry on with the previous value, which
169
+ * is now dead. Throwing here is what makes that loud instead of silently arming a connection to fail
170
+ * on its next refresh, forever (DHK-1306).
171
+ *
172
+ * Linear gives a 30-minute grace period for exactly this case: the original request can be replayed to
173
+ * retrieve the new refresh token. Recovery is the caller's to attempt; this function's job is to refuse
174
+ * to report success when the rotation is unaccounted for.
175
+ */
176
+ export function refreshTokens(
177
+ params: { clientId: string; clientSecret: string; refreshToken: string },
178
+ now: () => Date = () => new Date(),
179
+ ): Promise<LinearTokens> {
180
+ return postToken(
181
+ {
182
+ grant_type: "refresh_token",
183
+ client_id: params.clientId,
184
+ client_secret: params.clientSecret,
185
+ refresh_token: params.refreshToken,
186
+ },
187
+ now,
188
+ ).then((t) => {
189
+ if (!t.refreshToken) {
190
+ throw new Error(
191
+ "linear refresh returned no replacement refresh token: the presented token is now consumed. " +
192
+ "Replay the same request within 30 minutes to recover it.",
193
+ );
194
+ }
195
+ return t;
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Revoke an access token at Linear, de-authorising the app for the workspace that token belongs to.
201
+ *
202
+ * This is the ONLY app-callable lever Linear gives us over an install: there is no uninstall mutation
203
+ * and nothing in the GraphQL schema that names or targets an install (`revokeOauthToken`,
204
+ * `userAuthorizedApplications`, `applicationWithAuthorization` are all absent). De-authorisation is
205
+ * per organisation - Linear signals it with an `OAuthApp revoked` webhook carrying `organizationId`.
206
+ *
207
+ * Why it matters beyond hygiene: while an install exists, Linear's authorize hop resolves against it
208
+ * and shows "Dahrk already installed - Continue" rather than a consent screen for the workspace the
209
+ * user is actually in. So a user who wanted to move the connection to another workspace got the old
210
+ * one back, every time, with no way out from inside the product. Forgetting a token locally is not
211
+ * enough; Linear has to be told.
212
+ *
213
+ * A `400` counts as revoked. The endpoint returns it for an already-revoked token, and the caller's
214
+ * question is "is this token dead", to which "Linear dropped it earlier" is a yes. A `401` does not:
215
+ * it means we could not authenticate the revocation at all, so the install may well still be live and
216
+ * the caller must say so rather than report a clean disconnect.
217
+ */
218
+ export async function revokeToken(params: {
219
+ token: string;
220
+ tokenTypeHint?: "access_token" | "refresh_token";
221
+ }): Promise<void> {
222
+ const body: Record<string, string> = { token: params.token };
223
+ // Documented as optional but helpful; must not be combined with the legacy access_token/refresh_token
224
+ // form fields, which we never send.
225
+ if (params.tokenTypeHint) body.token_type_hint = params.tokenTypeHint;
226
+ const res = await fetch(REVOKE_URL, {
227
+ method: "POST",
228
+ headers: { "content-type": "application/x-www-form-urlencoded" },
229
+ body: new URLSearchParams(body).toString(),
230
+ });
231
+ if (res.ok || res.status === 400) return;
232
+ throw new Error(`linear revoke endpoint failed: ${res.status}`);
233
+ }
234
+
235
+ /**
236
+ * Mint an `app` actor token via the `client_credentials` grant: headless (no user, no redirect, no
237
+ * callback, no refresh token), scoped to the app's workspace, valid ~30 days. Linear's documented
238
+ * renewal is simply to mint a fresh one on a 401/expiry. Requires the app's "Client credentials"
239
+ * toggle to be enabled. This is the durable token path for a headless agent, vs the interactive
240
+ * authorization-code flow (24h token + fragile refresh + "already installed" install UX).
241
+ */
242
+ export function mintAppToken(
243
+ params: { clientId: string; clientSecret: string; scopes?: readonly string[] },
244
+ now: () => Date = () => new Date(),
245
+ ): Promise<LinearTokens> {
246
+ return postToken(
247
+ {
248
+ grant_type: "client_credentials",
249
+ client_id: params.clientId,
250
+ client_secret: params.clientSecret,
251
+ scope: (params.scopes ?? DEFAULT_AGENT_SCOPES).join(","),
252
+ },
253
+ now,
254
+ );
255
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * A credential-free AgentSessionClient that records every call instead of touching
3
+ * Linear. It is the offline-acceptance counterpart to the mock Runner: the hub harness
4
+ * and unit tests inject it and assert on the ordered call log, so the whole control
5
+ * surface (plan, activities, elicitation, auth, issue-start, PR attach, state,
6
+ * externalUrls) is verified with no Linear credentials. The real `@linear/sdk` client is
7
+ * the live path (./linear-client.ts).
8
+ */
9
+ import type {
10
+ Activity,
11
+ AgentSessionClient,
12
+ AuthRequest,
13
+ DocumentInput,
14
+ ElicitOption,
15
+ ExternalUrl,
16
+ IssueEngagement,
17
+ PlanItem,
18
+ PrAttachment,
19
+ RepoCandidate,
20
+ SessionState,
21
+ } from "./index.js";
22
+
23
+ export type RecordedCall =
24
+ | { call: "postActivity"; sessionId: string; activity: Activity }
25
+ | { call: "setPlan"; sessionId: string; items: PlanItem[] }
26
+ | { call: "raiseElicitation"; sessionId: string; prompt: string; options?: ElicitOption[] }
27
+ | { call: "suggestRepositories"; issueId: string; candidates: RepoCandidate[] }
28
+ | { call: "addIssueLabel"; issueId: string; name: string }
29
+ | { call: "requestAuth"; sessionId: string; prompt: string; auth: AuthRequest }
30
+ | { call: "startIssue"; sessionId: string }
31
+ | { call: "moveIssueToReview"; sessionId: string }
32
+ | { call: "commentOnIssue"; sessionId: string; body: string }
33
+ | { call: "attachPr"; sessionId: string; pr: PrAttachment }
34
+ | { call: "createDocument"; sessionId: string; doc: DocumentInput }
35
+ | { call: "setExternalUrls"; sessionId: string; urls: ExternalUrl[] }
36
+ | { call: "setState"; sessionId: string; state: SessionState }
37
+ | { call: "readIssueEngagement"; issueIds: string[] }
38
+ | { call: "createSessionOnIssue"; issueId: string }
39
+ | { call: "createSessionOnComment"; commentId: string };
40
+
41
+ export interface RecordingClient extends AgentSessionClient {
42
+ /** Every call made, in order. */
43
+ readonly calls: RecordedCall[];
44
+ }
45
+
46
+ /** Drive the read-only ground-truth methods so a test can simulate a disengaged issue. */
47
+ export interface RecordingClientOptions {
48
+ /** The app user id `appUserId()` returns (default "app-user"). */
49
+ appUserId?: string;
50
+ /** Per-issue engagement `readIssueEngagement` returns. Any requested id NOT present here defaults
51
+ * to a fully-engaged issue delegated + assigned to `appUserId` (so an unconfigured sweep never
52
+ * false-cancels). Set an id to `null` to simulate a hard-deleted issue (omitted from the result). */
53
+ engagement?: Record<string, IssueEngagement | null>;
54
+ }
55
+
56
+ export function createRecordingClient(opts: RecordingClientOptions = {}): RecordingClient {
57
+ const calls: RecordedCall[] = [];
58
+ const appUserId = opts.appUserId ?? "app-user";
59
+ const engagement = opts.engagement ?? {};
60
+ return {
61
+ calls,
62
+ async postActivity(sessionId, activity) {
63
+ calls.push({ call: "postActivity", sessionId, activity });
64
+ },
65
+ async setPlan(sessionId, items) {
66
+ calls.push({ call: "setPlan", sessionId, items });
67
+ },
68
+ async raiseElicitation(sessionId, prompt, options) {
69
+ calls.push({ call: "raiseElicitation", sessionId, prompt, ...(options ? { options } : {}) });
70
+ },
71
+ async suggestRepositories(issueId, candidates) {
72
+ calls.push({ call: "suggestRepositories", issueId, candidates });
73
+ // No opinion offline: the caller then falls back to registry order (exercised by the unit tests
74
+ // of the pure ordering function, which take real suggestions).
75
+ return [];
76
+ },
77
+ async addIssueLabel(issueId, name) {
78
+ calls.push({ call: "addIssueLabel", issueId, name });
79
+ },
80
+ async requestAuth(sessionId, prompt, auth) {
81
+ calls.push({ call: "requestAuth", sessionId, prompt, auth });
82
+ },
83
+ async startIssue(sessionId) {
84
+ calls.push({ call: "startIssue", sessionId });
85
+ // Offline stub: report a successful move so the reporter's no-op surfacing stays quiet.
86
+ return { moved: true, stateName: "In Progress" };
87
+ },
88
+ async moveIssueToReview(sessionId) {
89
+ calls.push({ call: "moveIssueToReview", sessionId });
90
+ // Offline stub: report a successful move so the reporter's no-op surfacing stays quiet.
91
+ return { moved: true, stateName: "In Review" };
92
+ },
93
+ async commentOnIssue(sessionId, body) {
94
+ calls.push({ call: "commentOnIssue", sessionId, body });
95
+ },
96
+ async attachPr(sessionId, pr) {
97
+ calls.push({ call: "attachPr", sessionId, pr });
98
+ },
99
+ async createDocument(sessionId, doc) {
100
+ calls.push({ call: "createDocument", sessionId, doc });
101
+ // Deterministic fake id/url so offline runs and tests have a stable document reference.
102
+ const slug = doc.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
103
+ return { id: `doc-${slug}`, url: `https://linear.app/doc/${slug}` };
104
+ },
105
+ async setExternalUrls(sessionId, urls) {
106
+ calls.push({ call: "setExternalUrls", sessionId, urls });
107
+ },
108
+ async setState(sessionId, state) {
109
+ calls.push({ call: "setState", sessionId, state });
110
+ },
111
+ async readIssueEngagement(issueIds) {
112
+ calls.push({ call: "readIssueEngagement", issueIds });
113
+ const out = new Map<string, IssueEngagement>();
114
+ for (const id of issueIds) {
115
+ if (!id) continue;
116
+ if (Object.prototype.hasOwnProperty.call(engagement, id)) {
117
+ const e = engagement[id];
118
+ // null => hard-deleted: leave it out of the result entirely.
119
+ if (e) out.set(id, e);
120
+ } else {
121
+ // Default: fully engaged (delegated + assigned to the app user), so a sweep against an
122
+ // unconfigured recording client reads every issue as still-engaged.
123
+ out.set(id, { present: true, archived: false, stateType: "started", delegateId: appUserId, assigneeId: appUserId });
124
+ }
125
+ }
126
+ return out;
127
+ },
128
+ async appUserId() {
129
+ return appUserId;
130
+ },
131
+ async createSessionOnIssue(issueId) {
132
+ calls.push({ call: "createSessionOnIssue", issueId });
133
+ // Deterministic synthetic session id so offline runs and tests have a stable reference.
134
+ return `sess-${issueId}`;
135
+ },
136
+ async createSessionOnComment(commentId) {
137
+ calls.push({ call: "createSessionOnComment", commentId });
138
+ return `sess-${commentId}`;
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * An actively-responding, credential-free AgentSessionClient for the `pnpm dev` offline
3
+ * environment. It is a superset of the recording client (./recording-client.ts): it keeps
4
+ * the ordered `calls` log so it is a drop-in wherever the recording client is injected, but
5
+ * it additionally
6
+ *
7
+ * 1. maintains a small, queryable per-session projection of what the hub last pushed
8
+ * (latest state, latest plan, appended activities, set external URLs), so a dev can
9
+ * observe a run without touching Linear, and
10
+ * 2. exposes a pure, pre-declared gate-decision policy (`decide`) a dev CLI reads to know
11
+ * which answer to fire for a stage.
12
+ *
13
+ * The gate policy is a value the dev supplies up front; it NEVER chooses the next stage
14
+ * (that would cross the determinism boundary). Gate answers remain human-drive: the CLI
15
+ * uses `decide` only to pick which `prompted` webhook to send.
16
+ */
17
+ import type {
18
+ Activity,
19
+ ElicitOption,
20
+ ExternalUrl,
21
+ PlanItem,
22
+ SessionState,
23
+ } from "./index.js";
24
+ import {
25
+ createRecordingClient,
26
+ type RecordingClient,
27
+ type RecordingClientOptions,
28
+ } from "./recording-client.js";
29
+
30
+ /** A pre-declared gate answer for a stage. Never a control-flow decision (see file header). */
31
+ export type GateDecision = "allow" | "deny";
32
+
33
+ /** What the hub last pushed for one session, observable offline without touching Linear. */
34
+ export interface SessionProjection {
35
+ /** The latest session state the hub mirrored, or undefined if none was set yet. */
36
+ state?: SessionState;
37
+ /** The latest plan (agent-plan checklist); replaced wholesale on each `setPlan`. */
38
+ plan: PlanItem[];
39
+ /** Every activity posted to the session, in order. */
40
+ activities: Activity[];
41
+ /** The latest external-URL set; replaced wholesale on each `setExternalUrls`. */
42
+ externalUrls: ExternalUrl[];
43
+ /** The latest elicitation raised on the session, if any. */
44
+ elicitation?: { prompt: string; options?: ElicitOption[] };
45
+ }
46
+
47
+ export interface RespondingClientOptions extends RecordingClientOptions {
48
+ /** Per-stage gate answers the dev pre-declares. A stage absent here defaults to "allow". */
49
+ gateDecisions?: Record<string, GateDecision>;
50
+ }
51
+
52
+ export interface RespondingClient extends RecordingClient {
53
+ /** The queryable projection of what the hub last pushed for a session, or undefined if the
54
+ * hub has not touched that session. */
55
+ session(sessionId: string): SessionProjection | undefined;
56
+ /** The dev's pre-declared gate answer for a stage (default "allow"). Pure. */
57
+ decide(stageId: string): GateDecision;
58
+ }
59
+
60
+ export function createRespondingLinearClient(opts: RespondingClientOptions = {}): RespondingClient {
61
+ // Reuse the recording client wholesale for the ordered call log and the seeded read-side
62
+ // methods (engagement, appUserId, createDocument), then wrap the mutating methods to also
63
+ // update a per-session projection.
64
+ const base = createRecordingClient(opts);
65
+ const gateDecisions = opts.gateDecisions ?? {};
66
+ const projections = new Map<string, SessionProjection>();
67
+
68
+ const projectionFor = (sessionId: string): SessionProjection => {
69
+ let p = projections.get(sessionId);
70
+ if (!p) {
71
+ p = { plan: [], activities: [], externalUrls: [] };
72
+ projections.set(sessionId, p);
73
+ }
74
+ return p;
75
+ };
76
+
77
+ return {
78
+ ...base,
79
+ async setPlan(sessionId, items) {
80
+ await base.setPlan(sessionId, items);
81
+ projectionFor(sessionId).plan = items;
82
+ },
83
+ async postActivity(sessionId, activity) {
84
+ await base.postActivity(sessionId, activity);
85
+ projectionFor(sessionId).activities.push(activity);
86
+ },
87
+ async raiseElicitation(sessionId, prompt, options) {
88
+ await base.raiseElicitation(sessionId, prompt, options);
89
+ projectionFor(sessionId).elicitation = { prompt, ...(options ? { options } : {}) };
90
+ },
91
+ async setExternalUrls(sessionId, urls) {
92
+ await base.setExternalUrls(sessionId, urls);
93
+ projectionFor(sessionId).externalUrls = urls;
94
+ },
95
+ async setState(sessionId, state) {
96
+ await base.setState(sessionId, state);
97
+ projectionFor(sessionId).state = state;
98
+ },
99
+ session(sessionId) {
100
+ return projections.get(sessionId);
101
+ },
102
+ decide(stageId) {
103
+ return gateDecisions[stageId] ?? "allow";
104
+ },
105
+ };
106
+ }
package/src/teams.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Workspace-team listing (DHK-777). A repository routes issues to itself partly by team key
3
+ * (`Repository.routing.teamKeys`); the portal offers the real keys instead of free text by reading
4
+ * the linked workspace's teams. The `TeamsApi` seam keeps this pure and unit-testable: the live
5
+ * adapter (`linearTeamsApi`) wraps `@linear/sdk`, while tests inject a fake - mirroring `LabelApi`.
6
+ */
7
+ import { LinearClient } from "@linear/sdk";
8
+
9
+ /** A workspace team, reduced to just what the picker needs. */
10
+ export interface Team {
11
+ id: string;
12
+ key: string;
13
+ name: string;
14
+ }
15
+
16
+ /** The injectable seam: list the workspace's teams. */
17
+ export interface TeamsApi {
18
+ listTeams(): Promise<Team[]>;
19
+ }
20
+
21
+ /**
22
+ * List the workspace's teams via the `TeamsApi` seam, normalised to `{id,key,name}` and sorted by
23
+ * key so the picker renders in a stable, deterministic order. A thin deterministic transform - no
24
+ * control flow, no network - so it feeds the portal picker directly.
25
+ */
26
+ export async function listWorkspaceTeams(api: TeamsApi): Promise<Team[]> {
27
+ const teams = await api.listTeams();
28
+ return teams
29
+ .map((t) => ({ id: t.id, key: t.key, name: t.name }))
30
+ .sort((a, b) => a.key.localeCompare(b.key));
31
+ }
32
+
33
+ /** The live `TeamsApi` backed by a Linear bearer token (workspace-scoped teams). Paginates
34
+ * `client.teams()` exactly as `linearLabelApi` paginates `issueLabels()`. */
35
+ export function linearTeamsApi(token: string): TeamsApi {
36
+ const client = new LinearClient({ accessToken: token });
37
+ return {
38
+ async listTeams() {
39
+ const conn = await client.teams();
40
+ while (conn.pageInfo.hasNextPage) await conn.fetchNext();
41
+ return conn.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name }));
42
+ },
43
+ };
44
+ }