@enrichlayer/el-linear 1.42.0 → 1.43.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 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
- return readDescriptionFile(options.descriptionFile);
62
+ const body = readDescriptionFile(options.descriptionFile);
63
+ assertNotIssueEnvelope(body, { allow });
64
+ return body;
58
65
  }
59
66
  if (hasInline) {
60
- return normalizeInlineTextInput(options.description);
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 ?? {};
@@ -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
- const limit = parsePositiveInt(options.limit, "--limit");
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);
@@ -1381,6 +1398,7 @@ export function setupIssuesCommands(program) {
1381
1398
  .command("list")
1382
1399
  .description("List issues.")
1383
1400
  .option("-l, --limit <number>", "limit results", "25")
1401
+ .option("--all", "fetch every matching issue (paginates fully in safe chunks). Equivalent to --limit 0; overrides --limit when both are given.")
1384
1402
  .option("--search <query>", "full-text search term; composes with list filters")
1385
1403
  .option("--team <team>", "filter by team key (EL: resolves names)")
1386
1404
  .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
@@ -1412,7 +1430,7 @@ export function setupIssuesCommands(program) {
1412
1430
  .option("--sort <field>", "sort results (priority, status, created, updated)")
1413
1431
  .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
1414
1432
  .option("--fields <fields>", "columns for table/csv output")
1415
- .option("-l, --limit <number>", "limit results", "10")
1433
+ .option("-l, --limit <number>", "limit results (full-text search reads at most 200 ranked candidates)", "10")
1416
1434
  .action(handleAsyncCommand(handleSearchIssues));
1417
1435
  issues
1418
1436
  .command("create [title]")
@@ -1421,6 +1439,7 @@ export function setupIssuesCommands(program) {
1421
1439
  .option("-d, --description <desc>", "issue description")
1422
1440
  .option("--description-file <path>", "read description from file (use - for stdin)")
1423
1441
  .option("--template <name>", "use a named description template from config.descriptionTemplates")
1442
+ .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
1443
  .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
1444
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
1426
1445
  .option("--delegate <delegate>", "delegate implementation to an agent app user (name, alias, or UUID)")
@@ -1503,6 +1522,7 @@ export function setupIssuesCommands(program) {
1503
1522
  .option("-t, --title <title>", "new title")
1504
1523
  .option("-d, --description <desc>", "new description")
1505
1524
  .option("--description-file <path>", "read description from file (use - for stdin)")
1525
+ .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
1526
  .option("--append-description <text>", "append text to the existing description")
1507
1527
  .option("-s, --status <status>", "new status name or ID")
1508
1528
  .option("--state <status>", "alias for --status (new status name or ID)")
@@ -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
  }
@@ -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
  *
@@ -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
- async getIssues(limit = 25) {
66
- const result = await this.graphQLService.rawRequest(GET_ISSUES_QUERY, { first: limit, orderBy: "updatedAt" });
67
- const nodes = result.issues?.nodes;
68
- if (!nodes?.length) {
69
- return [];
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) {
@@ -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: limit,
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 teamScoped = await this.graphQLService.rawRequest(TEAM_SCOPED_FILTERED_ISSUES_QUERY, {
622
- teamId: finalTeamId,
623
- first: limit,
624
- filter: filterArg,
625
- orderBy,
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 searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
634
- first: limit,
635
- filter: filterArg,
636
- orderBy,
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
- const filteredIssues = searchResult.issues;
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) {
@@ -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
+ }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
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",