@enrichlayer/el-linear 1.44.1 → 1.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Fetch project summaries, embedded teams, and lead in one paginated request.
3
+ * The conditional team branch replaces SDK relation follow-ups and keeps both
4
+ * request count and GraphQL complexity visible at one catalog boundary.
5
+ */
6
+ export declare const GET_PROJECTS_CATALOG_QUERY = "\n query GetProjectsCatalog(\n $filter: ProjectFilter\n\t\t$teamId: String!\n\t\t$teamScoped: Boolean!\n $first: Int!\n $after: String\n ) {\n projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) @skip(if: $teamScoped) {\n nodes { \n id\n name\n description\n state\n progress\n targetDate\n createdAt\n updatedAt\n lead { id name }\n teams { nodes { id key name } }\n }\n pageInfo { hasNextPage endCursor }\n }\n team(id: $teamId) @include(if: $teamScoped) {\n projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) {\n nodes { \n id\n name\n description\n state\n progress\n targetDate\n createdAt\n updatedAt\n lead { id name }\n teams { nodes { id key name } }\n }\n pageInfo { hasNextPage endCursor }\n }\n }\n }\n";
7
+ /** Batch label metadata and parent/team relations into one bounded catalog read. */
8
+ export declare const GET_LABELS_CATALOG_QUERY = "\n query GetLabelsCatalog($filter: IssueLabelFilter, $first: Int!) {\n issueLabels(filter: $filter, first: $first) {\n nodes {\n id\n name\n color\n isGroup\n parent { id name }\n team { id key name }\n }\n }\n }\n";
9
+ /** Batch cycle summaries and their team relation instead of resolving each edge. */
10
+ export declare const GET_CYCLES_CATALOG_QUERY = "\n query GetCyclesCatalog($filter: CycleFilter, $first: Int!) {\n cycles(filter: $filter, first: $first, orderBy: createdAt) {\n nodes {\n id\n name\n number\n startsAt\n endsAt\n isActive\n isPrevious\n isNext\n progress\n issueCountHistory\n team { id key name }\n }\n }\n }\n";
11
+ /**
12
+ * Read one cycle and its bounded issue catalog in a single query, avoiding the
13
+ * SDK's per-issue relation fan-out while keeping the issue page size explicit.
14
+ */
15
+ export declare const GET_CYCLE_DETAIL_QUERY = "\n query GetCycleDetail($id: String!, $issuesFirst: Int!) {\n cycle(id: $id) {\n id\n name\n number\n startsAt\n endsAt\n isActive\n isPrevious\n isNext\n progress\n issueCountHistory\n team { id key name }\n issues(first: $issuesFirst) {\n nodes {\n id\n identifier\n url\n title\n description\n priority\n estimate\n createdAt\n updatedAt\n state { id name }\n assignee { id name }\n team { id key name }\n project { id name }\n labels { nodes { id name } }\n }\n }\n }\n }\n";
16
+ /**
17
+ * Resolve a project name/slug with one bounded query. The conditional team
18
+ * branch performs scoping server-side without a separate project catalog read.
19
+ */
20
+ export declare const RESOLVE_PROJECT_QUERY = "\n query ResolveProjectCatalog(\n $filter: ProjectFilter!\n\t\t$teamId: String!\n\t\t$teamScoped: Boolean!\n $includeArchived: Boolean!\n ) {\n projects(filter: $filter, first: 5, includeArchived: $includeArchived) @skip(if: $teamScoped) {\n nodes {\n id\n name\n teams { nodes { id key name } }\n }\n }\n team(id: $teamId) @include(if: $teamScoped) {\n projects(filter: $filter, first: 5, includeArchived: $includeArchived) {\n nodes {\n id\n name\n teams { nodes { id key name } }\n }\n }\n }\n }\n";
@@ -0,0 +1,140 @@
1
+ const PROJECT_FIELDS = `
2
+ id
3
+ name
4
+ description
5
+ state
6
+ progress
7
+ targetDate
8
+ createdAt
9
+ updatedAt
10
+ lead { id name }
11
+ teams { nodes { id key name } }
12
+ `;
13
+ /**
14
+ * Fetch project summaries, embedded teams, and lead in one paginated request.
15
+ * The conditional team branch replaces SDK relation follow-ups and keeps both
16
+ * request count and GraphQL complexity visible at one catalog boundary.
17
+ */
18
+ export const GET_PROJECTS_CATALOG_QUERY = `
19
+ query GetProjectsCatalog(
20
+ $filter: ProjectFilter
21
+ $teamId: String!
22
+ $teamScoped: Boolean!
23
+ $first: Int!
24
+ $after: String
25
+ ) {
26
+ projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) @skip(if: $teamScoped) {
27
+ nodes { ${PROJECT_FIELDS} }
28
+ pageInfo { hasNextPage endCursor }
29
+ }
30
+ team(id: $teamId) @include(if: $teamScoped) {
31
+ projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt, includeArchived: false) {
32
+ nodes { ${PROJECT_FIELDS} }
33
+ pageInfo { hasNextPage endCursor }
34
+ }
35
+ }
36
+ }
37
+ `;
38
+ /** Batch label metadata and parent/team relations into one bounded catalog read. */
39
+ export const GET_LABELS_CATALOG_QUERY = `
40
+ query GetLabelsCatalog($filter: IssueLabelFilter, $first: Int!) {
41
+ issueLabels(filter: $filter, first: $first) {
42
+ nodes {
43
+ id
44
+ name
45
+ color
46
+ isGroup
47
+ parent { id name }
48
+ team { id key name }
49
+ }
50
+ }
51
+ }
52
+ `;
53
+ /** Batch cycle summaries and their team relation instead of resolving each edge. */
54
+ export const GET_CYCLES_CATALOG_QUERY = `
55
+ query GetCyclesCatalog($filter: CycleFilter, $first: Int!) {
56
+ cycles(filter: $filter, first: $first, orderBy: createdAt) {
57
+ nodes {
58
+ id
59
+ name
60
+ number
61
+ startsAt
62
+ endsAt
63
+ isActive
64
+ isPrevious
65
+ isNext
66
+ progress
67
+ issueCountHistory
68
+ team { id key name }
69
+ }
70
+ }
71
+ }
72
+ `;
73
+ /**
74
+ * Read one cycle and its bounded issue catalog in a single query, avoiding the
75
+ * SDK's per-issue relation fan-out while keeping the issue page size explicit.
76
+ */
77
+ export const GET_CYCLE_DETAIL_QUERY = `
78
+ query GetCycleDetail($id: String!, $issuesFirst: Int!) {
79
+ cycle(id: $id) {
80
+ id
81
+ name
82
+ number
83
+ startsAt
84
+ endsAt
85
+ isActive
86
+ isPrevious
87
+ isNext
88
+ progress
89
+ issueCountHistory
90
+ team { id key name }
91
+ issues(first: $issuesFirst) {
92
+ nodes {
93
+ id
94
+ identifier
95
+ url
96
+ title
97
+ description
98
+ priority
99
+ estimate
100
+ createdAt
101
+ updatedAt
102
+ state { id name }
103
+ assignee { id name }
104
+ team { id key name }
105
+ project { id name }
106
+ labels { nodes { id name } }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ `;
112
+ /**
113
+ * Resolve a project name/slug with one bounded query. The conditional team
114
+ * branch performs scoping server-side without a separate project catalog read.
115
+ */
116
+ export const RESOLVE_PROJECT_QUERY = `
117
+ query ResolveProjectCatalog(
118
+ $filter: ProjectFilter!
119
+ $teamId: String!
120
+ $teamScoped: Boolean!
121
+ $includeArchived: Boolean!
122
+ ) {
123
+ projects(filter: $filter, first: 5, includeArchived: $includeArchived) @skip(if: $teamScoped) {
124
+ nodes {
125
+ id
126
+ name
127
+ teams { nodes { id key name } }
128
+ }
129
+ }
130
+ team(id: $teamId) @include(if: $teamScoped) {
131
+ projects(filter: $filter, first: 5, includeArchived: $includeArchived) {
132
+ nodes {
133
+ id
134
+ name
135
+ teams { nodes { id key name } }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ `;
@@ -24,6 +24,7 @@
24
24
  import { randomBytes } from "node:crypto";
25
25
  import fs from "node:fs/promises";
26
26
  import path from "node:path";
27
+ import { withFileLock } from "../auth/oauth-fs.js";
27
28
  import { resolveActiveProfile } from "../config/paths.js";
28
29
  import { logger } from "./logger.js";
29
30
  const CACHE_VERSION = 1;
@@ -117,31 +118,64 @@ export async function cached(key, ttlSeconds, fetcher, options) {
117
118
  if (ttlSeconds <= 0) {
118
119
  return fetcher();
119
120
  }
120
- const now = Date.now();
121
121
  if (!options?.bypass) {
122
122
  const envelope = await readEnvelope(key);
123
- if (envelope && envelope.expiresAt > now) {
123
+ if (envelope && envelope.expiresAt > Date.now()) {
124
124
  return envelope.data;
125
125
  }
126
126
  }
127
- const data = await fetcher();
128
- const envelope = {
129
- v: CACHE_VERSION,
130
- key,
131
- fetchedAt: now,
132
- expiresAt: now + ttlSeconds * 1000,
133
- data,
127
+ const fetchAndStore = async () => {
128
+ const data = await fetcher();
129
+ const now = Date.now();
130
+ const envelope = {
131
+ v: CACHE_VERSION,
132
+ key,
133
+ fetchedAt: now,
134
+ expiresAt: now + ttlSeconds * 1000,
135
+ data,
136
+ };
137
+ try {
138
+ await writeEnvelope(key, envelope);
139
+ }
140
+ catch (err) {
141
+ // Cache writes are best-effort — log to stderr and return the data
142
+ // anyway so a flaky disk doesn't break the user's command.
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
145
+ }
146
+ return data;
134
147
  };
148
+ // `--no-cache` is an explicit request to bypass both the cached value and
149
+ // cache coordination. Preserve that contract instead of making two forced
150
+ // refreshes wait on one another.
151
+ if (options?.bypass)
152
+ return fetchAndStore();
153
+ // Cold-cache single flight across processes. Re-read only after acquiring
154
+ // the sidecar lock: another el-linear invocation may have populated the
155
+ // envelope while this process was waiting. Without this second read, a
156
+ // burst of identical commands still sends one Linear request per process.
157
+ let enteredCriticalSection = false;
135
158
  try {
136
- await writeEnvelope(key, envelope);
159
+ await fs.mkdir(cacheDir(), { recursive: true, mode: CACHE_DIR_MODE });
160
+ return await withFileLock(cachePath(key), async () => {
161
+ enteredCriticalSection = true;
162
+ const winner = await readEnvelope(key);
163
+ if (winner && winner.expiresAt > Date.now())
164
+ return winner.data;
165
+ return fetchAndStore();
166
+ });
137
167
  }
138
168
  catch (err) {
139
- // Cache writes are best-effort — log to stderr and return the data
140
- // anyway so a flaky disk doesn't break the user's command.
169
+ // Never retry a failed fetcher: a write may have reached Linear before
170
+ // throwing, so replaying it would be unsafe. Only coordination failures
171
+ // that happen before entering the critical section degrade to the old
172
+ // uncoordinated read-through behavior.
173
+ if (enteredCriticalSection)
174
+ throw err;
141
175
  const msg = err instanceof Error ? err.message : String(err);
142
- logger.error(`[disk-cache] write failed for "${key}": ${msg}`);
176
+ logger.error(`[disk-cache] single-flight unavailable for "${key}": ${msg}`);
177
+ return fetchAndStore();
143
178
  }
144
- return data;
145
179
  }
146
180
  /**
147
181
  * Clear cached entries. With no `prefix`, removes the entire cache
@@ -1,6 +1,10 @@
1
+ import { LinearClient } from "@linear/sdk";
1
2
  import type { LinearCredential } from "../auth/linear-credential.js";
3
+ import type { OAuthState } from "../auth/oauth-storage.js";
2
4
  import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
3
5
  import type { AuthOptions } from "./auth.js";
6
+ import { type RateLimitInfo } from "./output.js";
7
+ import { type RateLimitAdmission } from "./rate-limit-admission.js";
4
8
  export interface GraphQLRequestOptions {
5
9
  /**
6
10
  * Retry only an operation whose mutation is safe to repeat. Callers must
@@ -13,8 +17,30 @@ export interface GraphQLRequestOptions {
13
17
  interface GraphQLServiceRuntimeOptions {
14
18
  retryDelaysMs?: readonly number[];
15
19
  sleep?: (ms: number) => Promise<void>;
20
+ now?: () => number;
21
+ /** Test/embedding seam; production derives this from EL_LINEAR_RATE_LIMIT_HEADROOM. */
22
+ admission?: RateLimitAdmission;
23
+ /** Reacquire an app-user client-credentials token after HTTP 401. */
24
+ renewAccessToken?: () => Promise<string>;
16
25
  }
17
- /** True only for failures where retrying a known-idempotent request is useful. */
26
+ type OAuthStateRenewal = (state: OAuthState, options: {
27
+ forceOAuthRenewal: true;
28
+ }) => Promise<OAuthState>;
29
+ /**
30
+ * Keep the last persisted client-credentials state in a long-lived service.
31
+ * Each forced 401 renewal must advance from the previous renewal, not from the
32
+ * state captured when the service was constructed.
33
+ */
34
+ export declare function createClientCredentialsTokenRenewal(initialState: OAuthState, renew?: OAuthStateRenewal): (() => Promise<string>) | undefined;
35
+ export declare function extractRateLimitInfo(source: unknown, now: () => number): RateLimitInfo | undefined;
36
+ /** True for both HTTP and GraphQL representations of Linear rate limiting. */
37
+ export declare function isRateLimitGraphQLError(error: unknown): boolean;
38
+ /**
39
+ * True only for failures where retrying a known-idempotent request immediately
40
+ * is useful. This deliberately differs from published `errorDetail.retryable`:
41
+ * a rate limit is transient for an external caller with reset-aware backoff,
42
+ * but must never enter this CLI's 150 ms / 400 ms retry loop.
43
+ */
18
44
  export declare function isTransientGraphQLError(error: unknown): boolean;
19
45
  /**
20
46
  * Constructor arg for `GraphQLService`. Re-exported alias of the shared
@@ -28,11 +54,26 @@ export declare function isTransientGraphQLError(error: unknown): boolean;
28
54
  * semantic change — same `Authorization: <token>` header is emitted).
29
55
  */
30
56
  export type GraphQLServiceAuth = LinearCredential;
57
+ /**
58
+ * Put SDK-backed LinearService calls through the same quota admission and
59
+ * response-header observer as custom GraphQLService calls. The SDK's public
60
+ * `request()` discards response headers, so this narrow transport adapter uses
61
+ * its own public-ish rawRequest escape hatch and returns only `data` to the
62
+ * generated SDK models, preserving their existing contract.
63
+ */
64
+ export declare function instrumentLinearClient(client: LinearClient, auth: GraphQLServiceAuth, options?: {
65
+ admission?: RateLimitAdmission;
66
+ now?: () => number;
67
+ }): LinearClient;
31
68
  export declare class GraphQLService {
32
69
  private readonly graphQLClient;
33
70
  private readonly retryDelaysMs;
34
71
  private readonly sleep;
72
+ private readonly now;
73
+ private readonly admission;
74
+ private readonly renewAccessToken?;
35
75
  constructor(auth: GraphQLServiceAuth, options?: GraphQLServiceRuntimeOptions);
76
+ private observeRateLimit;
36
77
  rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables, options?: GraphQLRequestOptions): Promise<T>;
37
78
  }
38
79
  export declare function createGraphQLService(options: AuthOptions): Promise<GraphQLService>;
@@ -1,19 +1,180 @@
1
1
  import { LinearClient } from "@linear/sdk";
2
- import { getActiveAuth } from "../auth/token-resolver.js";
2
+ import { print } from "graphql";
3
+ import { ensureFreshAccessToken, getActiveAuth, } from "../auth/token-resolver.js";
4
+ import { graphQLErrorCode, graphQLErrorHttpStatus, toLinearGraphQLError, } from "./linear-graphql-error.js";
5
+ import { logger } from "./logger.js";
6
+ import { recordRateLimitInfo } from "./output.js";
7
+ import { createOAuthProfileRateLimitAdmission, createRateLimitAdmission, RateLimitAdmissionRefusal, } from "./rate-limit-admission.js";
3
8
  const DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS = [150, 400];
9
+ /**
10
+ * Keep the last persisted client-credentials state in a long-lived service.
11
+ * Each forced 401 renewal must advance from the previous renewal, not from the
12
+ * state captured when the service was constructed.
13
+ */
14
+ export function createClientCredentialsTokenRenewal(initialState, renew = ensureFreshAccessToken) {
15
+ if (initialState.grantType !== "client_credentials")
16
+ return undefined;
17
+ let currentState = initialState;
18
+ return async () => {
19
+ currentState = await renew(currentState, { forceOAuthRenewal: true });
20
+ return currentState.accessToken;
21
+ };
22
+ }
4
23
  function errorMessage(error) {
5
24
  return error instanceof Error ? error.message : String(error);
6
25
  }
7
- /** True only for failures where retrying a known-idempotent request is useful. */
26
+ function errorDetail(error) {
27
+ return error;
28
+ }
29
+ function errorResponse(error) {
30
+ const detail = errorDetail(error);
31
+ return detail.response ?? detail.raw?.response;
32
+ }
33
+ function headerValue(headers, name) {
34
+ if (!headers)
35
+ return undefined;
36
+ if ("get" in headers && typeof headers.get === "function") {
37
+ return headers.get(name) ?? undefined;
38
+ }
39
+ const expected = name.toLowerCase();
40
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === expected);
41
+ return entry?.[1];
42
+ }
43
+ function finiteNumber(value) {
44
+ if (value === undefined || value === null || value === "")
45
+ return undefined;
46
+ const parsed = typeof value === "number" ? value : Number(value);
47
+ return Number.isFinite(parsed) ? parsed : undefined;
48
+ }
49
+ function resetAtFromRetryAfter(value, now) {
50
+ if (value === undefined)
51
+ return undefined;
52
+ const seconds = finiteNumber(value);
53
+ if (seconds !== undefined) {
54
+ return new Date(now() + seconds * 1_000).toISOString();
55
+ }
56
+ if (typeof value === "string") {
57
+ const timestamp = Date.parse(value);
58
+ if (Number.isFinite(timestamp))
59
+ return new Date(timestamp).toISOString();
60
+ }
61
+ return undefined;
62
+ }
63
+ export function extractRateLimitInfo(source, now) {
64
+ const detail = errorDetail(source);
65
+ const response = errorResponse(source) ?? source;
66
+ const headers = response?.headers;
67
+ const limit = finiteNumber(detail.requestsLimit ?? headerValue(headers, "x-ratelimit-requests-limit"));
68
+ const remaining = finiteNumber(detail.requestsRemaining ??
69
+ headerValue(headers, "x-ratelimit-requests-remaining"));
70
+ const resetTimestamp = finiteNumber(detail.requestsResetAt ??
71
+ headerValue(headers, "x-ratelimit-requests-reset"));
72
+ const resetAt = resetTimestamp !== undefined
73
+ ? new Date(resetTimestamp).toISOString()
74
+ : resetAtFromRetryAfter(detail.retryAfter ?? headerValue(headers, "retry-after"), now);
75
+ const complexityCost = finiteNumber(headerValue(headers, "x-complexity"));
76
+ const complexityLimit = finiteNumber(headerValue(headers, "x-ratelimit-complexity-limit"));
77
+ const complexityRemaining = finiteNumber(headerValue(headers, "x-ratelimit-complexity-remaining"));
78
+ const complexityResetTimestamp = finiteNumber(headerValue(headers, "x-ratelimit-complexity-reset"));
79
+ const endpointName = headerValue(headers, "x-ratelimit-endpoint-name");
80
+ const endpointLimit = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-limit"));
81
+ const endpointRemaining = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-remaining"));
82
+ const endpointResetTimestamp = finiteNumber(headerValue(headers, "x-ratelimit-endpoint-requests-reset"));
83
+ const hasComplexity = complexityCost !== undefined ||
84
+ complexityLimit !== undefined ||
85
+ complexityRemaining !== undefined ||
86
+ complexityResetTimestamp !== undefined;
87
+ const hasEndpoint = endpointName !== undefined ||
88
+ endpointLimit !== undefined ||
89
+ endpointRemaining !== undefined ||
90
+ endpointResetTimestamp !== undefined;
91
+ if (limit === undefined &&
92
+ remaining === undefined &&
93
+ resetAt === undefined &&
94
+ !hasComplexity &&
95
+ !hasEndpoint) {
96
+ return undefined;
97
+ }
98
+ return {
99
+ limit,
100
+ remaining,
101
+ resetAt,
102
+ observedRequests: 1,
103
+ minimumRemaining: remaining,
104
+ ...(hasComplexity
105
+ ? {
106
+ complexity: {
107
+ cost: complexityCost,
108
+ totalCost: complexityCost,
109
+ limit: complexityLimit,
110
+ remaining: complexityRemaining,
111
+ minimumRemaining: complexityRemaining,
112
+ resetAt: complexityResetTimestamp === undefined
113
+ ? undefined
114
+ : new Date(complexityResetTimestamp).toISOString(),
115
+ },
116
+ }
117
+ : {}),
118
+ ...(hasEndpoint
119
+ ? {
120
+ endpoints: {
121
+ [endpointName ?? "unknown"]: {
122
+ limit: endpointLimit,
123
+ remaining: endpointRemaining,
124
+ minimumRemaining: endpointRemaining,
125
+ resetAt: endpointResetTimestamp === undefined
126
+ ? undefined
127
+ : new Date(endpointResetTimestamp).toISOString(),
128
+ observedRequests: 1,
129
+ },
130
+ },
131
+ }
132
+ : {}),
133
+ };
134
+ }
135
+ /** True for both HTTP and GraphQL representations of Linear rate limiting. */
136
+ export function isRateLimitGraphQLError(error) {
137
+ const detail = errorDetail(error);
138
+ if (graphQLErrorHttpStatus(error) === 429 ||
139
+ graphQLErrorCode(error)?.toUpperCase() === "RATELIMITED" ||
140
+ detail.type?.toLowerCase() === "ratelimited") {
141
+ return true;
142
+ }
143
+ return /\b(?:429|rate[- ]?limit(?:ed| exceeded))\b/i.test(errorMessage(error));
144
+ }
145
+ /**
146
+ * True only for failures where retrying a known-idempotent request immediately
147
+ * is useful. This deliberately differs from published `errorDetail.retryable`:
148
+ * a rate limit is transient for an external caller with reset-aware backoff,
149
+ * but must never enter this CLI's 150 ms / 400 ms retry loop.
150
+ */
8
151
  export function isTransientGraphQLError(error) {
9
- const detail = error;
10
- const status = detail.response?.status ?? detail.response?.statusCode;
11
- if (status === 408 ||
12
- status === 429 ||
13
- (status !== undefined && status >= 500)) {
152
+ if (isRateLimitGraphQLError(error))
153
+ return false;
154
+ const status = graphQLErrorHttpStatus(error);
155
+ if (status === 408 || (status !== null && status >= 500)) {
14
156
  return true;
15
157
  }
16
- return /\b(?:408|429|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
158
+ return /\b(?:408|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
159
+ }
160
+ function isMutationOperation(query) {
161
+ const withoutLeadingComments = query.replace(/^(?:\s*#[^\n]*(?:\n|$))*/, "");
162
+ return /^\s*mutation\b/i.test(withoutLeadingComments);
163
+ }
164
+ function rateLimitError(error, query, now) {
165
+ const rateLimit = extractRateLimitInfo(error, now);
166
+ const details = [];
167
+ if (rateLimit?.remaining !== undefined && rateLimit.limit !== undefined) {
168
+ details.push(`${rateLimit.remaining}/${rateLimit.limit} requests remain in the current window`);
169
+ }
170
+ if (rateLimit?.resetAt) {
171
+ details.push(`the request limit resets at ${rateLimit.resetAt}`);
172
+ }
173
+ const suffix = details.length > 0 ? ` ${details.join("; ")}.` : "";
174
+ const ambiguity = isMutationOperation(query)
175
+ ? " This mutation may have been applied; verify its outcome before retrying."
176
+ : "";
177
+ return toLinearGraphQLError(error, `GraphQL request failed: Rate limit exceeded.${suffix}${ambiguity}`);
17
178
  }
18
179
  function buildLinearClient(auth) {
19
180
  const baseHeaders = { "public-file-urls-expire-in": "3600" };
@@ -29,10 +190,61 @@ function buildLinearClient(auth) {
29
190
  }
30
191
  return new LinearClient({ apiKey: auth.apiKey, headers: baseHeaders });
31
192
  }
193
+ async function persistRateLimitObservation(admission, info) {
194
+ recordRateLimitInfo(info);
195
+ try {
196
+ await admission.observe(info);
197
+ }
198
+ catch (error) {
199
+ // The request already completed. Never turn an observation write
200
+ // failure into a replayable command failure (especially for mutations).
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ logger.error(`[rate-limit] could not persist quota observation: ${message}`);
203
+ }
204
+ }
205
+ /**
206
+ * Put SDK-backed LinearService calls through the same quota admission and
207
+ * response-header observer as custom GraphQLService calls. The SDK's public
208
+ * `request()` discards response headers, so this narrow transport adapter uses
209
+ * its own public-ish rawRequest escape hatch and returns only `data` to the
210
+ * generated SDK models, preserving their existing contract.
211
+ */
212
+ export function instrumentLinearClient(client, auth, options = {}) {
213
+ const transport = client
214
+ .client;
215
+ // Unit-test doubles and future SDK versions may not expose the escape hatch.
216
+ // Leave them untouched rather than manufacturing a partial transport.
217
+ if (!transport?.rawRequest || !transport.request)
218
+ return client;
219
+ const originalRawRequest = transport.rawRequest.bind(transport);
220
+ const admission = options.admission ?? createRateLimitAdmission(auth, { now: options.now });
221
+ const now = options.now ?? Date.now;
222
+ transport.request = async (document, variables, requestHeaders) => {
223
+ await admission.admit();
224
+ const query = typeof document === "string" ? document : print(document);
225
+ try {
226
+ const response = await originalRawRequest(query, variables, requestHeaders);
227
+ const rateLimit = extractRateLimitInfo(response, now);
228
+ if (rateLimit)
229
+ await persistRateLimitObservation(admission, rateLimit);
230
+ return response.data;
231
+ }
232
+ catch (error) {
233
+ const rateLimit = extractRateLimitInfo(error, now);
234
+ if (rateLimit)
235
+ await persistRateLimitObservation(admission, rateLimit);
236
+ throw error;
237
+ }
238
+ };
239
+ return client;
240
+ }
32
241
  export class GraphQLService {
33
242
  graphQLClient;
34
243
  retryDelaysMs;
35
244
  sleep;
245
+ now;
246
+ admission;
247
+ renewAccessToken;
36
248
  constructor(auth, options = {}) {
37
249
  const client = buildLinearClient(auth);
38
250
  // LinearClient stores a private graphql-request client — access via escape hatch
@@ -42,18 +254,43 @@ export class GraphQLService {
42
254
  this.sleep =
43
255
  options.sleep ??
44
256
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
257
+ this.now = options.now ?? Date.now;
258
+ this.admission =
259
+ options.admission ?? createRateLimitAdmission(auth, { now: this.now });
260
+ this.renewAccessToken = options.renewAccessToken;
261
+ }
262
+ async observeRateLimit(info) {
263
+ await persistRateLimitObservation(this.admission, info);
45
264
  }
46
265
  // Default type allows property access on raw GraphQL responses.
47
266
  // Callers can narrow with explicit type parameter: rawRequest<{ issues: { nodes: T[] } }>(...)
48
267
  async rawRequest(query, variables, options = {}) {
49
268
  let attempt = 0;
269
+ let renewedAfterUnauthorized = false;
50
270
  try {
51
271
  while (true) {
52
272
  try {
273
+ await this.admission.admit();
53
274
  const response = await this.graphQLClient.rawRequest(query, variables);
275
+ const rateLimit = extractRateLimitInfo(response, this.now);
276
+ if (rateLimit)
277
+ await this.observeRateLimit(rateLimit);
54
278
  return response.data;
55
279
  }
56
280
  catch (error) {
281
+ if (error instanceof RateLimitAdmissionRefusal)
282
+ throw error;
283
+ const rateLimit = extractRateLimitInfo(error, this.now);
284
+ if (rateLimit)
285
+ await this.observeRateLimit(rateLimit);
286
+ if (!renewedAfterUnauthorized &&
287
+ this.renewAccessToken &&
288
+ graphQLErrorHttpStatus(error) === 401) {
289
+ const token = await this.renewAccessToken();
290
+ this.graphQLClient.setHeader("authorization", `Bearer ${token}`);
291
+ renewedAfterUnauthorized = true;
292
+ continue;
293
+ }
57
294
  if (!options.retrySafeMutation ||
58
295
  attempt >= this.retryDelaysMs.length ||
59
296
  !isTransientGraphQLError(error)) {
@@ -65,19 +302,35 @@ export class GraphQLService {
65
302
  }
66
303
  }
67
304
  catch (error) {
68
- const err = error;
69
- if (err.response?.errors) {
70
- const graphQLError = err.response.errors[0];
71
- throw new Error(graphQLError.message || "GraphQL query failed");
305
+ if (error instanceof RateLimitAdmissionRefusal)
306
+ throw error;
307
+ if (isRateLimitGraphQLError(error)) {
308
+ throw rateLimitError(error, query, this.now);
72
309
  }
73
- throw new Error(`GraphQL request failed: ${err.message}`);
310
+ const err = error;
311
+ // The message text is byte-identical to what the plain `Error`
312
+ // carried before DEV-7987; only the classification is new, so
313
+ // callers matching on the message are untouched.
314
+ const message = err.response?.errors
315
+ ? err.response.errors[0]?.message || "GraphQL query failed"
316
+ : `GraphQL request failed: ${err.message}`;
317
+ // This is the ONLY place a Linear GraphQL failure leaves the
318
+ // service, so it is the only place that has to attach the
319
+ // classification. Any future branch that throws its own shaped
320
+ // message from here must go through `toLinearGraphQLError` too, or
321
+ // that failure silently loses its `errorDetail` on the envelope.
322
+ throw toLinearGraphQLError(error, message);
74
323
  }
75
324
  }
76
325
  }
77
326
  export async function createGraphQLService(options) {
78
327
  const auth = await getActiveAuth(options);
79
328
  if (auth.kind === "oauth") {
80
- return new GraphQLService({ oauthToken: auth.token });
329
+ const credential = { oauthToken: auth.token };
330
+ return new GraphQLService(credential, {
331
+ admission: createOAuthProfileRateLimitAdmission(credential, auth.oauth.clientId, auth.oauth.viewerId),
332
+ renewAccessToken: createClientCredentialsTokenRenewal(auth.oauth),
333
+ });
81
334
  }
82
335
  return new GraphQLService({ apiKey: auth.token });
83
336
  }