@enrichlayer/el-linear 1.42.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/README.md +16 -0
- package/dist/commands/issues/description.js +11 -2
- package/dist/commands/issues.js +31 -2
- package/dist/queries/issues-types.d.ts +11 -0
- package/dist/queries/issues.d.ts +3 -3
- package/dist/queries/issues.js +18 -1
- package/dist/utils/graphql-issues-service.d.ts +15 -0
- package/dist/utils/graphql-issues-service.js +88 -25
- package/dist/utils/graphql-service.d.ts +20 -2
- package/dist/utils/graphql-service.js +40 -4
- package/dist/utils/issue-envelope-guard.d.ts +50 -0
- package/dist/utils/issue-envelope-guard.js +110 -0
- package/dist/utils/output.js +6 -0
- package/dist/utils/protected-ranges.d.ts +2 -2
- package/dist/utils/protected-ranges.js +9 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -489,6 +489,22 @@ el-linear <command> --help # detailed help for one command
|
|
|
489
489
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
490
490
|
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
491
491
|
|
|
492
|
+
`issues list` also accepts `--all` (equivalent to `--limit 0`) to fetch **every**
|
|
493
|
+
matching issue. It paginates the full set in safe chunks under the hood, so
|
|
494
|
+
enumerating a large team no longer trips Linear's GraphQL complexity ceiling
|
|
495
|
+
(`Query too complex`) the way a single big `--limit` used to — no manual
|
|
496
|
+
per-status / per-priority bucketing needed:
|
|
497
|
+
|
|
498
|
+
```bash
|
|
499
|
+
el-linear issues list --team DEV --all --format csv --fields identifier # the whole open DEV backlog, one command
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`issues search` intentionally has no `--all`: Linear's full-text search is a
|
|
503
|
+
relevance-ranked candidate search whose results are filtered client-side, not
|
|
504
|
+
an exhaustive enumeration. Its `--limit` reads at most 200 ranked candidates to
|
|
505
|
+
keep the rich issue query below Linear's complexity ceiling. Use `issues list`
|
|
506
|
+
with structured filters and `--all` when you need every matching issue.
|
|
507
|
+
|
|
492
508
|
### Open by default — `issues list` and `issues search` skip terminal states
|
|
493
509
|
|
|
494
510
|
`el-linear issues list` and `el-linear issues search` **exclude issues in
|
|
@@ -20,6 +20,7 @@ import { loadConfig } from "../../config/config.js";
|
|
|
20
20
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
21
21
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
22
22
|
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
23
|
+
import { assertNotIssueEnvelope } from "../../utils/issue-envelope-guard.js";
|
|
23
24
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
25
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
26
|
import { readTextInputFile } from "../../utils/text-input-file.js";
|
|
@@ -53,11 +54,19 @@ export function resolveDescription(options) {
|
|
|
53
54
|
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
54
55
|
"Pick one.");
|
|
55
56
|
}
|
|
57
|
+
// Guard both user-supplied paths (file + inline) against being handed an
|
|
58
|
+
// issue's own JSON envelope, which would silently overwrite the real body
|
|
59
|
+
// (DEV-6315). Template bodies are config-authored, so they're not checked.
|
|
60
|
+
const allow = options.allowJsonDescription === true;
|
|
56
61
|
if (hasFile) {
|
|
57
|
-
|
|
62
|
+
const body = readDescriptionFile(options.descriptionFile);
|
|
63
|
+
assertNotIssueEnvelope(body, { allow });
|
|
64
|
+
return body;
|
|
58
65
|
}
|
|
59
66
|
if (hasInline) {
|
|
60
|
-
|
|
67
|
+
const body = normalizeInlineTextInput(options.description);
|
|
68
|
+
assertNotIssueEnvelope(body, { allow });
|
|
69
|
+
return body;
|
|
61
70
|
}
|
|
62
71
|
if (hasTemplate) {
|
|
63
72
|
const templates = loadConfig().descriptionTemplates ?? {};
|
package/dist/commands/issues.js
CHANGED
|
@@ -16,6 +16,7 @@ import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
|
16
16
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
17
17
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
18
18
|
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
19
|
+
import { assertNotIssueEnvelope } from "../utils/issue-envelope-guard.js";
|
|
19
20
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
20
21
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
21
22
|
import { logger } from "../utils/logger.js";
|
|
@@ -217,7 +218,11 @@ async function handleListIssues(options, command) {
|
|
|
217
218
|
options.project ||
|
|
218
219
|
options.project === false ||
|
|
219
220
|
options.priority;
|
|
220
|
-
|
|
221
|
+
// DEV-6312: `--all` / `--limit 0` mean unlimited. Stored as `limit = 0`,
|
|
222
|
+
// which the service's chunked pagination interprets as "fetch the whole
|
|
223
|
+
// matching set in safe pages" — mirrors `projects list --all` (DEV-4175).
|
|
224
|
+
const unlimited = options.all === true || options.limit === "0";
|
|
225
|
+
const limit = unlimited ? 0 : parsePositiveInt(options.limit, "--limit");
|
|
221
226
|
// Route through searchIssues whenever the CLI needs to control the GraphQL
|
|
222
227
|
// state filter: any explicit filter, the default `excludeTerminalStates`,
|
|
223
228
|
// OR an explicit `--include-closed`. The last case is load-bearing —
|
|
@@ -1100,6 +1105,18 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
1100
1105
|
if (typeof options.appendDescription === "string") {
|
|
1101
1106
|
options.appendDescription = normalizeInlineTextInput(options.appendDescription);
|
|
1102
1107
|
}
|
|
1108
|
+
// Guard against overwriting this issue's body with its own JSON envelope
|
|
1109
|
+
// (DEV-6315). The update path resolves description inline rather than via
|
|
1110
|
+
// resolveDescription(), so the guard is applied here too; --description,
|
|
1111
|
+
// --description-file (normalized above), and --append-description are all
|
|
1112
|
+
// checked (append would corrupt just as badly).
|
|
1113
|
+
{
|
|
1114
|
+
const allow = options.allowJsonDescription === true;
|
|
1115
|
+
assertNotIssueEnvelope(typeof options.description === "string" ? options.description : undefined, { allow, targetIssueRef: issueId });
|
|
1116
|
+
assertNotIssueEnvelope(typeof options.appendDescription === "string"
|
|
1117
|
+
? options.appendDescription
|
|
1118
|
+
: undefined, { allow, targetIssueRef: issueId });
|
|
1119
|
+
}
|
|
1103
1120
|
validateUpdateOptions(options);
|
|
1104
1121
|
const rootOpts = getRootOpts(command);
|
|
1105
1122
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
@@ -1324,6 +1341,14 @@ async function handleMarkBranch(issueId, options, command) {
|
|
|
1324
1341
|
`Pass one explicitly: 'el-linear issues mark-branch DEV-123'.`);
|
|
1325
1342
|
}
|
|
1326
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
|
+
}
|
|
1327
1352
|
const previous = getBranchLinearIssue(branch);
|
|
1328
1353
|
setBranchLinearIssue(branch, identifier);
|
|
1329
1354
|
let claim;
|
|
@@ -1381,6 +1406,7 @@ export function setupIssuesCommands(program) {
|
|
|
1381
1406
|
.command("list")
|
|
1382
1407
|
.description("List issues.")
|
|
1383
1408
|
.option("-l, --limit <number>", "limit results", "25")
|
|
1409
|
+
.option("--all", "fetch every matching issue (paginates fully in safe chunks). Equivalent to --limit 0; overrides --limit when both are given.")
|
|
1384
1410
|
.option("--search <query>", "full-text search term; composes with list filters")
|
|
1385
1411
|
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1386
1412
|
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
@@ -1412,7 +1438,7 @@ export function setupIssuesCommands(program) {
|
|
|
1412
1438
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1413
1439
|
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1414
1440
|
.option("--fields <fields>", "columns for table/csv output")
|
|
1415
|
-
.option("-l, --limit <number>", "limit results", "10")
|
|
1441
|
+
.option("-l, --limit <number>", "limit results (full-text search reads at most 200 ranked candidates)", "10")
|
|
1416
1442
|
.action(handleAsyncCommand(handleSearchIssues));
|
|
1417
1443
|
issues
|
|
1418
1444
|
.command("create [title]")
|
|
@@ -1421,6 +1447,7 @@ export function setupIssuesCommands(program) {
|
|
|
1421
1447
|
.option("-d, --description <desc>", "issue description")
|
|
1422
1448
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1423
1449
|
.option("--template <name>", "use a named description template from config.descriptionTemplates")
|
|
1450
|
+
.option("--allow-json-description", "allow a --description / --description-file body that looks like an issue's JSON envelope (normally blocked to prevent silently overwriting a body with 'issues get --format json' output — DEV-6315)")
|
|
1424
1451
|
.option("--from-template <id>", "instantiate the issue from a Linear server-side template (UUID from `el-linear templates list`). Sets templateId on the underlying issueCreate mutation; Linear copies the template's title, description, labels, priority, etc. as the new issue's defaults. Override any field with the matching --title / --description / --labels flag.")
|
|
1425
1452
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1426
1453
|
.option("--delegate <delegate>", "delegate implementation to an agent app user (name, alias, or UUID)")
|
|
@@ -1503,6 +1530,7 @@ export function setupIssuesCommands(program) {
|
|
|
1503
1530
|
.option("-t, --title <title>", "new title")
|
|
1504
1531
|
.option("-d, --description <desc>", "new description")
|
|
1505
1532
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1533
|
+
.option("--allow-json-description", "allow a --description / --description-file body that looks like an issue's JSON envelope (normally blocked to prevent silently overwriting a body with 'issues get --format json' output — DEV-6315)")
|
|
1506
1534
|
.option("--append-description <text>", "append text to the existing description")
|
|
1507
1535
|
.option("-s, --status <status>", "new status name or ID")
|
|
1508
1536
|
.option("--state <status>", "alias for --status (new status name or ID)")
|
|
@@ -1600,5 +1628,6 @@ export function setupIssuesCommands(program) {
|
|
|
1600
1628
|
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
|
|
1601
1629
|
"By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
|
|
1602
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")
|
|
1603
1632
|
.action(handleAsyncCommand(handleMarkBranch));
|
|
1604
1633
|
}
|
|
@@ -147,10 +147,20 @@ export interface BatchGetIssuesResponse {
|
|
|
147
147
|
nodes: IssueWithCommentsNode[];
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Cursor-pagination page metadata (DEV-6312). Present on the connection
|
|
152
|
+
* responses the list/search enumeration paths chunk through so a large
|
|
153
|
+
* `--limit` / `--all` never blows Linear's GraphQL complexity ceiling.
|
|
154
|
+
*/
|
|
155
|
+
export interface IssuePageInfo {
|
|
156
|
+
hasNextPage: boolean;
|
|
157
|
+
endCursor: string | null;
|
|
158
|
+
}
|
|
150
159
|
/** Response shape for `GET_ISSUES_QUERY`. */
|
|
151
160
|
export interface GetIssuesResponse {
|
|
152
161
|
issues: {
|
|
153
162
|
nodes: IssueNode[];
|
|
163
|
+
pageInfo?: IssuePageInfo;
|
|
154
164
|
};
|
|
155
165
|
}
|
|
156
166
|
/**
|
|
@@ -161,6 +171,7 @@ export interface TeamScopedFilteredIssuesResponse {
|
|
|
161
171
|
team: {
|
|
162
172
|
issues: {
|
|
163
173
|
nodes: IssueNode[];
|
|
174
|
+
pageInfo?: IssuePageInfo;
|
|
164
175
|
};
|
|
165
176
|
} | null;
|
|
166
177
|
}
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
1
|
+
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n after: $after\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n";
|
|
2
2
|
export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
3
|
-
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n";
|
|
3
|
+
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $after: String\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n after: $after\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n";
|
|
4
4
|
/**
|
|
5
5
|
* Team-scoped variant of `FILTERED_SEARCH_ISSUES_QUERY` (DEV-5578).
|
|
6
6
|
*
|
|
@@ -19,7 +19,7 @@ export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIss
|
|
|
19
19
|
* (state / labels / assignee / priority / project) is applied on top of the
|
|
20
20
|
* already-team-scoped connection.
|
|
21
21
|
*/
|
|
22
|
-
export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n }\n }\n }\n";
|
|
22
|
+
export declare const TEAM_SCOPED_FILTERED_ISSUES_QUERY = "\n query TeamScopedFilteredIssues(\n $teamId: String!\n $first: Int!\n $after: String\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n team(id: $teamId) {\n issues(\n first: $first\n after: $after\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n completedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n";
|
|
23
23
|
/**
|
|
24
24
|
* Batch-resolves a search's team/project/assignee/delegate filter inputs.
|
|
25
25
|
*
|
package/dist/queries/issues.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { COMPLETE_ISSUE_FRAGMENT, COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT, } from "./common.js";
|
|
2
2
|
export const GET_ISSUES_QUERY = `
|
|
3
|
-
query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {
|
|
3
|
+
query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) {
|
|
4
4
|
issues(
|
|
5
5
|
first: $first
|
|
6
|
+
after: $after
|
|
6
7
|
orderBy: $orderBy
|
|
7
8
|
filter: {
|
|
8
9
|
state: { type: { neq: "completed" } }
|
|
@@ -11,6 +12,10 @@ export const GET_ISSUES_QUERY = `
|
|
|
11
12
|
nodes {
|
|
12
13
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
13
14
|
}
|
|
15
|
+
pageInfo {
|
|
16
|
+
hasNextPage
|
|
17
|
+
endCursor
|
|
18
|
+
}
|
|
14
19
|
}
|
|
15
20
|
}
|
|
16
21
|
`;
|
|
@@ -26,11 +31,13 @@ export const SEARCH_ISSUES_QUERY = `
|
|
|
26
31
|
export const FILTERED_SEARCH_ISSUES_QUERY = `
|
|
27
32
|
query FilteredSearchIssues(
|
|
28
33
|
$first: Int!
|
|
34
|
+
$after: String
|
|
29
35
|
$filter: IssueFilter
|
|
30
36
|
$orderBy: PaginationOrderBy
|
|
31
37
|
) {
|
|
32
38
|
issues(
|
|
33
39
|
first: $first
|
|
40
|
+
after: $after
|
|
34
41
|
filter: $filter
|
|
35
42
|
orderBy: $orderBy
|
|
36
43
|
includeArchived: false
|
|
@@ -38,6 +45,10 @@ export const FILTERED_SEARCH_ISSUES_QUERY = `
|
|
|
38
45
|
nodes {
|
|
39
46
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
40
47
|
}
|
|
48
|
+
pageInfo {
|
|
49
|
+
hasNextPage
|
|
50
|
+
endCursor
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
`;
|
|
@@ -63,12 +74,14 @@ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
|
|
|
63
74
|
query TeamScopedFilteredIssues(
|
|
64
75
|
$teamId: String!
|
|
65
76
|
$first: Int!
|
|
77
|
+
$after: String
|
|
66
78
|
$filter: IssueFilter
|
|
67
79
|
$orderBy: PaginationOrderBy
|
|
68
80
|
) {
|
|
69
81
|
team(id: $teamId) {
|
|
70
82
|
issues(
|
|
71
83
|
first: $first
|
|
84
|
+
after: $after
|
|
72
85
|
filter: $filter
|
|
73
86
|
orderBy: $orderBy
|
|
74
87
|
includeArchived: false
|
|
@@ -76,6 +89,10 @@ export const TEAM_SCOPED_FILTERED_ISSUES_QUERY = `
|
|
|
76
89
|
nodes {
|
|
77
90
|
${COMPLETE_ISSUE_FRAGMENT}
|
|
78
91
|
}
|
|
92
|
+
pageInfo {
|
|
93
|
+
hasNextPage
|
|
94
|
+
endCursor
|
|
95
|
+
}
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
98
|
}
|
|
@@ -183,6 +183,21 @@ export declare class GraphQLIssuesService {
|
|
|
183
183
|
private readonly graphQLService;
|
|
184
184
|
private readonly linearService;
|
|
185
185
|
constructor(graphQLService: GraphQLService, linearService: LinearService);
|
|
186
|
+
/**
|
|
187
|
+
* Chunked cursor pagination shared by every issue-enumeration path
|
|
188
|
+
* (DEV-6312). Fetches successive pages of at most `SAFE_ISSUE_PAGE_SIZE`
|
|
189
|
+
* via `fetchPage(first, after)` until the target is met or the connection
|
|
190
|
+
* is exhausted, so a large `--limit` (or `--all`, passed as `target === 0`)
|
|
191
|
+
* never issues one oversized request that Linear rejects as "Query too
|
|
192
|
+
* complex".
|
|
193
|
+
*
|
|
194
|
+
* `target === 0` means unlimited (fetch the whole connection). A positive
|
|
195
|
+
* target caps the total and trims any final-page overshoot. `fetchPage`
|
|
196
|
+
* returns `null` when the connection root is absent (e.g. an unresolved
|
|
197
|
+
* team) — treated as an empty result. A page that claims `hasNextPage` but
|
|
198
|
+
* returns zero nodes terminates the loop rather than spinning forever.
|
|
199
|
+
*/
|
|
200
|
+
private paginateIssueNodes;
|
|
186
201
|
getIssues(limit?: number): Promise<LinearIssue[]>;
|
|
187
202
|
getIssueById(issueId: string): Promise<LinearIssue>;
|
|
188
203
|
/**
|
|
@@ -12,6 +12,18 @@ import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-par
|
|
|
12
12
|
import { logger } from "./logger.js";
|
|
13
13
|
import { isUuid } from "./uuid.js";
|
|
14
14
|
const TEAM_KEY_REGEX = /^[A-Z0-9]+$/i;
|
|
15
|
+
/**
|
|
16
|
+
* Per-page cap for the chunked issue-enumeration paths (DEV-6312).
|
|
17
|
+
*
|
|
18
|
+
* The list/search queries embed the rich `COMPLETE_ISSUE_FRAGMENT`, so a single
|
|
19
|
+
* page's GraphQL complexity scales with `first`. Linear rejects any query above
|
|
20
|
+
* complexity 10000; empirically `first: 500` measured ~10900, i.e. ~21.8 per
|
|
21
|
+
* issue. 200 keeps a page near ~4400 — comfortably under the ceiling with
|
|
22
|
+
* headroom for the fragment growing — while still being few enough round-trips
|
|
23
|
+
* for large teams. A big `--limit` (or `--all`) is fetched as successive pages
|
|
24
|
+
* of this size rather than one oversized request that would be rejected.
|
|
25
|
+
*/
|
|
26
|
+
const SAFE_ISSUE_PAGE_SIZE = 200;
|
|
15
27
|
function extractSummaryText(summary) {
|
|
16
28
|
if (summary.generationStatus !== "completed" || !summary.content) {
|
|
17
29
|
return undefined;
|
|
@@ -62,12 +74,55 @@ export class GraphQLIssuesService {
|
|
|
62
74
|
this.graphQLService = graphQLService;
|
|
63
75
|
this.linearService = linearService;
|
|
64
76
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Chunked cursor pagination shared by every issue-enumeration path
|
|
79
|
+
* (DEV-6312). Fetches successive pages of at most `SAFE_ISSUE_PAGE_SIZE`
|
|
80
|
+
* via `fetchPage(first, after)` until the target is met or the connection
|
|
81
|
+
* is exhausted, so a large `--limit` (or `--all`, passed as `target === 0`)
|
|
82
|
+
* never issues one oversized request that Linear rejects as "Query too
|
|
83
|
+
* complex".
|
|
84
|
+
*
|
|
85
|
+
* `target === 0` means unlimited (fetch the whole connection). A positive
|
|
86
|
+
* target caps the total and trims any final-page overshoot. `fetchPage`
|
|
87
|
+
* returns `null` when the connection root is absent (e.g. an unresolved
|
|
88
|
+
* team) — treated as an empty result. A page that claims `hasNextPage` but
|
|
89
|
+
* returns zero nodes terminates the loop rather than spinning forever.
|
|
90
|
+
*/
|
|
91
|
+
async paginateIssueNodes(target, fetchPage) {
|
|
92
|
+
const unlimited = target === 0;
|
|
93
|
+
const collected = [];
|
|
94
|
+
let after = null;
|
|
95
|
+
while (true) {
|
|
96
|
+
const remaining = unlimited
|
|
97
|
+
? SAFE_ISSUE_PAGE_SIZE
|
|
98
|
+
: Math.min(target - collected.length, SAFE_ISSUE_PAGE_SIZE);
|
|
99
|
+
if (!unlimited && remaining <= 0) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const page = await fetchPage(remaining, after);
|
|
103
|
+
const nodes = page?.nodes ?? [];
|
|
104
|
+
collected.push(...nodes);
|
|
105
|
+
if (!unlimited && collected.length >= target) {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
const pageInfo = page?.pageInfo;
|
|
109
|
+
if (!pageInfo?.hasNextPage || !pageInfo.endCursor) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
// Defensive: an empty page that still claims hasNextPage would loop
|
|
113
|
+
// forever on the same cursor. Stop instead.
|
|
114
|
+
if (nodes.length === 0) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
after = pageInfo.endCursor;
|
|
70
118
|
}
|
|
119
|
+
return unlimited ? collected : collected.slice(0, target);
|
|
120
|
+
}
|
|
121
|
+
async getIssues(limit = 25) {
|
|
122
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
123
|
+
const result = await this.graphQLService.rawRequest(GET_ISSUES_QUERY, { first, after, orderBy: "updatedAt" });
|
|
124
|
+
return result.issues ?? null;
|
|
125
|
+
});
|
|
71
126
|
return nodes.map((issue) => this.transformIssueData(issue));
|
|
72
127
|
}
|
|
73
128
|
async getIssueById(issueId) {
|
|
@@ -381,7 +436,7 @@ export class GraphQLIssuesService {
|
|
|
381
436
|
updateResult = await this.graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
382
437
|
id: resolvedIssueId,
|
|
383
438
|
input: updateInput,
|
|
384
|
-
});
|
|
439
|
+
}, { retrySafeMutation: true });
|
|
385
440
|
}
|
|
386
441
|
catch (error) {
|
|
387
442
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -579,9 +634,17 @@ export class GraphQLIssuesService {
|
|
|
579
634
|
: undefined;
|
|
580
635
|
const limit = args.limit ?? 10;
|
|
581
636
|
if (args.query) {
|
|
637
|
+
// Full-text search is relevance-ranked and then filtered client-side,
|
|
638
|
+
// so it cannot promise exhaustive `--all` semantics. Keep it to one
|
|
639
|
+
// bounded candidate page: the query embeds COMPLETE_ISSUE_FRAGMENT and
|
|
640
|
+
// an explicit large --limit would otherwise recreate the GraphQL
|
|
641
|
+
// complexity failure that chunking prevents on enumeration paths.
|
|
642
|
+
const fullTextLimit = limit === 0
|
|
643
|
+
? SAFE_ISSUE_PAGE_SIZE
|
|
644
|
+
: Math.min(limit, SAFE_ISSUE_PAGE_SIZE);
|
|
582
645
|
const searchResult = await this.graphQLService.rawRequest(SEARCH_ISSUES_QUERY, {
|
|
583
646
|
term: args.query,
|
|
584
|
-
first:
|
|
647
|
+
first: fullTextLimit,
|
|
585
648
|
});
|
|
586
649
|
const nodes = searchResult.searchIssues?.nodes;
|
|
587
650
|
if (!nodes?.length) {
|
|
@@ -618,28 +681,28 @@ export class GraphQLIssuesService {
|
|
|
618
681
|
const filterArg = Object.keys(filter).length > 0 ? filter : undefined;
|
|
619
682
|
const orderBy = args.orderBy ?? "updatedAt";
|
|
620
683
|
if (finalTeamId) {
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
684
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
685
|
+
const teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
|
|
686
|
+
teamId: finalTeamId,
|
|
687
|
+
first,
|
|
688
|
+
after,
|
|
689
|
+
filter: filterArg,
|
|
690
|
+
orderBy,
|
|
691
|
+
});
|
|
692
|
+
return teamScoped.team?.issues ?? null;
|
|
626
693
|
});
|
|
627
|
-
const nodes = teamScoped.team?.issues?.nodes;
|
|
628
|
-
if (!nodes?.length) {
|
|
629
|
-
return [];
|
|
630
|
-
}
|
|
631
694
|
return nodes.map((issue) => this.transformIssueData(issue));
|
|
632
695
|
}
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
696
|
+
const nodes = await this.paginateIssueNodes(limit, async (first, after) => {
|
|
697
|
+
const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
|
|
698
|
+
first,
|
|
699
|
+
after,
|
|
700
|
+
filter: filterArg,
|
|
701
|
+
orderBy,
|
|
702
|
+
});
|
|
703
|
+
return searchResult.issues ?? null;
|
|
637
704
|
});
|
|
638
|
-
|
|
639
|
-
if (!filteredIssues?.nodes) {
|
|
640
|
-
return [];
|
|
641
|
-
}
|
|
642
|
-
return filteredIssues.nodes.map((issue) => this.transformIssueData(issue));
|
|
705
|
+
return nodes.map((issue) => this.transformIssueData(issue));
|
|
643
706
|
}
|
|
644
707
|
// --- Private helper methods for field resolution ---
|
|
645
708
|
async resolveTeamId(teamId, resolveResult) {
|
|
@@ -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;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard against overwriting an issue body with the issue's own JSON envelope.
|
|
3
|
+
*
|
|
4
|
+
* `issues get/read --format json` emits `JSON.stringify(transformIssueData(...))`
|
|
5
|
+
* — an object shaped `{ id, identifier, url, title, description, branchName,
|
|
6
|
+
* state, assignee, ... }`. Feeding that straight back into
|
|
7
|
+
* `issues update <ID> --description "$(... get <ID> --format json)"` (or the
|
|
8
|
+
* equivalent create) silently replaces the real markdown with the stringified
|
|
9
|
+
* envelope. Re-running on an already-corrupted issue re-wraps it, producing
|
|
10
|
+
* envelope-inside-envelope — so the damage deepens and recurs.
|
|
11
|
+
*
|
|
12
|
+
* This detector spots the envelope signature so the create/update path can
|
|
13
|
+
* block it with an actionable error. It is deliberately conservative: a plain
|
|
14
|
+
* markdown body never parses to an object carrying `identifier` plus an
|
|
15
|
+
* issue-only sibling key, so real descriptions pass untouched.
|
|
16
|
+
*
|
|
17
|
+
* See DEV-6315 (and the two victims it recovered, DEV-6092 / DEV-6042).
|
|
18
|
+
*/
|
|
19
|
+
export interface IssueEnvelopeMatch {
|
|
20
|
+
/** The `identifier` field carried by the detected envelope (e.g. "DEV-123"). */
|
|
21
|
+
identifier: string;
|
|
22
|
+
/** UUID carried by the envelope, when present. Used for self-match messages. */
|
|
23
|
+
id?: string;
|
|
24
|
+
/** Linear URL carried by the envelope, when present. */
|
|
25
|
+
url?: string;
|
|
26
|
+
/** True when the envelope's `description` is itself a nested envelope — the
|
|
27
|
+
* double-nesting signature of a re-corrupted body. */
|
|
28
|
+
nested: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Return a match when `text` parses as an issue-envelope JSON object, else null.
|
|
32
|
+
*
|
|
33
|
+
* An envelope is an object with a string `identifier` AND at least one of
|
|
34
|
+
* {@link ENVELOPE_SIBLING_KEYS}. Non-JSON text, JSON that isn't an object, and
|
|
35
|
+
* JSON objects without the signature all return null.
|
|
36
|
+
*/
|
|
37
|
+
export declare function detectIssueEnvelope(text: string): IssueEnvelopeMatch | null;
|
|
38
|
+
/**
|
|
39
|
+
* Throw an actionable error when `text` looks like an issue's own JSON
|
|
40
|
+
* envelope being used as a description body. No-op otherwise, or when the
|
|
41
|
+
* caller passed the audited `--allow-json-description` override.
|
|
42
|
+
*
|
|
43
|
+
* `context` carries the audited override and, when known, the raw update target
|
|
44
|
+
* (identifier, UUID, or URL) so the message can flag the exact self-overwrite
|
|
45
|
+
* case without making a network request.
|
|
46
|
+
*/
|
|
47
|
+
export declare function assertNotIssueEnvelope(text: string | undefined, context?: {
|
|
48
|
+
allow?: boolean;
|
|
49
|
+
targetIssueRef?: string;
|
|
50
|
+
}): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard against overwriting an issue body with the issue's own JSON envelope.
|
|
3
|
+
*
|
|
4
|
+
* `issues get/read --format json` emits `JSON.stringify(transformIssueData(...))`
|
|
5
|
+
* — an object shaped `{ id, identifier, url, title, description, branchName,
|
|
6
|
+
* state, assignee, ... }`. Feeding that straight back into
|
|
7
|
+
* `issues update <ID> --description "$(... get <ID> --format json)"` (or the
|
|
8
|
+
* equivalent create) silently replaces the real markdown with the stringified
|
|
9
|
+
* envelope. Re-running on an already-corrupted issue re-wraps it, producing
|
|
10
|
+
* envelope-inside-envelope — so the damage deepens and recurs.
|
|
11
|
+
*
|
|
12
|
+
* This detector spots the envelope signature so the create/update path can
|
|
13
|
+
* block it with an actionable error. It is deliberately conservative: a plain
|
|
14
|
+
* markdown body never parses to an object carrying `identifier` plus an
|
|
15
|
+
* issue-only sibling key, so real descriptions pass untouched.
|
|
16
|
+
*
|
|
17
|
+
* See DEV-6315 (and the two victims it recovered, DEV-6092 / DEV-6042).
|
|
18
|
+
*/
|
|
19
|
+
/** Strong issue-envelope keys that, alongside `identifier`, distinguish the
|
|
20
|
+
* CLI output from unrelated ticket JSON. `description` is deliberately absent:
|
|
21
|
+
* `{ identifier, description }` is common enough outside Linear to avoid a
|
|
22
|
+
* false positive, while real `issues get` output always carries `url` and
|
|
23
|
+
* normally also `state` / `branchName`. */
|
|
24
|
+
const ENVELOPE_SIBLING_KEYS = ["branchName", "state", "url"];
|
|
25
|
+
/**
|
|
26
|
+
* Return a match when `text` parses as an issue-envelope JSON object, else null.
|
|
27
|
+
*
|
|
28
|
+
* An envelope is an object with a string `identifier` AND at least one of
|
|
29
|
+
* {@link ENVELOPE_SIBLING_KEYS}. Non-JSON text, JSON that isn't an object, and
|
|
30
|
+
* JSON objects without the signature all return null.
|
|
31
|
+
*/
|
|
32
|
+
export function detectIssueEnvelope(text) {
|
|
33
|
+
const trimmed = text.trim();
|
|
34
|
+
// Cheap prefilter: an envelope is a JSON object literal. Skips the parse for
|
|
35
|
+
// the overwhelmingly common markdown case.
|
|
36
|
+
if (!trimmed.startsWith("{")) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(trimmed);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const obj = parsed;
|
|
50
|
+
if (typeof obj.identifier !== "string" || obj.identifier.length === 0) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const hasSibling = ENVELOPE_SIBLING_KEYS.some((key) => key in obj);
|
|
54
|
+
if (!hasSibling) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const nested = typeof obj.description === "string" &&
|
|
58
|
+
detectIssueEnvelope(obj.description) !== null;
|
|
59
|
+
return {
|
|
60
|
+
identifier: obj.identifier,
|
|
61
|
+
...(typeof obj.id === "string" ? { id: obj.id } : {}),
|
|
62
|
+
...(typeof obj.url === "string" ? { url: obj.url } : {}),
|
|
63
|
+
nested,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Whether a raw CLI target (identifier, UUID, or Linear URL) names `match`. */
|
|
67
|
+
function isSameIssueTarget(match, targetIssueRef) {
|
|
68
|
+
if (!targetIssueRef) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const target = targetIssueRef.toLowerCase();
|
|
72
|
+
if (target === match.identifier.toLowerCase() ||
|
|
73
|
+
(match.id !== undefined && target === match.id.toLowerCase()) ||
|
|
74
|
+
(match.url !== undefined && target === match.url.toLowerCase())) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// Linear issue URLs carry the canonical identifier as a path segment.
|
|
78
|
+
return target.split(/[/?#]/).includes(match.identifier.toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Throw an actionable error when `text` looks like an issue's own JSON
|
|
82
|
+
* envelope being used as a description body. No-op otherwise, or when the
|
|
83
|
+
* caller passed the audited `--allow-json-description` override.
|
|
84
|
+
*
|
|
85
|
+
* `context` carries the audited override and, when known, the raw update target
|
|
86
|
+
* (identifier, UUID, or URL) so the message can flag the exact self-overwrite
|
|
87
|
+
* case without making a network request.
|
|
88
|
+
*/
|
|
89
|
+
export function assertNotIssueEnvelope(text, context = {}) {
|
|
90
|
+
if (!text || context.allow) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const match = detectIssueEnvelope(text);
|
|
94
|
+
if (!match) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const isSelf = isSameIssueTarget(match, context.targetIssueRef);
|
|
98
|
+
const selfNote = isSelf
|
|
99
|
+
? ` This is ${match.identifier}'s own envelope — the update would overwrite its body with itself.`
|
|
100
|
+
: "";
|
|
101
|
+
const nestedNote = match.nested
|
|
102
|
+
? " (It is already a doubly-nested envelope — a sign this body was corrupted by an earlier run.)"
|
|
103
|
+
: "";
|
|
104
|
+
throw new Error(`Refusing to write a description that looks like an issue's JSON envelope ` +
|
|
105
|
+
`(a "${match.identifier}" object with an issue-only field such as branchName/state/url).${selfNote}${nestedNote}\n` +
|
|
106
|
+
`This is almost always an "issues get --format json" output accidentally piped into --description — ` +
|
|
107
|
+
`which silently destroys the real body (DEV-6315). ` +
|
|
108
|
+
`Pass a markdown body via --description-file <path>, or, if you genuinely mean to store this JSON, ` +
|
|
109
|
+
`re-run with --allow-json-description.`);
|
|
110
|
+
}
|
package/dist/utils/output.js
CHANGED
|
@@ -348,6 +348,12 @@ export function outputSuccess(data) {
|
|
|
348
348
|
* current limit) so the agent doesn't have to derive it.
|
|
349
349
|
*/
|
|
350
350
|
export function warnIfTruncated(count, limit) {
|
|
351
|
+
// limit <= 0 means unlimited (--all / --limit 0, DEV-6312): a fully
|
|
352
|
+
// paginated result set can't be truncated, so never warn — and never
|
|
353
|
+
// emit the nonsensical "--limit 0" hint on an empty unlimited fetch.
|
|
354
|
+
if (limit <= 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
351
357
|
if (count !== limit) {
|
|
352
358
|
return;
|
|
353
359
|
}
|
|
@@ -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.
|
|
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",
|