@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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Structured classification for a failed Linear GraphQL request (DEV-7987).
3
+ *
4
+ * WHY THIS EXISTS. Every command routes its failures through `outputError`
5
+ * in `utils/output.ts`, which used to emit `{ error, activeProfile }` and
6
+ * nothing else. That flattens a rate limit, a gateway blip and a permanent
7
+ * schema error into one indistinguishable shape, so a machine caller
8
+ * shelling out to `el-linear` (a job runner deciding whether to retry, say)
9
+ * had to substring-match the message to guess. This module carries the two
10
+ * facts that were being thrown away — the HTTP status and the GraphQL
11
+ * `extensions.code` — plus the retryable verdict derived from them, so the
12
+ * envelope can state them.
13
+ *
14
+ * Kept in its own module, free of `@linear/sdk`, so `utils/output.ts` can
15
+ * read the classification without importing the GraphQL client.
16
+ */
17
+ /**
18
+ * GraphQL `extensions.code` values Linear returns for conditions that clear
19
+ * on their own. A caller may retry these regardless of the HTTP status —
20
+ * Linear answers a rate limit with `RATELIMITED` under more than one
21
+ * status, so the code is load-bearing on its own.
22
+ */
23
+ export const RETRYABLE_GRAPHQL_ERROR_CODES = [
24
+ "RATELIMITED",
25
+ "INTERNAL_SERVER_ERROR",
26
+ "SERVICE_UNAVAILABLE",
27
+ ];
28
+ /** HTTP statuses that mean "the request was fine, the moment wasn't". */
29
+ const RETRYABLE_HTTP_STATUSES = [408, 429];
30
+ /** Accept explicit UTC timestamps only; error messages never establish a deadline. */
31
+ function normalizeResetAt(value) {
32
+ if (typeof value !== "string" ||
33
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) {
34
+ return undefined;
35
+ }
36
+ const millis = Date.parse(value);
37
+ if (!Number.isFinite(millis))
38
+ return undefined;
39
+ const normalized = new Date(millis).toISOString();
40
+ // Date.parse normalizes impossible calendar dates rather than rejecting them.
41
+ return normalized.slice(0, 19) === value.slice(0, 19)
42
+ ? normalized
43
+ : undefined;
44
+ }
45
+ function asNumber(value) {
46
+ return typeof value === "number" ? value : null;
47
+ }
48
+ function asString(value) {
49
+ return typeof value === "string" ? value : null;
50
+ }
51
+ /** HTTP status of a rejected request, or `null` if no response arrived. */
52
+ export function graphQLErrorHttpStatus(error) {
53
+ if (typeof error !== "object" || error === null) {
54
+ return null;
55
+ }
56
+ const rejection = error;
57
+ return (asNumber(rejection.status) ??
58
+ asNumber(rejection.response?.status) ??
59
+ asNumber(rejection.response?.statusCode) ??
60
+ asNumber(rejection.raw?.response?.status));
61
+ }
62
+ /** First GraphQL error's `extensions.code`, or `null` when absent. */
63
+ export function graphQLErrorCode(error) {
64
+ if (typeof error !== "object" || error === null) {
65
+ return null;
66
+ }
67
+ const rejection = error;
68
+ return (asString(rejection.response?.errors?.[0]?.extensions?.code) ??
69
+ asString(rejection.raw?.response?.errors?.[0]?.extensions?.code));
70
+ }
71
+ /** True when an HTTP status alone justifies a retry. */
72
+ export function isRetryableHttpStatus(status) {
73
+ if (status === null) {
74
+ return false;
75
+ }
76
+ return RETRYABLE_HTTP_STATUSES.includes(status) || status >= 500;
77
+ }
78
+ /** True when a GraphQL `extensions.code` alone justifies a retry. */
79
+ export function isRetryableGraphQLCode(code) {
80
+ return code !== null && RETRYABLE_GRAPHQL_ERROR_CODES.includes(code);
81
+ }
82
+ /**
83
+ * The retryable verdict from the authoritative response status and GraphQL code.
84
+ *
85
+ * "Retryable" here means *the failure is transient* — a caller that waits
86
+ * an appropriate interval could succeed. It is NOT the same question as
87
+ * `isTransientGraphQLError` in `graphql-service.ts`, which gates this CLI's
88
+ * own immediate 150 ms / 400 ms retry: a rate limit is transient (so
89
+ * `retryable: true`) but a terrible candidate for an immediate retry.
90
+ *
91
+ * The last arm is the one that is easy to get wrong: a rejection carrying
92
+ * NEITHER a status NOR a code never got an answer out of Linear at all —
93
+ * DNS, a refused connection, a dropped socket — and those are exactly the
94
+ * failures worth retrying. Every failure Linear itself produced arrives
95
+ * with a status (`LinearError.status` is set from the HTTP response), so
96
+ * this arm cannot swallow a permanent schema or permission error. Message
97
+ * text is deliberately excluded: a permanent response may legitimately
98
+ * mention a transient-looking number such as a validation limit of 500.
99
+ */
100
+ export function classifyGraphQLFailure(httpStatus, code) {
101
+ if (isRetryableHttpStatus(httpStatus) || isRetryableGraphQLCode(code)) {
102
+ return true;
103
+ }
104
+ if (httpStatus === null && code === null) {
105
+ return true;
106
+ }
107
+ // A response status or GraphQL code is authoritative. Message text may
108
+ // contain an unrelated number such as a validation limit of 500; it must
109
+ // never turn a known permanent 400/401/403 into a retryable transport fault.
110
+ return false;
111
+ }
112
+ /**
113
+ * A Linear GraphQL failure that carries its own classification.
114
+ *
115
+ * The `message` is unchanged from what the plain `Error` used to carry, so
116
+ * every existing consumer that matches on the message text keeps working;
117
+ * the classification is purely additive.
118
+ */
119
+ export class LinearGraphQLError extends Error {
120
+ httpStatus;
121
+ code;
122
+ retryable;
123
+ resetAt;
124
+ constructor(message, detail) {
125
+ super(message);
126
+ this.name = "LinearGraphQLError";
127
+ this.httpStatus = detail.httpStatus;
128
+ this.code = detail.code;
129
+ this.retryable = detail.retryable;
130
+ this.resetAt = normalizeResetAt(detail.resetAt);
131
+ }
132
+ get detail() {
133
+ return {
134
+ httpStatus: this.httpStatus,
135
+ code: this.code,
136
+ retryable: this.retryable,
137
+ ...(this.resetAt ? { resetAt: this.resetAt } : {}),
138
+ };
139
+ }
140
+ }
141
+ /**
142
+ * Wrap a rejection from the Linear GraphQL client in a classified error,
143
+ * keeping `message` exactly as the caller composed it.
144
+ *
145
+ * This is the seam every throw out of `GraphQLService.rawRequest` must pass
146
+ * through. A branch that builds its own message and throws a bare `Error`
147
+ * instead — a rate-limit path with a friendlier wording, say — produces a
148
+ * failure with NO `errorDetail` on the envelope, which is precisely the
149
+ * blindness DEV-7987 exists to remove, and it fails silently.
150
+ */
151
+ export function toLinearGraphQLError(error, message) {
152
+ const httpStatus = graphQLErrorHttpStatus(error);
153
+ const code = graphQLErrorCode(error);
154
+ return new LinearGraphQLError(message, {
155
+ httpStatus,
156
+ code,
157
+ retryable: classifyGraphQLFailure(httpStatus, code),
158
+ });
159
+ }
160
+ /**
161
+ * Read the classification off an arbitrary thrown value, or `null` when it
162
+ * carries none. Structural rather than `instanceof` so a
163
+ * `LinearGraphQLError` that crossed a module-instance boundary (a vitest
164
+ * mock, a duplicated dependency copy) is still recognized — the envelope
165
+ * contract must not depend on prototype identity.
166
+ */
167
+ export function readGraphQLErrorDetail(error) {
168
+ if (typeof error !== "object" || error === null) {
169
+ return null;
170
+ }
171
+ const candidate = error;
172
+ if (typeof candidate.retryable !== "boolean") {
173
+ return null;
174
+ }
175
+ const resetAt = normalizeResetAt(candidate.resetAt);
176
+ return {
177
+ httpStatus: asNumber(candidate.httpStatus),
178
+ code: asString(candidate.code),
179
+ retryable: candidate.retryable,
180
+ ...(resetAt ? { resetAt } : {}),
181
+ };
182
+ }
@@ -1,6 +1,9 @@
1
+ import { LinearClient } from "@linear/sdk";
1
2
  import type { LinearCredential } from "../auth/linear-credential.js";
2
3
  import type { LinearComment, LinearCycleDetail, LinearCycleSummary, LinearLabel, LinearProject, LinearTeam, LinearUser } from "../types/linear.js";
3
4
  import type { AuthOptions } from "./auth.js";
5
+ import { GraphQLService } from "./graphql-service.js";
6
+ import { type RateLimitAdmission } from "./rate-limit-admission.js";
4
7
  /**
5
8
  * Constructor arg for `LinearService`. Re-exported alias of the shared
6
9
  * `LinearCredential` union (`{ apiKey } | { oauthToken }`). See
@@ -8,9 +11,24 @@ import type { AuthOptions } from "./auth.js";
8
11
  * legacy arm was dropped in DEV-4068 T7.
9
12
  */
10
13
  export type LinearServiceAuth = LinearCredential;
14
+ /**
15
+ * Classify every generated SDK operation after the SDK has normalized its raw
16
+ * transport rejection. Patching `client.client.request` is too early: the
17
+ * LinearClient constructor wraps that transport with `parseLinearError`, which
18
+ * would immediately strip our additive classification fields again. Keeping
19
+ * the wrapper here (rather than guessing in `outputError`) also leaves local
20
+ * validation and not-found errors unclassified.
21
+ */
22
+ export declare function instrumentLinearGraphQLErrorClassification(client: LinearClient): LinearClient;
23
+ export declare function instrumentClientCredentialsRenewal(client: LinearClient, renewAccessToken?: () => Promise<string>): LinearClient;
11
24
  export declare class LinearService {
12
25
  private readonly client;
13
- constructor(auth: LinearServiceAuth);
26
+ private readonly graphQLService;
27
+ constructor(auth: LinearServiceAuth, options?: {
28
+ admission?: RateLimitAdmission;
29
+ graphQLService?: GraphQLService;
30
+ renewAccessToken?: () => Promise<string>;
31
+ });
14
32
  resolveIssueId(issueId: string): Promise<string>;
15
33
  getTeams(limit?: number): Promise<LinearTeam[]>;
16
34
  resolveUserId(nameOrEmailOrId: string): Promise<string>;
@@ -32,7 +50,6 @@ export declare class LinearService {
32
50
  }): Promise<LinearProject[]>;
33
51
  resolveTeamId(teamKeyOrNameOrId: string): Promise<string>;
34
52
  resolveStatusId(statusName: string, teamId?: string): Promise<string>;
35
- private buildLabelData;
36
53
  getLabels(teamFilter?: string, limit?: number, nameFilter?: string): Promise<{
37
54
  labels: LinearLabel[];
38
55
  }>;