@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.
- package/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +32 -0
- package/dist/auth/oauth-storage.d.ts +5 -3
- package/dist/auth/oauth-token.d.ts +7 -0
- package/dist/auth/oauth-token.js +13 -0
- package/dist/auth/token-resolver.d.ts +2 -0
- package/dist/auth/token-resolver.js +25 -10
- package/dist/commands/init/index.js +17 -1
- package/dist/commands/init/oauth.d.ts +9 -1
- package/dist/commands/init/oauth.js +62 -3
- package/dist/queries/catalog-types.d.ts +129 -0
- package/dist/queries/catalog-types.js +1 -0
- package/dist/queries/catalog.d.ts +20 -0
- package/dist/queries/catalog.js +140 -0
- package/dist/utils/disk-cache.js +48 -14
- package/dist/utils/graphql-service.d.ts +42 -1
- package/dist/utils/graphql-service.js +267 -14
- package/dist/utils/linear-graphql-error.d.ts +100 -0
- package/dist/utils/linear-graphql-error.js +182 -0
- package/dist/utils/linear-service.d.ts +19 -2
- package/dist/utils/linear-service.js +194 -214
- package/dist/utils/output.d.ts +39 -0
- package/dist/utils/output.js +152 -1
- package/dist/utils/rate-limit-admission.d.ts +33 -0
- package/dist/utils/rate-limit-admission.js +240 -0
- package/package.json +79 -79
|
@@ -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
|
+
`;
|
package/dist/utils/disk-cache.js
CHANGED
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
//
|
|
140
|
-
//
|
|
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]
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
throw
|
|
305
|
+
if (error instanceof RateLimitAdmissionRefusal)
|
|
306
|
+
throw error;
|
|
307
|
+
if (isRateLimitGraphQLError(error)) {
|
|
308
|
+
throw rateLimitError(error, query, this.now);
|
|
72
309
|
}
|
|
73
|
-
|
|
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
|
-
|
|
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
|
}
|