@enrichlayer/el-linear 1.44.2 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -0
- package/claude-skills/linear-operations/SKILL.md +17 -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/commands/issues.js +244 -1
- package/dist/config/config.d.ts +30 -0
- package/dist/config/config.js +3 -0
- package/dist/config/consent-receipt.d.ts +76 -0
- package/dist/config/consent-receipt.js +219 -0
- package/dist/config/label-advisor.d.ts +53 -0
- package/dist/config/label-advisor.js +245 -0
- 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
package/dist/utils/output.js
CHANGED
|
@@ -1,14 +1,89 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { resolveActiveProfile } from "../config/paths.js";
|
|
3
3
|
import { dispatch as dispatchSummary, drainSummaryFieldWarnings, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
|
|
4
|
+
import { readGraphQLErrorDetail } from "./linear-graphql-error.js";
|
|
4
5
|
import { logger } from "./logger.js";
|
|
5
6
|
import { sanitizeForLog } from "./sanitize-for-log.js";
|
|
6
7
|
const warningBuffer = [];
|
|
8
|
+
let rateLimitBuffer = null;
|
|
7
9
|
let rawMode = false;
|
|
8
10
|
let quietMode = false;
|
|
9
11
|
let jqFilter = null;
|
|
10
12
|
let fieldsFilter = null;
|
|
11
13
|
let outputFormat = "json";
|
|
14
|
+
function minimumDefined(left, right) {
|
|
15
|
+
if (left === undefined)
|
|
16
|
+
return right;
|
|
17
|
+
if (right === undefined)
|
|
18
|
+
return left;
|
|
19
|
+
return Math.min(left, right);
|
|
20
|
+
}
|
|
21
|
+
function mergeRateLimitInfo(current, next) {
|
|
22
|
+
const endpoints = { ...current.endpoints };
|
|
23
|
+
for (const [name, observation] of Object.entries(next.endpoints ?? {})) {
|
|
24
|
+
const previous = endpoints[name];
|
|
25
|
+
endpoints[name] = {
|
|
26
|
+
...previous,
|
|
27
|
+
...observation,
|
|
28
|
+
minimumRemaining: minimumDefined(previous?.minimumRemaining ?? previous?.remaining, observation.minimumRemaining ?? observation.remaining),
|
|
29
|
+
observedRequests: (previous?.observedRequests ?? 0) + (observation.observedRequests ?? 1),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const currentComplexity = current.complexity;
|
|
33
|
+
const nextComplexity = next.complexity;
|
|
34
|
+
const complexity = currentComplexity || nextComplexity
|
|
35
|
+
? {
|
|
36
|
+
...currentComplexity,
|
|
37
|
+
...nextComplexity,
|
|
38
|
+
totalCost: (currentComplexity?.totalCost ?? currentComplexity?.cost ?? 0) +
|
|
39
|
+
(nextComplexity?.totalCost ?? nextComplexity?.cost ?? 0),
|
|
40
|
+
minimumRemaining: minimumDefined(currentComplexity?.minimumRemaining ?? currentComplexity?.remaining, nextComplexity?.minimumRemaining ?? nextComplexity?.remaining),
|
|
41
|
+
}
|
|
42
|
+
: undefined;
|
|
43
|
+
return {
|
|
44
|
+
...current,
|
|
45
|
+
...next,
|
|
46
|
+
observedRequests: (current.observedRequests ?? 1) + (next.observedRequests ?? 1),
|
|
47
|
+
minimumRemaining: minimumDefined(current.minimumRemaining ?? current.remaining, next.minimumRemaining ?? next.remaining),
|
|
48
|
+
...(complexity ? { complexity } : {}),
|
|
49
|
+
...(Object.keys(endpoints).length > 0 ? { endpoints } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Record a quota observation for the command's eventual output.
|
|
54
|
+
*
|
|
55
|
+
* The latest response remains authoritative for current remaining/reset
|
|
56
|
+
* values. Multi-request commands additionally retain their total observed
|
|
57
|
+
* request/complexity cost and the lowest headroom seen in every bucket.
|
|
58
|
+
*/
|
|
59
|
+
export function recordRateLimitInfo(info) {
|
|
60
|
+
const endpoints = Object.fromEntries(Object.entries(info.endpoints ?? {}).map(([name, endpoint]) => [
|
|
61
|
+
name,
|
|
62
|
+
{
|
|
63
|
+
...endpoint,
|
|
64
|
+
minimumRemaining: endpoint.minimumRemaining ?? endpoint.remaining,
|
|
65
|
+
observedRequests: endpoint.observedRequests ?? 1,
|
|
66
|
+
},
|
|
67
|
+
]));
|
|
68
|
+
const observation = {
|
|
69
|
+
...info,
|
|
70
|
+
observedRequests: info.observedRequests ?? 1,
|
|
71
|
+
minimumRemaining: info.minimumRemaining ?? info.remaining,
|
|
72
|
+
...(info.complexity
|
|
73
|
+
? {
|
|
74
|
+
complexity: {
|
|
75
|
+
...info.complexity,
|
|
76
|
+
totalCost: info.complexity.totalCost ?? info.complexity.cost,
|
|
77
|
+
minimumRemaining: info.complexity.minimumRemaining ?? info.complexity.remaining,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
...(Object.keys(endpoints).length > 0 ? { endpoints } : {}),
|
|
82
|
+
};
|
|
83
|
+
rateLimitBuffer = rateLimitBuffer
|
|
84
|
+
? mergeRateLimitInfo(rateLimitBuffer, observation)
|
|
85
|
+
: observation;
|
|
86
|
+
}
|
|
12
87
|
export function setRawMode(enabled) {
|
|
13
88
|
rawMode = enabled;
|
|
14
89
|
}
|
|
@@ -211,6 +286,7 @@ export function outputSuccess(data) {
|
|
|
211
286
|
if (quietMode) {
|
|
212
287
|
logger.info(formatLine(output));
|
|
213
288
|
drainWarnings();
|
|
289
|
+
drainRateLimitInfo();
|
|
214
290
|
drainSummaryFieldWarnings();
|
|
215
291
|
return;
|
|
216
292
|
}
|
|
@@ -273,6 +349,10 @@ export function outputSuccess(data) {
|
|
|
273
349
|
// the signal is visible without breaking the text contract.
|
|
274
350
|
if (outputFormat === "summary") {
|
|
275
351
|
emitSummary(output, inferredKind, fieldsFilter);
|
|
352
|
+
const rateLimit = drainRateLimitInfo();
|
|
353
|
+
if (rateLimit) {
|
|
354
|
+
logger.info(`_rateLimit: ${formatRateLimitInfo(rateLimit)}`);
|
|
355
|
+
}
|
|
276
356
|
const summaryWarnings = [
|
|
277
357
|
...drainWarnings(),
|
|
278
358
|
...drainSummaryFieldWarnings(),
|
|
@@ -314,6 +394,22 @@ export function outputSuccess(data) {
|
|
|
314
394
|
}
|
|
315
395
|
}
|
|
316
396
|
}
|
|
397
|
+
const rateLimit = drainRateLimitInfo();
|
|
398
|
+
if (rateLimit) {
|
|
399
|
+
if (output !== null &&
|
|
400
|
+
typeof output === "object" &&
|
|
401
|
+
!Array.isArray(output)) {
|
|
402
|
+
output = {
|
|
403
|
+
...output,
|
|
404
|
+
_rateLimit: rateLimit,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// A bare array/primitive cannot carry metadata without changing its
|
|
409
|
+
// contract. Keep stdout machine-readable and surface the quota on stderr.
|
|
410
|
+
logger.error(`_rateLimit: ${formatRateLimitInfo(rateLimit)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
317
413
|
if (jqFilter) {
|
|
318
414
|
const json = JSON.stringify(output);
|
|
319
415
|
// Normalize common shell-escape artifacts (zsh history expansion)
|
|
@@ -373,8 +469,51 @@ function drainWarnings() {
|
|
|
373
469
|
warningBuffer.length = 0;
|
|
374
470
|
return warnings;
|
|
375
471
|
}
|
|
472
|
+
function drainRateLimitInfo() {
|
|
473
|
+
const rateLimit = rateLimitBuffer;
|
|
474
|
+
rateLimitBuffer = null;
|
|
475
|
+
return rateLimit;
|
|
476
|
+
}
|
|
477
|
+
function formatRateLimitInfo(info) {
|
|
478
|
+
const parts = [];
|
|
479
|
+
if (info.remaining !== undefined && info.limit !== undefined) {
|
|
480
|
+
parts.push(`${info.remaining}/${info.limit} requests remaining`);
|
|
481
|
+
}
|
|
482
|
+
else if (info.remaining !== undefined) {
|
|
483
|
+
parts.push(`${info.remaining} requests remaining`);
|
|
484
|
+
}
|
|
485
|
+
else if (info.limit !== undefined) {
|
|
486
|
+
parts.push(`${info.limit} request limit`);
|
|
487
|
+
}
|
|
488
|
+
if (info.resetAt) {
|
|
489
|
+
parts.push(`resets at ${info.resetAt}`);
|
|
490
|
+
}
|
|
491
|
+
if ((info.observedRequests ?? 0) > 1) {
|
|
492
|
+
parts.push(`${info.observedRequests} responses observed`);
|
|
493
|
+
}
|
|
494
|
+
if (info.minimumRemaining !== undefined &&
|
|
495
|
+
info.minimumRemaining !== info.remaining) {
|
|
496
|
+
parts.push(`minimum ${info.minimumRemaining} requests remaining`);
|
|
497
|
+
}
|
|
498
|
+
if (info.complexity?.totalCost !== undefined) {
|
|
499
|
+
parts.push(`${info.complexity.totalCost} total complexity`);
|
|
500
|
+
}
|
|
501
|
+
const endpointEntries = Object.entries(info.endpoints ?? {});
|
|
502
|
+
if (endpointEntries.length > 0) {
|
|
503
|
+
parts.push(endpointEntries
|
|
504
|
+
.map(([name, endpoint]) => {
|
|
505
|
+
const remaining = endpoint.minimumRemaining ?? endpoint.remaining;
|
|
506
|
+
return remaining === undefined
|
|
507
|
+
? `${name} endpoint observed`
|
|
508
|
+
: `${name} endpoint minimum ${remaining} remaining`;
|
|
509
|
+
})
|
|
510
|
+
.join(", "));
|
|
511
|
+
}
|
|
512
|
+
return parts.join("; ");
|
|
513
|
+
}
|
|
376
514
|
export function resetWarnings() {
|
|
377
515
|
warningBuffer.length = 0;
|
|
516
|
+
rateLimitBuffer = null;
|
|
378
517
|
}
|
|
379
518
|
function outputError(error) {
|
|
380
519
|
// Run the message and stack through sanitizeForLog so a future SDK
|
|
@@ -400,7 +539,19 @@ function outputError(error) {
|
|
|
400
539
|
catch {
|
|
401
540
|
// leave activeProfile as "<unknown>"
|
|
402
541
|
}
|
|
403
|
-
|
|
542
|
+
// `errorDetail` (DEV-7987) is the classification block: present only
|
|
543
|
+
// when the failure came from a Linear GraphQL request or its quota admission, absent for
|
|
544
|
+
// every other error, so nothing about the existing two-key envelope
|
|
545
|
+
// changes for callers that never looked at it. See the "Error
|
|
546
|
+
// envelope" section of the README for the published contract.
|
|
547
|
+
const errorDetail = readGraphQLErrorDetail(error);
|
|
548
|
+
const rateLimit = drainRateLimitInfo();
|
|
549
|
+
const payload = JSON.stringify({
|
|
550
|
+
error: sanitizeForLog(error.message),
|
|
551
|
+
activeProfile,
|
|
552
|
+
...(errorDetail ? { errorDetail } : {}),
|
|
553
|
+
...(rateLimit ? { _rateLimit: rateLimit } : {}),
|
|
554
|
+
}, null, 2);
|
|
404
555
|
// Write to stdout (same channel as success) so machine callers always
|
|
405
556
|
// receive exactly one parseable JSON object regardless of stream capture.
|
|
406
557
|
logger.info(payload);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { LinearCredential } from "../auth/linear-credential.js";
|
|
2
|
+
import { LinearGraphQLError } from "./linear-graphql-error.js";
|
|
3
|
+
import type { RateLimitInfo } from "./output.js";
|
|
4
|
+
export interface RateLimitAdmission {
|
|
5
|
+
readonly enabled: boolean;
|
|
6
|
+
admit(): Promise<void>;
|
|
7
|
+
observe(info: RateLimitInfo): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/** A local/coordinator quota gate refused before any Linear request was sent. */
|
|
10
|
+
export declare class RateLimitAdmissionRefusal extends LinearGraphQLError {
|
|
11
|
+
constructor(message: string, resetAt?: string);
|
|
12
|
+
}
|
|
13
|
+
export interface RateLimitAdmissionOptions {
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
fetchImpl?: CoordinatorFetchLike;
|
|
16
|
+
now?: () => number;
|
|
17
|
+
stateRoot?: string;
|
|
18
|
+
}
|
|
19
|
+
type CoordinatorFetchLike = (url: string, init: {
|
|
20
|
+
method: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
body: string;
|
|
23
|
+
}) => Promise<{
|
|
24
|
+
ok: boolean;
|
|
25
|
+
status: number;
|
|
26
|
+
json(): Promise<unknown>;
|
|
27
|
+
}>;
|
|
28
|
+
export declare function createRateLimitAdmission(auth: LinearCredential, options?: RateLimitAdmissionOptions): RateLimitAdmission;
|
|
29
|
+
/** Keep local OAuth quota state stable when a profile refreshes its token. */
|
|
30
|
+
export declare function createOAuthProfileRateLimitAdmission(auth: Extract<LinearCredential, {
|
|
31
|
+
oauthToken: string;
|
|
32
|
+
}>, clientId: string, viewerId?: string): RateLimitAdmission;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { atomicWrite, withFileLock } from "../auth/oauth-fs.js";
|
|
6
|
+
import { LinearGraphQLError } from "./linear-graphql-error.js";
|
|
7
|
+
const STATE_VERSION = 1;
|
|
8
|
+
const PROBE_LEASE_MS = 30_000;
|
|
9
|
+
/** A local/coordinator quota gate refused before any Linear request was sent. */
|
|
10
|
+
export class RateLimitAdmissionRefusal extends LinearGraphQLError {
|
|
11
|
+
constructor(message, resetAt) {
|
|
12
|
+
super(message, {
|
|
13
|
+
httpStatus: null,
|
|
14
|
+
code: "RATELIMITED",
|
|
15
|
+
retryable: true,
|
|
16
|
+
resetAt,
|
|
17
|
+
});
|
|
18
|
+
this.name = "RateLimitAdmissionRefusal";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function parseHeadroom(env) {
|
|
22
|
+
const raw = env.EL_LINEAR_RATE_LIMIT_HEADROOM?.trim();
|
|
23
|
+
if (!raw)
|
|
24
|
+
return 0;
|
|
25
|
+
const value = Number(raw);
|
|
26
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
27
|
+
throw new Error("EL_LINEAR_RATE_LIMIT_HEADROOM must be a non-negative integer.");
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function quotaKey(auth, env) {
|
|
32
|
+
const explicit = env.EL_LINEAR_QUOTA_KEY?.trim();
|
|
33
|
+
const source = explicit || ("oauthToken" in auth ? auth.oauthToken : auth.apiKey);
|
|
34
|
+
return createHash("sha256").update(source).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
function coordinatorUrl(env) {
|
|
37
|
+
const raw = env.EL_LINEAR_RATE_LIMIT_COORDINATOR_URL?.trim();
|
|
38
|
+
if (!raw)
|
|
39
|
+
return null;
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(raw);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must be a valid URL.");
|
|
46
|
+
}
|
|
47
|
+
if (!/^https?:$/.test(parsed.protocol)) {
|
|
48
|
+
throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must use HTTPS (HTTP is allowed only for loopback development).");
|
|
49
|
+
}
|
|
50
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
51
|
+
if (parsed.protocol === "http:" && !loopbackHosts.has(parsed.hostname)) {
|
|
52
|
+
throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_URL must use HTTPS; bearer credentials may use HTTP only with a loopback host.");
|
|
53
|
+
}
|
|
54
|
+
return parsed.toString().replace(/\/$/, "");
|
|
55
|
+
}
|
|
56
|
+
function createCoordinatorAdmission(key, headroom, baseUrl, token, fetchImpl) {
|
|
57
|
+
const post = async (action, body) => {
|
|
58
|
+
const response = await fetchImpl(`${baseUrl}/v1/quota/${encodeURIComponent(key)}/${action}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
authorization: `Bearer ${token}`,
|
|
62
|
+
"content-type": "application/json",
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
});
|
|
66
|
+
const payload = await response.json().catch(() => null);
|
|
67
|
+
if (!(response.ok || (action === "admit" && response.status === 429))) {
|
|
68
|
+
throw new Error(`Linear quota coordinator ${action} failed with HTTP ${response.status}.`);
|
|
69
|
+
}
|
|
70
|
+
return { status: response.status, payload };
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
enabled: true,
|
|
74
|
+
async admit() {
|
|
75
|
+
const result = await post("admit", { headroom, cost: 1 });
|
|
76
|
+
if (result.status !== 429)
|
|
77
|
+
return;
|
|
78
|
+
const state = result.payload?.state;
|
|
79
|
+
const remaining = state?.remaining ?? 0;
|
|
80
|
+
const reset = state?.resetAt ? ` until ${state.resetAt}` : "";
|
|
81
|
+
throw new RateLimitAdmissionRefusal(`Linear rate limit exceeded before request; distributed admission refused: ${remaining} requests remain, preserving configured headroom ${headroom}${reset}.`, state?.resetAt);
|
|
82
|
+
},
|
|
83
|
+
async observe(info) {
|
|
84
|
+
if (info.remaining === undefined)
|
|
85
|
+
return;
|
|
86
|
+
await post("observe", {
|
|
87
|
+
limit: info.limit,
|
|
88
|
+
remaining: info.remaining,
|
|
89
|
+
resetAt: info.resetAt,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function defaultStateRoot(env) {
|
|
95
|
+
const stateHome = env.XDG_STATE_HOME?.trim() || path.join(os.homedir(), ".local", "state");
|
|
96
|
+
return path.join(stateHome, "el-linear", "rate-limit");
|
|
97
|
+
}
|
|
98
|
+
async function readState(statePath) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
|
|
101
|
+
if (parsed?.v !== STATE_VERSION ||
|
|
102
|
+
typeof parsed.remaining !== "number" ||
|
|
103
|
+
typeof parsed.observedAt !== "number") {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function writeState(statePath, state) {
|
|
113
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
114
|
+
await atomicWrite(statePath, `${JSON.stringify(state)}\n`, 0o600);
|
|
115
|
+
}
|
|
116
|
+
export function createRateLimitAdmission(auth, options = {}) {
|
|
117
|
+
const env = options.env ?? process.env;
|
|
118
|
+
const headroom = parseHeadroom(env);
|
|
119
|
+
if (headroom === 0) {
|
|
120
|
+
return {
|
|
121
|
+
enabled: false,
|
|
122
|
+
admit: async () => { },
|
|
123
|
+
observe: async () => { },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const now = options.now ?? Date.now;
|
|
127
|
+
const key = quotaKey(auth, env);
|
|
128
|
+
const remote = coordinatorUrl(env);
|
|
129
|
+
if (remote) {
|
|
130
|
+
const token = env.EL_LINEAR_RATE_LIMIT_COORDINATOR_TOKEN?.trim();
|
|
131
|
+
if (!token) {
|
|
132
|
+
throw new Error("EL_LINEAR_RATE_LIMIT_COORDINATOR_TOKEN is required when the coordinator URL is configured.");
|
|
133
|
+
}
|
|
134
|
+
const fetchImpl = options.fetchImpl ??
|
|
135
|
+
(async (url, init) => {
|
|
136
|
+
const response = await globalThis.fetch(url, init);
|
|
137
|
+
return {
|
|
138
|
+
ok: response.ok,
|
|
139
|
+
status: response.status,
|
|
140
|
+
json: () => response.json(),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return createCoordinatorAdmission(key, headroom, remote, token, fetchImpl);
|
|
144
|
+
}
|
|
145
|
+
const statePath = path.join(options.stateRoot ?? defaultStateRoot(env), `${key}.json`);
|
|
146
|
+
return {
|
|
147
|
+
enabled: true,
|
|
148
|
+
async admit() {
|
|
149
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
150
|
+
await withFileLock(statePath, async () => {
|
|
151
|
+
const state = await readState(statePath);
|
|
152
|
+
const currentTime = now();
|
|
153
|
+
if (!state) {
|
|
154
|
+
await writeState(statePath, {
|
|
155
|
+
v: STATE_VERSION,
|
|
156
|
+
remaining: headroom,
|
|
157
|
+
observedAt: currentTime,
|
|
158
|
+
probeUntil: currentTime + PROBE_LEASE_MS,
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const resetAtMs = state.resetAt
|
|
163
|
+
? Date.parse(state.resetAt)
|
|
164
|
+
: Number.NaN;
|
|
165
|
+
const leaseExpired = state.probeUntil !== undefined && currentTime >= state.probeUntil;
|
|
166
|
+
if ((Number.isFinite(resetAtMs) && currentTime >= resetAtMs) ||
|
|
167
|
+
leaseExpired) {
|
|
168
|
+
// Permit exactly one probe into the new window. Closing the local
|
|
169
|
+
// gate before releasing the lock prevents every waiting process
|
|
170
|
+
// from seeing the same expired state and stampeding the reset.
|
|
171
|
+
// Its response headers reopen the gate with authoritative capacity;
|
|
172
|
+
// a headerless failure remains pessimistic until the bounded lease
|
|
173
|
+
// permits exactly one recovery probe.
|
|
174
|
+
await writeState(statePath, {
|
|
175
|
+
...state,
|
|
176
|
+
remaining: headroom,
|
|
177
|
+
resetAt: undefined,
|
|
178
|
+
observedAt: currentTime,
|
|
179
|
+
probeUntil: currentTime + PROBE_LEASE_MS,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (state.probeUntil !== undefined) {
|
|
184
|
+
throw new RateLimitAdmissionRefusal(`Linear rate limit probe is already in flight; admission refused while preserving configured headroom ${headroom}.`, typeof state.probeUntil === "number"
|
|
185
|
+
? (new Date(state.probeUntil).toJSON() ?? undefined)
|
|
186
|
+
: undefined);
|
|
187
|
+
}
|
|
188
|
+
if (state.remaining <= headroom) {
|
|
189
|
+
const reset = state.resetAt ? ` until ${state.resetAt}` : "";
|
|
190
|
+
throw new RateLimitAdmissionRefusal(`Linear rate limit exceeded before request; admission refused: ${state.remaining} requests remain, preserving configured headroom ${headroom}${reset}.`, state.resetAt);
|
|
191
|
+
}
|
|
192
|
+
// Reserve one request while holding the process-shared lock. The
|
|
193
|
+
// response observation will replace this pessimistic estimate with
|
|
194
|
+
// Linear's authoritative remaining count.
|
|
195
|
+
await writeState(statePath, {
|
|
196
|
+
...state,
|
|
197
|
+
remaining: state.remaining - 1,
|
|
198
|
+
observedAt: currentTime,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
async observe(info) {
|
|
203
|
+
if (info.remaining === undefined)
|
|
204
|
+
return;
|
|
205
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
206
|
+
await withFileLock(statePath, async () => {
|
|
207
|
+
const existing = await readState(statePath);
|
|
208
|
+
const sameWindow = existing?.resetAt !== undefined &&
|
|
209
|
+
info.resetAt !== undefined &&
|
|
210
|
+
existing.resetAt === info.resetAt;
|
|
211
|
+
// Concurrent responses can arrive out of order. Within one Linear
|
|
212
|
+
// window the remaining count only moves downward, so retaining the
|
|
213
|
+
// lower observation prevents a stale response from refunding quota.
|
|
214
|
+
const remaining = sameWindow
|
|
215
|
+
? Math.min(existing.remaining, info.remaining)
|
|
216
|
+
: info.remaining;
|
|
217
|
+
await writeState(statePath, {
|
|
218
|
+
v: STATE_VERSION,
|
|
219
|
+
limit: info.limit,
|
|
220
|
+
remaining,
|
|
221
|
+
resetAt: info.resetAt,
|
|
222
|
+
observedAt: now(),
|
|
223
|
+
probeUntil: undefined,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Keep local OAuth quota state stable when a profile refreshes its token. */
|
|
230
|
+
export function createOAuthProfileRateLimitAdmission(auth, clientId, viewerId) {
|
|
231
|
+
const quotaKey = viewerId
|
|
232
|
+
? `oauth:${clientId}:${viewerId}`
|
|
233
|
+
: `oauth:${clientId}`;
|
|
234
|
+
return createRateLimitAdmission(auth, {
|
|
235
|
+
env: {
|
|
236
|
+
...process.env,
|
|
237
|
+
EL_LINEAR_QUOTA_KEY: process.env.EL_LINEAR_QUOTA_KEY?.trim() || quotaKey,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,80 +1,80 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
2
|
+
"name": "@enrichlayer/el-linear",
|
|
3
|
+
"version": "1.46.0",
|
|
4
|
+
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
|
+
"main": "dist/main.js",
|
|
6
|
+
"types": "dist/main.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/main.d.ts",
|
|
11
|
+
"import": "./dist/main.js"
|
|
12
|
+
},
|
|
13
|
+
"./output": {
|
|
14
|
+
"types": "./dist/output.d.ts",
|
|
15
|
+
"import": "./dist/output.js",
|
|
16
|
+
"default": "./dist/output.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"bin": {
|
|
21
|
+
"el-linear": "dist/main.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"claude-skills/",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.build.json && chmod +x dist/main.js",
|
|
31
|
+
"clean": "rm -rf dist/",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"lint": "biome check .",
|
|
34
|
+
"lint:fix": "biome check --fix .",
|
|
35
|
+
"start": "tsx src/main.ts",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.0.0"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/enrichlayer/el-linear.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://enrichlayer.com/tools/el-linear",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/enrichlayer/el-linear/issues"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"linear",
|
|
53
|
+
"linear.app",
|
|
54
|
+
"cli",
|
|
55
|
+
"issue-tracker",
|
|
56
|
+
"graphql",
|
|
57
|
+
"claude",
|
|
58
|
+
"claude-code"
|
|
59
|
+
],
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@inquirer/prompts": "^8.4.2",
|
|
65
|
+
"@linear/sdk": "^88.0.0",
|
|
66
|
+
"commander": "^15.0.0",
|
|
67
|
+
"graphql": "^17.0.1",
|
|
68
|
+
"picocolors": "^1.1.1"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@biomejs/biome": "^2.4.14",
|
|
72
|
+
"@types/node": "^26.0.0",
|
|
73
|
+
"tsx": "^4.21.0",
|
|
74
|
+
"typescript": "^6.0.3",
|
|
75
|
+
"vitest": "^4.0.18"
|
|
76
|
+
},
|
|
77
|
+
"optionalDependencies": {
|
|
78
|
+
"@sentry/node": "^10.50.0"
|
|
79
|
+
}
|
|
80
|
+
}
|