@dahrk/linear 0.1.1 → 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/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. `["skakel-site"]` - so the hub can bind every issue in that project to
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.
@@ -79,11 +79,10 @@ export interface StateLike {
79
79
  type: string;
80
80
  }
81
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`. */
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 ?? process.env.SKAKEL_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
- /** Absent on some refresh responses; callers keep the prior refresh token when so. */
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
- /** Rotate the access token using the stored refresh token (the hub calls this near expiry / on 401). */
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
  /**