@enrichlayer/el-linear 1.32.1 → 1.32.3

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
@@ -442,7 +442,7 @@ el-linear <command> --help # detailed help for one command
442
442
  | Group | Common commands |
443
443
  |-------|-----------------|
444
444
  | Issues | `issues {list, search, create, read, update, start, delete, history, related, link-references}` |
445
- | Comments | `comments {list, create, update}` |
445
+ | Comments | `comments {list, read, create, update, delete}` |
446
446
  | Labels | `labels {list, create, retire, restore}` |
447
447
  | Projects | `projects {list, add-team, remove-team}` |
448
448
  | Cycles | `cycles {list, read}` |
@@ -660,6 +660,29 @@ el-linear issues read DEV-123 --body
660
660
 
661
661
  Mutually exclusive with `--field` / `--sections` / `--with`.
662
662
 
663
+ ### Read full comment bodies without JSON parsing
664
+
665
+ `comments read <comment-id>` reads one comment by full UUID, `comment-<hash>`
666
+ token, or a Linear URL containing `#comment-<hash>`. Use `--body` for the raw
667
+ markdown body with real newlines and no JSON envelope. `comments list --body`
668
+ prints every complete body on an issue as plain text blocks, and
669
+ `comments list --format summary --no-truncate` keeps the summary shape while
670
+ showing full bodies.
671
+
672
+ ```bash
673
+ el-linear comments list DEV-123 --format summary
674
+ # comment c5d15b28-... Alice 2026-07-01T00:00:00.000Z
675
+ # Short preview...
676
+
677
+ el-linear comments read comment-c5d15b28 --body
678
+ # Full comment body...
679
+
680
+ el-linear comments list DEV-123 --body
681
+ # comment c5d15b28-...
682
+ #
683
+ # Full comment body...
684
+ ```
685
+
663
686
  ### One-line write confirmations: `-q, --quiet`
664
687
 
665
688
  `issues create|update`, `issues relate`, and `comments create|update` accept
@@ -63,6 +63,9 @@ el-linear issues read DEV-123 --format summary 2>&1
63
63
 
64
64
  # ✅ Whole description as raw text → --body. Terse write confirmation → --quiet
65
65
  el-linear issues read DEV-123 --body 2>&1
66
+ el-linear comments read comment-c5d15b28 --body 2>&1
67
+ el-linear comments list DEV-123 --body 2>&1
68
+ el-linear comments list DEV-123 --format summary --no-truncate 2>&1
66
69
  el-linear issues update DEV-123 --status Done --quiet 2>&1
67
70
  ```
68
71
 
@@ -96,6 +99,28 @@ el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys;
96
99
 
97
100
  `--body` is mutually exclusive with `--field` / `--sections` / `--with` (those extract named parts or extend the JSON envelope; `--body` is the whole thing as text).
98
101
 
102
+ ### Comment reads and full comment bodies
103
+
104
+ When you need a specific comment, or the full text of a long comment, use the
105
+ comments read/list surfaces instead of dumping JSON:
106
+
107
+ ```bash
108
+ # ✅ Resolve a Linear permalink anchor or full comment UUID
109
+ el-linear comments read comment-c5d15b28 --format summary 2>&1
110
+ el-linear comments read comment-c5d15b28 --body 2>&1
111
+
112
+ # ✅ Full bodies for every comment on an issue
113
+ el-linear comments list DEV-123 --body 2>&1
114
+ el-linear comments list DEV-123 --format summary --no-truncate 2>&1
115
+
116
+ # ❌ Don't do this
117
+ el-linear comments list DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['body'])"
118
+ ```
119
+
120
+ `comments read` accepts a full UUID, `comment-<hash>`, or a URL containing
121
+ `#comment-<hash>`. `comments list --format summary` includes each comment id
122
+ so you can copy it straight into `comments read`.
123
+
99
124
  ### Terse write confirmations: `-q, --quiet`
100
125
 
101
126
  `issues create|update` and `comments create|update` accept `-q, --quiet`, which prints a single machine-stable confirmation line instead of the full JSON envelope — no need to `grep` the result for the identifier / state / url:
@@ -118,7 +143,7 @@ For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` fo
118
143
 
119
144
  `--format summary` is implemented for:
120
145
 
121
- - **Single resources:** `issues read`, `projects read`, `cycles read`, `project-milestones read`, `documents read`, `templates read`, releases (`graphql` query results), `users read`
146
+ - **Single resources:** `issues read`, `comments read`, `projects read`, `cycles read`, `project-milestones read`, `documents read`, `templates read`, releases (`graphql` query results), `users read`
122
147
  - **Lists:** `issues list`, `issues search`, `projects list`, `comments list`, `cycles list`, `project-milestones list`, `labels list`, `teams list`, `users list`, `documents list`, `templates list`, `attachments list`, `releases list`, and the cross-resource `search` command
123
148
 
124
149
  Commands without a dedicated formatter (e.g. `config show`, custom `graphql` queries) fall back to a generic key/value rendering of their JSON payload.
@@ -10,6 +10,28 @@
10
10
  "Selects labels from the known label taxonomy (e.g., bug)",
11
11
  "Description includes a 'Why we need this' section",
12
12
  "Uses el-linear CLI to create the issue, not raw curl"
13
+ ],
14
+ "assertions": [
15
+ {
16
+ "type": "skill_used",
17
+ "name": "linear-operations",
18
+ "description": "Issue creation must route through the Linear operations skill."
19
+ },
20
+ {
21
+ "type": "command_matches",
22
+ "pattern": "el-linear\\s+issues?\\s+search",
23
+ "description": "Duplicate checking runs before creating the issue."
24
+ },
25
+ {
26
+ "type": "command_matches",
27
+ "pattern": "el-linear\\s+issues?\\s+create",
28
+ "description": "The issue is created through el-linear."
29
+ },
30
+ {
31
+ "type": "output_contains",
32
+ "value": "Why we need this",
33
+ "description": "The issue body includes the required intent section."
34
+ }
13
35
  ]
14
36
  },
15
37
  {
@@ -20,6 +42,23 @@
20
42
  "Uses el-linear search command with appropriate query",
21
43
  "Does not attempt to use curl against api.linear.app",
22
44
  "Returns formatted results with issue IDs and titles"
45
+ ],
46
+ "assertions": [
47
+ {
48
+ "type": "skill_used",
49
+ "name": "linear-operations",
50
+ "description": "Linear search should use the Linear operations skill."
51
+ },
52
+ {
53
+ "type": "command_matches",
54
+ "pattern": "el-linear\\s+issues?\\s+search\\s+.*auth",
55
+ "description": "Searches Linear with the requested auth query."
56
+ },
57
+ {
58
+ "type": "output_not_contains",
59
+ "value": "api.linear.app",
60
+ "description": "Does not bypass el-linear with raw Linear API calls."
61
+ }
23
62
  ]
24
63
  },
25
64
  {
@@ -30,6 +69,23 @@
30
69
  "Uses el-linear update command with the issue identifier DEV-123",
31
70
  "Sets the status to In Progress",
32
71
  "Does not create a new issue"
72
+ ],
73
+ "assertions": [
74
+ {
75
+ "type": "skill_used",
76
+ "name": "linear-operations",
77
+ "description": "Issue mutation should use the Linear operations skill."
78
+ },
79
+ {
80
+ "type": "command_matches",
81
+ "pattern": "el-linear\\s+issues?\\s+update\\s+DEV-123\\b.*--status\\s+['\\\"]?In Progress",
82
+ "description": "Updates DEV-123 to In Progress through el-linear."
83
+ },
84
+ {
85
+ "type": "command_matches",
86
+ "pattern": "el-linear\\s+issues?\\s+read\\s+DEV-123",
87
+ "description": "Reads the existing issue before mutation."
88
+ }
33
89
  ]
34
90
  },
35
91
  {
@@ -40,6 +96,33 @@
40
96
  "Does NOT trigger linear-operations skill",
41
97
  "Routes to git-branch-from-linear instead",
42
98
  "No el-linear create or update commands are issued"
99
+ ],
100
+ "assertions": [
101
+ {
102
+ "type": "skill_not_used",
103
+ "name": "linear-operations",
104
+ "description": "Branch creation is outside linear-operations scope."
105
+ },
106
+ {
107
+ "type": "skill_used",
108
+ "name": "git-branch-from-linear",
109
+ "description": "Branch creation should route to the branch skill."
110
+ },
111
+ {
112
+ "type": "command_matches",
113
+ "pattern": "el-linear\\s+issues?\\s+read\\s+DEV-456",
114
+ "description": "Reading issue metadata is allowed for branch creation."
115
+ },
116
+ {
117
+ "type": "output_not_contains",
118
+ "value": "el-linear issues create",
119
+ "description": "Does not create a new issue for branch creation."
120
+ },
121
+ {
122
+ "type": "output_not_contains",
123
+ "value": "el-linear issues update",
124
+ "description": "Does not update issue fields for branch creation."
125
+ }
43
126
  ]
44
127
  }
45
128
  ]
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolveUserDisplayName } from "../config/resolver.js";
3
- import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
3
+ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
4
4
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
5
5
  import { applyFooter } from "../utils/footer.js";
6
6
  import { createGraphQLService, } from "../utils/graphql-service.js";
@@ -9,7 +9,7 @@ import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js"
9
9
  import { createLinearService, } from "../utils/linear-service.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { resolveMentions, } from "../utils/mention-resolver.js";
12
- import { getQuietMode, handleAsyncCommand, outputSuccess, } from "../utils/output.js";
12
+ import { getOutputFormat, getQuietMode, handleAsyncCommand, outputSuccess, } from "../utils/output.js";
13
13
  import { getRootOpts } from "../utils/root-opts.js";
14
14
  import { validateReferences } from "../utils/validate-references.js";
15
15
  import { parsePositiveInt } from "../utils/validators.js";
@@ -18,6 +18,8 @@ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
18
18
  // tweak on their side doesn't silently regress the fallback path.
19
19
  const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
20
20
  const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
21
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const COMMENT_ANCHOR_REGEX = /(?:^|[#/])comment-([A-Za-z0-9-]+)/;
21
23
  /**
22
24
  * Turn a {@link MentionReport} into the `mentions` output field, or `undefined`
23
25
  * when there is nothing to report. `delivered=false` records a bodyData→plain
@@ -90,10 +92,11 @@ async function fetchSelfUserId(graphQLService) {
90
92
  return;
91
93
  }
92
94
  }
93
- function transformComment(comment) {
95
+ function transformComment(comment, options = {}) {
94
96
  return {
95
97
  id: comment.id,
96
98
  body: comment.body,
99
+ url: comment.url ?? undefined,
97
100
  user: {
98
101
  id: comment.user.id,
99
102
  name: resolveUserDisplayName(comment.user.id, comment.user.name),
@@ -101,8 +104,35 @@ function transformComment(comment) {
101
104
  },
102
105
  createdAt: comment.createdAt,
103
106
  updatedAt: comment.updatedAt,
107
+ ...(options.fullBodySummary ? { _summaryFullBody: true } : {}),
104
108
  };
105
109
  }
110
+ function normalizeCommentRef(input) {
111
+ const trimmed = input.trim();
112
+ const anchorMatch = COMMENT_ANCHOR_REGEX.exec(trimmed);
113
+ const candidate = anchorMatch?.[1] ?? trimmed.replace(/^#?comment-/, "");
114
+ if (UUID_REGEX.test(candidate)) {
115
+ return { id: candidate.toLowerCase() };
116
+ }
117
+ return { hash: candidate };
118
+ }
119
+ function printRawCommentBody(comment, label) {
120
+ const body = typeof comment.body === "string" ? comment.body : "";
121
+ if (body.trim() === "") {
122
+ process.stderr.write(`el-linear: comment ${label} has no body\n`);
123
+ process.exit(1);
124
+ }
125
+ process.stdout.write(`${body}\n`);
126
+ }
127
+ function formatCommentBodyBlocks(comments) {
128
+ return comments
129
+ .map((comment) => {
130
+ const id = typeof comment.id === "string" ? comment.id : "-";
131
+ const body = typeof comment.body === "string" ? comment.body : "";
132
+ return `comment ${id}\n\n${body}`;
133
+ })
134
+ .join("\n\n---\n\n");
135
+ }
106
136
  /**
107
137
  * Wrap valid issue references in a comment body as markdown links — same logic as for
108
138
  * issue descriptions. Returns the rewritten body plus the validated identifier→UUID map
@@ -324,7 +354,12 @@ async function handleListComments(issueId, options, command) {
324
354
  if (!result.issue) {
325
355
  throw new Error(`Issue "${issueId}" not found`);
326
356
  }
327
- const nodes = result.issue.comments.nodes.map(transformComment);
357
+ const fullBodySummary = options.truncate === false && getOutputFormat() === "summary";
358
+ const nodes = result.issue.comments.nodes.map((comment) => transformComment(comment, { fullBodySummary }));
359
+ if (options.body === true) {
360
+ process.stdout.write(`${formatCommentBodyBlocks(nodes)}\n`);
361
+ return;
362
+ }
328
363
  outputSuccess({
329
364
  data: nodes,
330
365
  meta: {
@@ -333,6 +368,23 @@ async function handleListComments(issueId, options, command) {
333
368
  },
334
369
  });
335
370
  }
371
+ async function handleReadComment(commentRef, options, command) {
372
+ const rootOpts = getRootOpts(command);
373
+ const graphQLService = await createGraphQLService(rootOpts);
374
+ const ref = normalizeCommentRef(commentRef);
375
+ const result = await graphQLService.rawRequest(GET_COMMENT_QUERY, ref);
376
+ if (!result.comment) {
377
+ throw new Error(`Comment "${commentRef}" not found`);
378
+ }
379
+ const comment = transformComment(result.comment, {
380
+ fullBodySummary: getOutputFormat() === "summary",
381
+ });
382
+ if (options.body === true) {
383
+ printRawCommentBody(comment, commentRef);
384
+ return;
385
+ }
386
+ outputSuccess(comment);
387
+ }
336
388
  async function handleDeleteComment(commentId, _options, command) {
337
389
  const rootOpts = getRootOpts(command);
338
390
  const graphQLService = await createGraphQLService(rootOpts);
@@ -374,7 +426,18 @@ export function setupCommentsCommands(program) {
374
426
  .description("List comments on an issue.")
375
427
  .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
376
428
  .option("-l, --limit <number>", "limit results", "25")
429
+ .option("--body", "print complete comment bodies as plain text blocks instead of JSON")
430
+ .option("--no-truncate", "do not truncate comment bodies in --format summary output")
377
431
  .action(handleAsyncCommand(handleListComments));
432
+ comments
433
+ .command("read <commentId>")
434
+ .alias("get")
435
+ .alias("show")
436
+ .description("Read a comment by id or Linear #comment-<hash> anchor.")
437
+ .addHelpText("after", "\nAccepts a full comment UUID, a comment-<hash> token, or a URL containing #comment-<hash>.")
438
+ .option("--body", "print the raw comment body with no JSON envelope")
439
+ .option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
440
+ .action(handleAsyncCommand(handleReadComment));
378
441
  comments
379
442
  .command("delete <commentId>")
380
443
  .alias("remove")
@@ -21,6 +21,7 @@ interface CommentUserRef {
21
21
  export interface CommentResourceNode {
22
22
  id: string;
23
23
  body: string;
24
+ url?: string | null;
24
25
  createdAt: string;
25
26
  updatedAt: string;
26
27
  user: CommentUserRef;
@@ -31,6 +32,12 @@ interface UpdatedCommentResourceNode extends CommentResourceNode {
31
32
  identifier: string;
32
33
  } | null;
33
34
  }
35
+ interface ReadCommentResourceNode extends CommentResourceNode {
36
+ issue: {
37
+ id: string;
38
+ identifier: string;
39
+ } | null;
40
+ }
34
41
  export interface ListCommentsResponse {
35
42
  issue: {
36
43
  id: string;
@@ -40,6 +47,9 @@ export interface ListCommentsResponse {
40
47
  };
41
48
  } | null;
42
49
  }
50
+ export interface GetCommentResponse {
51
+ comment: ReadCommentResourceNode | null;
52
+ }
43
53
  export interface CreateCommentResponse {
44
54
  commentCreate: {
45
55
  success: boolean;
@@ -1,4 +1,5 @@
1
1
  export declare const LIST_COMMENTS_QUERY = "\n query ListComments($issueId: String!, $first: Int) {\n issue(id: $issueId) {\n id\n identifier\n comments(first: $first, orderBy: createdAt) {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n }\n";
2
+ export declare const GET_COMMENT_QUERY = "\n query GetComment($id: String, $hash: String) {\n comment(id: $id, hash: $hash) {\n id\n body\n url\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n issue {\n id\n identifier\n }\n }\n }\n";
2
3
  export declare const CREATE_COMMENT_MUTATION = "\n mutation CreateComment($input: CommentCreateInput!) {\n commentCreate(input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n }\n }\n }\n";
3
4
  export declare const UPDATE_COMMENT_MUTATION = "\n mutation UpdateComment($id: String!, $input: CommentUpdateInput!) {\n commentUpdate(id: $id, input: $input) {\n success\n comment {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n displayName\n url\n }\n issue {\n id\n identifier\n }\n }\n }\n }\n";
4
5
  export declare const DELETE_COMMENT_MUTATION = "\n mutation DeleteComment($id: String!) {\n commentDelete(id: $id) {\n success\n }\n }\n";
@@ -20,6 +20,27 @@ export const LIST_COMMENTS_QUERY = `
20
20
  }
21
21
  }
22
22
  `;
23
+ export const GET_COMMENT_QUERY = `
24
+ query GetComment($id: String, $hash: String) {
25
+ comment(id: $id, hash: $hash) {
26
+ id
27
+ body
28
+ url
29
+ createdAt
30
+ updatedAt
31
+ user {
32
+ id
33
+ name
34
+ displayName
35
+ url
36
+ }
37
+ issue {
38
+ id
39
+ identifier
40
+ }
41
+ }
42
+ }
43
+ `;
23
44
  export const CREATE_COMMENT_MUTATION = `
24
45
  mutation CreateComment($input: CommentCreateInput!) {
25
46
  commentCreate(input: $input) {
@@ -567,8 +567,10 @@ export function formatProjectList(projects, fields) {
567
567
  export function formatCommentSummary(comment) {
568
568
  const author = getName(comment.user);
569
569
  const createdAt = s(comment.createdAt);
570
- const body = clipDescription(comment.body);
571
- const headerLine = `${author} ${createdAt}`;
570
+ const body = comment._summaryFullBody === true
571
+ ? s(comment.body)
572
+ : clipDescription(comment.body);
573
+ const headerLine = `comment ${s(comment.id)} ${author} ${createdAt}`;
572
574
  const parts = [headerLine];
573
575
  if (body)
574
576
  parts.push("", body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.32.1",
3
+ "version": "1.32.3",
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",