@enrichlayer/el-linear 1.43.0 → 1.43.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/dist/commands/issues.js
CHANGED
|
@@ -1341,6 +1341,14 @@ async function handleMarkBranch(issueId, options, command) {
|
|
|
1341
1341
|
`Pass one explicitly: 'el-linear issues mark-branch DEV-123'.`);
|
|
1342
1342
|
}
|
|
1343
1343
|
}
|
|
1344
|
+
const branchIdentifier = extractIssueIdentifierFromBranch(branch);
|
|
1345
|
+
if (issueId &&
|
|
1346
|
+
branchIdentifier &&
|
|
1347
|
+
branchIdentifier !== identifier &&
|
|
1348
|
+
options.allowBranchMismatch !== true) {
|
|
1349
|
+
throw new Error(`Branch '${branch}' implies ${branchIdentifier}, but the requested marker is ${identifier}. ` +
|
|
1350
|
+
"Run this command from the matching branch, or pass --allow-branch-mismatch for deliberate recovery.");
|
|
1351
|
+
}
|
|
1344
1352
|
const previous = getBranchLinearIssue(branch);
|
|
1345
1353
|
setBranchLinearIssue(branch, identifier);
|
|
1346
1354
|
let claim;
|
|
@@ -1620,5 +1628,6 @@ export function setupIssuesCommands(program) {
|
|
|
1620
1628
|
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
|
|
1621
1629
|
"By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
|
|
1622
1630
|
.option("--no-claim", "only record git metadata; skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1631
|
+
.option("--allow-branch-mismatch", "allow an explicit issue identifier that conflicts with the current branch name")
|
|
1623
1632
|
.action(handleAsyncCommand(handleMarkBranch));
|
|
1624
1633
|
}
|
|
@@ -436,7 +436,7 @@ export class GraphQLIssuesService {
|
|
|
436
436
|
updateResult = await this.graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
437
437
|
id: resolvedIssueId,
|
|
438
438
|
input: updateInput,
|
|
439
|
-
});
|
|
439
|
+
}, { retrySafeMutation: true });
|
|
440
440
|
}
|
|
441
441
|
catch (error) {
|
|
442
442
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import type { LinearCredential } from "../auth/linear-credential.js";
|
|
2
2
|
import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
|
|
3
3
|
import type { AuthOptions } from "./auth.js";
|
|
4
|
+
export interface GraphQLRequestOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Retry only an operation whose mutation is safe to repeat. Callers must
|
|
7
|
+
* opt in; creates and comment writes intentionally retain one-shot
|
|
8
|
+
* semantics because a transport failure can leave their server-side result
|
|
9
|
+
* unknown.
|
|
10
|
+
*/
|
|
11
|
+
retrySafeMutation?: boolean;
|
|
12
|
+
}
|
|
13
|
+
interface GraphQLServiceRuntimeOptions {
|
|
14
|
+
retryDelaysMs?: readonly number[];
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** True only for failures where retrying a known-idempotent request is useful. */
|
|
18
|
+
export declare function isTransientGraphQLError(error: unknown): boolean;
|
|
4
19
|
/**
|
|
5
20
|
* Constructor arg for `GraphQLService`. Re-exported alias of the shared
|
|
6
21
|
* `LinearCredential` union (`{ apiKey } | { oauthToken }`). Kept as a
|
|
@@ -15,7 +30,10 @@ import type { AuthOptions } from "./auth.js";
|
|
|
15
30
|
export type GraphQLServiceAuth = LinearCredential;
|
|
16
31
|
export declare class GraphQLService {
|
|
17
32
|
private readonly graphQLClient;
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
private readonly retryDelaysMs;
|
|
34
|
+
private readonly sleep;
|
|
35
|
+
constructor(auth: GraphQLServiceAuth, options?: GraphQLServiceRuntimeOptions);
|
|
36
|
+
rawRequest<T = GraphQLResponseData>(query: string, variables?: GraphQLVariables, options?: GraphQLRequestOptions): Promise<T>;
|
|
20
37
|
}
|
|
21
38
|
export declare function createGraphQLService(options: AuthOptions): Promise<GraphQLService>;
|
|
39
|
+
export {};
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { LinearClient } from "@linear/sdk";
|
|
2
2
|
import { getActiveAuth } from "../auth/token-resolver.js";
|
|
3
|
+
const DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS = [150, 400];
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
/** True only for failures where retrying a known-idempotent request is useful. */
|
|
8
|
+
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)) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
return /\b(?:408|429|500|502|503|504)\b|(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|connection termination)/i.test(errorMessage(error));
|
|
17
|
+
}
|
|
3
18
|
function buildLinearClient(auth) {
|
|
4
19
|
const baseHeaders = { "public-file-urls-expire-in": "3600" };
|
|
5
20
|
if ("oauthToken" in auth) {
|
|
@@ -16,17 +31,38 @@ function buildLinearClient(auth) {
|
|
|
16
31
|
}
|
|
17
32
|
export class GraphQLService {
|
|
18
33
|
graphQLClient;
|
|
19
|
-
|
|
34
|
+
retryDelaysMs;
|
|
35
|
+
sleep;
|
|
36
|
+
constructor(auth, options = {}) {
|
|
20
37
|
const client = buildLinearClient(auth);
|
|
21
38
|
// LinearClient stores a private graphql-request client — access via escape hatch
|
|
22
39
|
this.graphQLClient = client.client;
|
|
40
|
+
this.retryDelaysMs =
|
|
41
|
+
options.retryDelaysMs ?? DEFAULT_SAFE_MUTATION_RETRY_DELAYS_MS;
|
|
42
|
+
this.sleep =
|
|
43
|
+
options.sleep ??
|
|
44
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
23
45
|
}
|
|
24
46
|
// Default type allows property access on raw GraphQL responses.
|
|
25
47
|
// Callers can narrow with explicit type parameter: rawRequest<{ issues: { nodes: T[] } }>(...)
|
|
26
|
-
async rawRequest(query, variables) {
|
|
48
|
+
async rawRequest(query, variables, options = {}) {
|
|
49
|
+
let attempt = 0;
|
|
27
50
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await this.graphQLClient.rawRequest(query, variables);
|
|
54
|
+
return response.data;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!options.retrySafeMutation ||
|
|
58
|
+
attempt >= this.retryDelaysMs.length ||
|
|
59
|
+
!isTransientGraphQLError(error)) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
await this.sleep(this.retryDelaysMs[attempt]);
|
|
63
|
+
attempt += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
30
66
|
}
|
|
31
67
|
catch (error) {
|
|
32
68
|
const err = error;
|
|
@@ -37,8 +37,8 @@ export interface ProtectedRange {
|
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Find ranges of `text` that should NOT have identifiers processed.
|
|
40
|
-
* Covered: fenced code, inline backticks, existing markdown
|
|
41
|
-
* Slack links, angle-bracket autolinks, bare URLs.
|
|
40
|
+
* Covered: fenced code, inline backticks, HTML comments, existing markdown
|
|
41
|
+
* links, Slack links, angle-bracket autolinks, bare URLs.
|
|
42
42
|
*
|
|
43
43
|
* The returned ranges may overlap; callers only test "is this position
|
|
44
44
|
* inside any protected range" so overlap is harmless.
|
|
@@ -22,6 +22,12 @@ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1
|
|
|
22
22
|
// for spans containing backticks; we keep it simple and match
|
|
23
23
|
// single-backtick spans, which covers the common case.
|
|
24
24
|
const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
|
|
25
|
+
// HTML comments carry machine-readable metadata in Markdown documents (for
|
|
26
|
+
// example, el-git's signed review and nit-disposition markers). They are not
|
|
27
|
+
// rendered prose, so identifiers inside them must remain byte-for-byte stable.
|
|
28
|
+
// Treat an unterminated comment as extending to EOF: preserving a malformed
|
|
29
|
+
// marker is safer than inserting Markdown into its payload.
|
|
30
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?(?:-->|$)/g;
|
|
25
31
|
// Existing markdown links: [text](url). Both halves protected so
|
|
26
32
|
// identifiers inside the URL or the text aren't reprocessed.
|
|
27
33
|
const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
@@ -116,8 +122,8 @@ function trimBareUrlTrailingPunct(url) {
|
|
|
116
122
|
}
|
|
117
123
|
/**
|
|
118
124
|
* Find ranges of `text` that should NOT have identifiers processed.
|
|
119
|
-
* Covered: fenced code, inline backticks, existing markdown
|
|
120
|
-
* Slack links, angle-bracket autolinks, bare URLs.
|
|
125
|
+
* Covered: fenced code, inline backticks, HTML comments, existing markdown
|
|
126
|
+
* links, Slack links, angle-bracket autolinks, bare URLs.
|
|
121
127
|
*
|
|
122
128
|
* The returned ranges may overlap; callers only test "is this position
|
|
123
129
|
* inside any protected range" so overlap is harmless.
|
|
@@ -127,6 +133,7 @@ export function findProtectedRanges(text) {
|
|
|
127
133
|
for (const re of [
|
|
128
134
|
FENCED_CODE_BLOCK_REGEX,
|
|
129
135
|
INLINE_CODE_REGEX,
|
|
136
|
+
HTML_COMMENT_REGEX,
|
|
130
137
|
MARKDOWN_LINK_REGEX,
|
|
131
138
|
// Slack links must be considered before generic angle-bracket
|
|
132
139
|
// autolinks — the autolink regex doesn't know about the `|label`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.43.
|
|
3
|
+
"version": "1.43.1",
|
|
4
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
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|