@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
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
|
+
}
|