@dahrk/linear 0.1.1 → 0.3.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/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/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +53 -4
- package/dist/index.js.map +1 -1
- package/dist/issue-read.d.ts +21 -0
- package/dist/issue-read.d.ts.map +1 -0
- package/dist/issue-read.js +38 -0
- package/dist/issue-read.js.map +1 -0
- package/dist/issues.d.ts +24 -0
- package/dist/issues.d.ts.map +1 -1
- package/dist/issues.js +28 -0
- package/dist/issues.js.map +1 -1
- package/dist/labels.d.ts +1 -1
- package/dist/labels.js +1 -1
- package/dist/linear-client.d.ts.map +1 -1
- package/dist/linear-client.js +3 -4
- package/dist/linear-client.js.map +1 -1
- package/dist/oauth.d.ts +41 -2
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +56 -2
- package/dist/oauth.js.map +1 -1
- package/package.json +2 -2
- package/src/batch-source.ts +208 -0
- package/src/index.ts +71 -8
- package/src/issue-read.ts +52 -0
- package/src/issues.ts +49 -0
- package/src/labels.ts +1 -1
- package/src/linear-client.ts +3 -4
- package/src/oauth.ts +64 -3
package/src/index.ts
CHANGED
|
@@ -83,6 +83,43 @@ function directiveFromPromptContext(promptContext: string): { commentId?: string
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Compute the NAMES of the labels newly added by an `Issue`/`update` webhook (DHK-93). Positive
|
|
88
|
+
* evidence only: `updatedFrom` must carry a `labelIds` array (the pre-change set) - a title/description
|
|
89
|
+
* edit that never touched labels carries none, so it yields []. The new set is read from `data.labelIds`
|
|
90
|
+
* (falling back to the ids on `data.labels`), the added ids are the new-set minus the old-set, and each
|
|
91
|
+
* added id is resolved to a name via `data.labels` (`[{id,name}]`). An added id with no resolvable name
|
|
92
|
+
* is dropped, so a payload we cannot name from produces no false trigger. Pure; no LLM.
|
|
93
|
+
*/
|
|
94
|
+
function addedLabelNames(
|
|
95
|
+
data: Record<string, unknown> | undefined,
|
|
96
|
+
updatedFrom: Record<string, unknown> | null | undefined,
|
|
97
|
+
): string[] {
|
|
98
|
+
if (!data || !updatedFrom) return [];
|
|
99
|
+
const oldIds = updatedFrom.labelIds;
|
|
100
|
+
if (!Array.isArray(oldIds)) return []; // labels were not part of this change
|
|
101
|
+
const oldSet = new Set(oldIds.filter((v): v is string => typeof v === "string"));
|
|
102
|
+
const labels = Array.isArray(data.labels)
|
|
103
|
+
? (data.labels as unknown[]).filter(
|
|
104
|
+
(l): l is { id?: string; name?: string } => !!l && typeof l === "object",
|
|
105
|
+
)
|
|
106
|
+
: [];
|
|
107
|
+
const nameById = new Map<string, string>();
|
|
108
|
+
for (const l of labels) {
|
|
109
|
+
if (typeof l.id === "string" && typeof l.name === "string") nameById.set(l.id, l.name);
|
|
110
|
+
}
|
|
111
|
+
const newIds = Array.isArray(data.labelIds)
|
|
112
|
+
? (data.labelIds as unknown[]).filter((v): v is string => typeof v === "string")
|
|
113
|
+
: labels.map((l) => l.id).filter((v): v is string => typeof v === "string");
|
|
114
|
+
const names: string[] = [];
|
|
115
|
+
for (const id of newIds) {
|
|
116
|
+
if (oldSet.has(id)) continue; // pre-existing, not part of the delta
|
|
117
|
+
const name = nameById.get(id);
|
|
118
|
+
if (name !== undefined) names.push(name);
|
|
119
|
+
}
|
|
120
|
+
return names;
|
|
121
|
+
}
|
|
122
|
+
|
|
86
123
|
/** Fixed 👍/👎 -> sentiment lookup. A reaction's sentiment comes from this table, never an LLM, so the
|
|
87
124
|
* determinism boundary holds. Returns undefined for any emoji we do not treat as feedback. */
|
|
88
125
|
function reactionSentiment(emoji: string | undefined): "positive" | "negative" | undefined {
|
|
@@ -194,13 +231,17 @@ export function normalise(payload: LinearWebhookPayload, connectionId: string, t
|
|
|
194
231
|
if (action === "issueUnassignedFromYou") {
|
|
195
232
|
return { ...base, type: "issue-unassigned", subject: { issueId } };
|
|
196
233
|
}
|
|
197
|
-
// Inbox notification: the issue's status changed.
|
|
198
|
-
// a disengagement signal (stop its run); any other status change falls through to ignored.
|
|
234
|
+
// Inbox notification: the issue's status changed. A move to a *canceled* or *completed* workflow-state
|
|
235
|
+
// type is a disengagement signal (stop its run); any other status change falls through to ignored.
|
|
236
|
+
// (A completed move only settles a run parked at a gate; that gate-open rule lives on the hub side.)
|
|
199
237
|
if (action === "issueStatusChanged") {
|
|
200
238
|
const stateType = n.issue?.state?.type ?? n.issueStatusType;
|
|
201
239
|
if (stateType === "canceled") {
|
|
202
240
|
return { ...base, type: "issue-canceled", subject: { issueId } };
|
|
203
241
|
}
|
|
242
|
+
if (stateType === "completed") {
|
|
243
|
+
return { ...base, type: "issue-completed", subject: { issueId } };
|
|
244
|
+
}
|
|
204
245
|
}
|
|
205
246
|
// Inbox notification: a 👍/👎 reaction on a Dahrk response -> a feedback signal. A non-thumb emoji
|
|
206
247
|
// falls through to ignored (never mis-dispatched).
|
|
@@ -261,7 +302,7 @@ export function normalise(payload: LinearWebhookPayload, connectionId: string, t
|
|
|
261
302
|
if (payload.type === "Issue") {
|
|
262
303
|
const p = payload as EntityWebhookPayloadWithIssueData & {
|
|
263
304
|
issue?: { id?: string } | null;
|
|
264
|
-
data?: { state?: { type?: string } | null } | null;
|
|
305
|
+
data?: (Record<string, unknown> & { state?: { type?: string } | null }) | null;
|
|
265
306
|
updatedFrom?: Record<string, unknown> | null;
|
|
266
307
|
};
|
|
267
308
|
// Live "Issue" entity webhooks carry the issue under `data`; tolerate a top-level `issue` too.
|
|
@@ -279,6 +320,13 @@ export function normalise(payload: LinearWebhookPayload, connectionId: string, t
|
|
|
279
320
|
if (p.data?.state?.type === "triage" && p.updatedFrom && "stateId" in p.updatedFrom) {
|
|
280
321
|
return { ...base, type: "triage-entry", subject: { issueId } };
|
|
281
322
|
}
|
|
323
|
+
// Label add (DHK-93): a workflow-trigger label was applied. Emit a distinct `label-added` only on
|
|
324
|
+
// positive evidence - `updatedFrom` carries `labelIds` and at least one added label name resolves -
|
|
325
|
+
// so a pure removal, or any edit that never touched labels, still falls through to `issue-created`.
|
|
326
|
+
const addedLabels = addedLabelNames(p.data ?? undefined, p.updatedFrom);
|
|
327
|
+
if (addedLabels.length > 0) {
|
|
328
|
+
return { ...base, type: "label-added", subject: { issueId }, addedLabels };
|
|
329
|
+
}
|
|
282
330
|
return { ...base, type: "issue-created", subject: { issueId } };
|
|
283
331
|
}
|
|
284
332
|
|
|
@@ -312,7 +360,7 @@ export type StartIssueSkip =
|
|
|
312
360
|
* silently sitting In Progress. `moved` is true only when the issue was actually transitioned into a
|
|
313
361
|
* review state; otherwise `reason` says why not - benign (`already-there`, e.g. a closed issue or one
|
|
314
362
|
* already in the review state) vs a real gap (`no-review-state`: this team has no state named
|
|
315
|
-
* review/in review/qa and `
|
|
363
|
+
* review/in review/qa and `DAHRK_REVIEW_STATE_NAME` is unset/unmatched). */
|
|
316
364
|
export type MoveToReviewOutcome =
|
|
317
365
|
| { moved: true; stateName: string }
|
|
318
366
|
| { moved: false; reason: MoveToReviewSkip };
|
|
@@ -470,7 +518,7 @@ export interface AgentSessionClient {
|
|
|
470
518
|
* instead of it vanishing (see `StartIssueOutcome`). */
|
|
471
519
|
startIssue(sessionId: string): Promise<StartIssueOutcome>;
|
|
472
520
|
/** Move the session's issue to a review-named state on run completion (configurable via
|
|
473
|
-
* `
|
|
521
|
+
* `DAHRK_REVIEW_STATE_NAME`). Leaves it unchanged when no such state exists - never
|
|
474
522
|
* auto-completes, so a human keeps iterating and can re-summon @Dahrk on the same PR. Returns an
|
|
475
523
|
* outcome so a caller can surface a no-op (no matching state) rather than have the ticket silently
|
|
476
524
|
* stay In Progress (see `MoveToReviewOutcome`). */
|
|
@@ -518,7 +566,7 @@ export type {
|
|
|
518
566
|
} from "./responding-client.js";
|
|
519
567
|
export { createLinearClient } from "./linear-client.js";
|
|
520
568
|
export { formatToolAction, formatToolResult, type ToolAction } from "./format-action.js";
|
|
521
|
-
export { authorizeUrl, exchangeCode, refreshTokens, mintAppToken, fetchOrganizationId, probeToken, DEFAULT_AGENT_SCOPES } from "./oauth.js";
|
|
569
|
+
export { authorizeUrl, exchangeCode, refreshTokens, revokeToken, mintAppToken, fetchOrganizationId, probeToken, DEFAULT_AGENT_SCOPES } from "./oauth.js";
|
|
522
570
|
export type { LinearTokens, LinearProbe } from "./oauth.js";
|
|
523
571
|
export {
|
|
524
572
|
provisionLabels,
|
|
@@ -535,8 +583,8 @@ export {
|
|
|
535
583
|
export type { LabelApi, ProvisionOptions, OpenBlocker } from "./labels.js";
|
|
536
584
|
export { linearTeamsApi, listWorkspaceTeams } from "./teams.js";
|
|
537
585
|
export type { Team, TeamsApi } from "./teams.js";
|
|
538
|
-
export { linearTriageApi, linearClientAuth } from "./issues.js";
|
|
539
|
-
export type { TriageApi } from "./issues.js";
|
|
586
|
+
export { linearTriageApi, linearClientAuth, linearCaptureApi } from "./issues.js";
|
|
587
|
+
export type { TriageApi, CaptureLinearApi, CaptureIssueResult } from "./issues.js";
|
|
540
588
|
export {
|
|
541
589
|
fetchAttachedDocuments,
|
|
542
590
|
collectAttachedDocuments,
|
|
@@ -554,3 +602,18 @@ export {
|
|
|
554
602
|
MAX_RELATED_ISSUES,
|
|
555
603
|
} from "./issue-graph.js";
|
|
556
604
|
export type { IssueGraphSource, RawEdge, RawRelatedIssue } from "./issue-graph.js";
|
|
605
|
+
export { fetchIssueProjection } from "./issue-read.js";
|
|
606
|
+
export type { IssueReadProjection } from "./issue-read.js";
|
|
607
|
+
export {
|
|
608
|
+
fetchParentBatchSource,
|
|
609
|
+
collectParentBatchSource,
|
|
610
|
+
linearParentBatchSource,
|
|
611
|
+
MAX_BATCH_CHILDREN,
|
|
612
|
+
} from "./batch-source.js";
|
|
613
|
+
export type {
|
|
614
|
+
ParentBatchSource,
|
|
615
|
+
ParentBatchSnapshot,
|
|
616
|
+
RawChild,
|
|
617
|
+
RawBlocker,
|
|
618
|
+
ExternalBlocker,
|
|
619
|
+
} from "./batch-source.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read one Linear issue as a bounded projection (DHK-1178), for the hub-owned workspace MCP server's
|
|
3
|
+
* `get_issue` tool. The hub performs the call with the tenant's own connection token; this module keeps
|
|
4
|
+
* the Linear SDK usage inside `@dahrk/linear` (as `documents.ts`/`issue-graph.ts` do) so the hub and the
|
|
5
|
+
* workspace-mcp package stay SDK-free.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a bounded projection, NOT the raw SDK object: only the fields a stage needs to reason
|
|
8
|
+
* about an issue, so nothing unexpected rides back to the agent.
|
|
9
|
+
*/
|
|
10
|
+
import { LinearClient } from "@linear/sdk";
|
|
11
|
+
|
|
12
|
+
/** The fields `get_issue` returns. A subset of Linear's Issue, resolved from the id/identifier. */
|
|
13
|
+
export interface IssueReadProjection {
|
|
14
|
+
id: string;
|
|
15
|
+
identifier?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
/** The workflow state's display name (e.g. "In Progress"). */
|
|
19
|
+
state?: string;
|
|
20
|
+
/** The assignee's display name, when one is set. */
|
|
21
|
+
assignee?: string;
|
|
22
|
+
/** The issue's label names. */
|
|
23
|
+
labels?: string[];
|
|
24
|
+
url?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a single issue by its id (uuid) or human identifier (e.g. `DHK-1178`) and project it to
|
|
29
|
+
* {@link IssueReadProjection}. Returns null when Linear resolves no such issue. A hard failure (auth,
|
|
30
|
+
* network) propagates so the caller can surface it as a tool error.
|
|
31
|
+
*/
|
|
32
|
+
export async function fetchIssueProjection(token: string, id: string): Promise<IssueReadProjection | null> {
|
|
33
|
+
const client = new LinearClient({ accessToken: token });
|
|
34
|
+
const issue = await client.issue(id).catch(() => null);
|
|
35
|
+
if (!issue) return null;
|
|
36
|
+
// The nested reads (state, assignee, labels) are independent promises; run them together.
|
|
37
|
+
const [state, assignee, labels] = await Promise.all([
|
|
38
|
+
issue.state,
|
|
39
|
+
issue.assignee,
|
|
40
|
+
issue.labels({ first: 50 }),
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
id: issue.id,
|
|
44
|
+
...(issue.identifier ? { identifier: issue.identifier } : {}),
|
|
45
|
+
...(issue.title ? { title: issue.title } : {}),
|
|
46
|
+
...(issue.description ? { description: issue.description } : {}),
|
|
47
|
+
...(state?.name ? { state: state.name } : {}),
|
|
48
|
+
...(assignee?.displayName ? { assignee: assignee.displayName } : {}),
|
|
49
|
+
...(labels.nodes.length > 0 ? { labels: labels.nodes.map((l) => l.name) } : {}),
|
|
50
|
+
...(issue.url ? { url: issue.url } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/issues.ts
CHANGED
|
@@ -91,3 +91,52 @@ export function linearTriageApi(token: string): TriageApi {
|
|
|
91
91
|
},
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/** The result of a Capture create/update: the issue's internal id plus its human identifier and url. */
|
|
96
|
+
export interface CaptureIssueResult {
|
|
97
|
+
issueId: string;
|
|
98
|
+
identifier: string;
|
|
99
|
+
url: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Capture Linear seam (DHK-1187): create an issue in a team (or under a project), or update an
|
|
103
|
+
* existing one. Unlike {@link TriageApi}, it returns the issue's internal `issueId` too (a Capture
|
|
104
|
+
* receipt needs it) and can update. Backed by a CUSTOMER connection token, never the hub triage token. */
|
|
105
|
+
export interface CaptureLinearApi {
|
|
106
|
+
createIssue(input: {
|
|
107
|
+
teamKey: string;
|
|
108
|
+
projectName?: string;
|
|
109
|
+
title: string;
|
|
110
|
+
description: string;
|
|
111
|
+
}): Promise<CaptureIssueResult>;
|
|
112
|
+
updateIssue(input: { issueId: string; title: string; description: string }): Promise<CaptureIssueResult>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The live {@link CaptureLinearApi} backed by a tenant connection token (API key or OAuth token). */
|
|
116
|
+
export function linearCaptureApi(token: string): CaptureLinearApi {
|
|
117
|
+
const client = new LinearClient(linearClientAuth(token));
|
|
118
|
+
return {
|
|
119
|
+
async createIssue(input) {
|
|
120
|
+
const team = (await client.teams({ filter: { key: { eq: input.teamKey } } })).nodes[0];
|
|
121
|
+
if (!team) throw new Error(`capture team not found: ${input.teamKey}`);
|
|
122
|
+
let projectId: string | undefined;
|
|
123
|
+
if (input.projectName) {
|
|
124
|
+
projectId = (await client.projects({ filter: { name: { eq: input.projectName } } })).nodes[0]?.id;
|
|
125
|
+
}
|
|
126
|
+
const payload = await client.createIssue({
|
|
127
|
+
teamId: team.id,
|
|
128
|
+
title: input.title,
|
|
129
|
+
description: input.description,
|
|
130
|
+
...(projectId ? { projectId } : {}),
|
|
131
|
+
});
|
|
132
|
+
const issue = await payload.issue;
|
|
133
|
+
return { issueId: issue?.id ?? "", identifier: issue?.identifier ?? "", url: issue?.url ?? "" };
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async updateIssue(input) {
|
|
137
|
+
const payload = await client.updateIssue(input.issueId, { title: input.title, description: input.description });
|
|
138
|
+
const issue = await payload.issue;
|
|
139
|
+
return { issueId: issue?.id ?? input.issueId, identifier: issue?.identifier ?? "", url: issue?.url ?? "" };
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/labels.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function provisionRepoLabels(
|
|
|
115
115
|
/**
|
|
116
116
|
* Fetch the child names of the issue's **project** `repo` label group (project labels are a separate
|
|
117
117
|
* Linear namespace from issue labels, `Project.labels`). Returns the names of project labels whose
|
|
118
|
-
* parent group is `repo` - e.g. `["
|
|
118
|
+
* parent group is `repo` - e.g. `["dahrk-web"]` - so the hub can bind every issue in that project to
|
|
119
119
|
* a repo via the project-label routing tier. Empty when the issue has no project, no project labels, or
|
|
120
120
|
* no `repo` group. A read against the connection token; data assembly, not control flow, so the result
|
|
121
121
|
* feeds deterministic routing and is snapshotted into the run.
|
package/src/linear-client.ts
CHANGED
|
@@ -79,11 +79,10 @@ export interface StateLike {
|
|
|
79
79
|
type: string;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** The configured review-state name.
|
|
83
|
-
*
|
|
84
|
-
* dependency on the hub, so it cannot reuse the hub's `envRenamed`. */
|
|
82
|
+
/** The configured review-state name. This package has no dependency on the hub, so it cannot reuse the
|
|
83
|
+
* hub's `envValue`. The pre-rename legacy twin was dropped: no current host sets it. */
|
|
85
84
|
function reviewStateNameEnv(): string | undefined {
|
|
86
|
-
return process.env.DAHRK_REVIEW_STATE_NAME
|
|
85
|
+
return process.env.DAHRK_REVIEW_STATE_NAME;
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
/** Find the review-target state the way `moveIssueToReview` does, so `startIssue` can EXCLUDE it.
|
package/src/oauth.ts
CHANGED
|
@@ -11,13 +11,17 @@ import { LinearClient } from "@linear/sdk";
|
|
|
11
11
|
|
|
12
12
|
const AUTHORIZE_URL = "https://linear.app/oauth/authorize";
|
|
13
13
|
const TOKEN_URL = "https://api.linear.app/oauth/token";
|
|
14
|
+
const REVOKE_URL = "https://api.linear.app/oauth/revoke";
|
|
14
15
|
|
|
15
16
|
/** The agent scopes: read/write plus the two that make the app assignable + mentionable. */
|
|
16
17
|
export const DEFAULT_AGENT_SCOPES = ["read", "write", "app:assignable", "app:mentionable"] as const;
|
|
17
18
|
|
|
18
19
|
export interface LinearTokens {
|
|
19
20
|
accessToken: string;
|
|
20
|
-
/**
|
|
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}. */
|
|
21
25
|
refreshToken?: string;
|
|
22
26
|
/** When the access token expires (computed from `expires_in`). */
|
|
23
27
|
expiresAt: Date;
|
|
@@ -155,7 +159,20 @@ export async function probeToken(accessToken: string): Promise<LinearProbe> {
|
|
|
155
159
|
return probe;
|
|
156
160
|
}
|
|
157
161
|
|
|
158
|
-
/**
|
|
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
|
+
*/
|
|
159
176
|
export function refreshTokens(
|
|
160
177
|
params: { clientId: string; clientSecret: string; refreshToken: string },
|
|
161
178
|
now: () => Date = () => new Date(),
|
|
@@ -168,7 +185,51 @@ export function refreshTokens(
|
|
|
168
185
|
refresh_token: params.refreshToken,
|
|
169
186
|
},
|
|
170
187
|
now,
|
|
171
|
-
)
|
|
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}`);
|
|
172
233
|
}
|
|
173
234
|
|
|
174
235
|
/**
|