@enrichlayer/el-linear 1.21.0 → 1.23.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 +32 -0
- package/claude-skills/linear-operations/SKILL.md +41 -3
- package/dist/commands/comments.js +2 -0
- package/dist/commands/issues.js +45 -3
- package/dist/commands/projects.js +29 -1
- package/dist/commands/read-shortcut.js +30 -7
- package/dist/main.js +7 -1
- package/dist/queries/issues-types.d.ts +23 -0
- package/dist/queries/issues.d.ts +6 -0
- package/dist/queries/issues.js +22 -0
- package/dist/queries/projects-types.d.ts +18 -0
- package/dist/queries/projects.d.ts +9 -0
- package/dist/queries/projects.js +27 -0
- package/dist/utils/formatters/summary.d.ts +18 -0
- package/dist/utils/formatters/summary.js +30 -0
- package/dist/utils/graphql-issues-service.d.ts +29 -0
- package/dist/utils/graphql-issues-service.js +74 -8
- package/dist/utils/output.d.ts +6 -0
- package/dist/utils/output.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -553,6 +553,38 @@ el-linear read ADM-652 --field "Out of scope"
|
|
|
553
553
|
Single-issue only — pair it with `--jq` on full JSON for batch
|
|
554
554
|
extraction across many issues.
|
|
555
555
|
|
|
556
|
+
### Print the whole description as raw text: `--body`
|
|
557
|
+
|
|
558
|
+
`issues read --body` prints the issue's **entire** description as raw
|
|
559
|
+
markdown — real newlines, no JSON envelope — instead of one named
|
|
560
|
+
section. Single-issue only; exits 1 with a stderr hint when the issue has
|
|
561
|
+
no description. This is the canonical replacement for
|
|
562
|
+
`... --format json | python3 -c "...['description']"`.
|
|
563
|
+
|
|
564
|
+
```bash
|
|
565
|
+
el-linear issues read DEV-123 --body
|
|
566
|
+
# ## Why we need this
|
|
567
|
+
# ...full description body...
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Mutually exclusive with `--field` / `--sections` / `--with`.
|
|
571
|
+
|
|
572
|
+
### One-line write confirmations: `-q, --quiet`
|
|
573
|
+
|
|
574
|
+
`issues create|update` and `comments create|update` accept `-q, --quiet`,
|
|
575
|
+
printing a single confirmation line instead of the full JSON envelope so
|
|
576
|
+
you don't have to `grep` the result:
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
el-linear issues update DEV-123 --status "In Review" --quiet
|
|
580
|
+
# DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
|
|
581
|
+
|
|
582
|
+
el-linear comments create DEV-123 --body "..." --quiet
|
|
583
|
+
# comment 6f1c…
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
`--quiet` overrides `--format` and is independent of `--fields` / `--jq`.
|
|
587
|
+
|
|
556
588
|
### Render an issue's tree: `issues tree`
|
|
557
589
|
|
|
558
590
|
`issues tree <ID>` walks the parent → children graph for an issue in a
|
|
@@ -50,10 +50,20 @@ el-linear projects list --limit 50 2>&1 | head -100
|
|
|
50
50
|
# ❌ Don't reach for jq just to print title + state
|
|
51
51
|
el-linear issues read DEV-123 --jq '.title + " " + .state.name' 2>&1
|
|
52
52
|
|
|
53
|
+
# ❌ Don't pipe a read through python just to get the whole description body
|
|
54
|
+
el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
|
|
55
|
+
|
|
56
|
+
# ❌ Don't grep a write's JSON for the new state / url
|
|
57
|
+
el-linear issues update DEV-123 --status Done 2>&1 | grep -iE 'state|url'
|
|
58
|
+
|
|
53
59
|
# ✅ Just use --format summary
|
|
54
60
|
el-linear issues search "..." --limit 10 --format summary 2>&1
|
|
55
61
|
el-linear projects list --limit 50 --format summary 2>&1
|
|
56
62
|
el-linear issues read DEV-123 --format summary 2>&1
|
|
63
|
+
|
|
64
|
+
# ✅ Whole description as raw text → --body. Terse write confirmation → --quiet
|
|
65
|
+
el-linear issues read DEV-123 --body 2>&1
|
|
66
|
+
el-linear issues update DEV-123 --status Done --quiet 2>&1
|
|
57
67
|
```
|
|
58
68
|
|
|
59
69
|
The summary formatter exists exactly because every consumer (humans and LLMs) was reinventing the same `python -c` / `jq` extraction in shell. Pick the canonical path; the per-resource format is a stable contract.
|
|
@@ -72,6 +82,34 @@ el-linear issues read DEV-123 2>&1 | python3 -c "import json,sys; print(json.loa
|
|
|
72
82
|
|
|
73
83
|
`--field` is single-issue only — for batch extraction, fall back to `--jq` on the full JSON.
|
|
74
84
|
|
|
85
|
+
### The whole description as raw text: `--body`
|
|
86
|
+
|
|
87
|
+
When you want the **entire** description (not one section) as plain markdown — to read it, diff it, or pipe it to a file — use `issues read --body`. It prints the raw description with real newlines and no JSON envelope, single-issue only, and exits non-zero when the issue has no description. This is the canonical replacement for `... --format json | python3 -c "...['description']"` and the `sed 's/\\n/\n/g'` newline-unescaping hack.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# ✅ Full description, raw markdown, scriptable
|
|
91
|
+
el-linear issues read DEV-123 --body 2>&1
|
|
92
|
+
|
|
93
|
+
# ❌ Don't do this
|
|
94
|
+
el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`--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
|
+
|
|
99
|
+
### Terse write confirmations: `-q, --quiet`
|
|
100
|
+
|
|
101
|
+
`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:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
el-linear issues update DEV-123 --status "In Review" --quiet 2>&1
|
|
105
|
+
# DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
|
|
106
|
+
|
|
107
|
+
el-linear comments create DEV-123 --body "..." --quiet 2>&1
|
|
108
|
+
# comment <id>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`--quiet` overrides `--format` (it's the whole point) and is independent of `--fields`/`--jq`.
|
|
112
|
+
|
|
75
113
|
### When you must reach outside el-linear: prefer `jq` over `python3 -c`
|
|
76
114
|
|
|
77
115
|
For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` for JSON extraction in one-shot shell commands. `python3 -c "import json,sys; ..."` produces longer, harder-to-read pipelines, and tends to attract incremental complexity (try/except, fallbacks) that `jq` handles inline. Reach for python only when the transformation genuinely needs control flow that's painful in `jq` (e.g. multi-step assembly with intermediate state).
|
|
@@ -201,11 +239,11 @@ el-linear issues link-references ENG-123 --dry-run # preview
|
|
|
201
239
|
|
|
202
240
|
When starting work on an existing issue (`el-linear issues read ENG-123`):
|
|
203
241
|
|
|
204
|
-
- [ ] **
|
|
242
|
+
- [ ] **Branch claim path used** — `el-linear issues create --checkout` and `el-linear issues mark-branch ENG-123` automatically claim the issue by assigning it to the current Linear user and moving it to the team's first started state. Use `--no-claim` only when intentionally creating/marking a branch on someone else's behalf.
|
|
205
243
|
- [ ] **Project set** — if missing, ask user and update: `el-linear issues update ENG-123 --project "<name>"`.
|
|
206
|
-
- [ ] **Status appropriate** — move to "In Progress" or your team's equivalent.
|
|
244
|
+
- [ ] **Status appropriate** — if you did not use one of the branch claim paths, move the issue to "In Progress" or your team's equivalent.
|
|
207
245
|
|
|
208
|
-
Don't start implementation work on an unassigned issue
|
|
246
|
+
Don't start implementation work on an unassigned issue — the assignee is the person accountable. If you did not use one of the branch claim paths (or used `--no-claim`), assign the issue before proceeding.
|
|
209
247
|
|
|
210
248
|
---
|
|
211
249
|
|
|
@@ -291,6 +291,7 @@ export function setupCommentsCommands(program) {
|
|
|
291
291
|
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
292
292
|
.option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
|
|
293
293
|
.option("--no-footer", "skip the configured messageFooter for this comment")
|
|
294
|
+
.option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
|
|
294
295
|
.action(handleAsyncCommand(handleCreateComment));
|
|
295
296
|
comments
|
|
296
297
|
.command("update <commentId>")
|
|
@@ -299,6 +300,7 @@ export function setupCommentsCommands(program) {
|
|
|
299
300
|
.option("--body-file <path>", "read new comment body from file")
|
|
300
301
|
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
301
302
|
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
303
|
+
.option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
|
|
302
304
|
.action(handleAsyncCommand(handleUpdateComment));
|
|
303
305
|
comments
|
|
304
306
|
.command("list <issueId>")
|
package/dist/commands/issues.js
CHANGED
|
@@ -37,6 +37,24 @@ function isImageFile(filename) {
|
|
|
37
37
|
const ext = filename.lastIndexOf(".");
|
|
38
38
|
return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
|
|
39
39
|
}
|
|
40
|
+
function errorMessage(error) {
|
|
41
|
+
return error instanceof Error ? error.message : String(error);
|
|
42
|
+
}
|
|
43
|
+
function warnClaimFailure(issueId, error) {
|
|
44
|
+
outputWarning(`Branch operation succeeded for ${issueId}, but auto-claim failed: ${errorMessage(error)}`);
|
|
45
|
+
}
|
|
46
|
+
async function maybeClaimBranchIssue(issueId, options, issuesService) {
|
|
47
|
+
if (options.claim === false) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return await issuesService.claimIssue(issueId);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
warnClaimFailure(issueId, error);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
40
58
|
function validateUpdateOptions(options) {
|
|
41
59
|
if (options.delegate && options.clearDelegate) {
|
|
42
60
|
throw new Error("Cannot use --delegate and --clear-delegate together");
|
|
@@ -497,6 +515,7 @@ async function handleCreateIssue(title, options, command) {
|
|
|
497
515
|
}
|
|
498
516
|
}
|
|
499
517
|
let branch;
|
|
518
|
+
let claim;
|
|
500
519
|
if (options.checkout && result.branchName) {
|
|
501
520
|
const branchName = toBranchName(result.branchName);
|
|
502
521
|
// Only treat the branch as created (and surface it in the output) when
|
|
@@ -516,11 +535,13 @@ async function handleCreateIssue(title, options, command) {
|
|
|
516
535
|
outputWarning(`Created branch ${branch} but could not record its Linear issue marker. ` +
|
|
517
536
|
`Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
|
|
518
537
|
}
|
|
538
|
+
claim = await maybeClaimBranchIssue(result.identifier, options, issuesService);
|
|
519
539
|
}
|
|
520
540
|
}
|
|
521
541
|
const output = {
|
|
522
542
|
...result,
|
|
523
543
|
...(branch ? { branch } : {}),
|
|
544
|
+
...(claim ? { claim } : {}),
|
|
524
545
|
...(relations.length > 0 ? { relations } : {}),
|
|
525
546
|
...(autoLinked ? { autoLinked } : {}),
|
|
526
547
|
...(attachments.length === 1
|
|
@@ -852,7 +873,7 @@ async function handleRetrolink(options, command) {
|
|
|
852
873
|
...(newBranch ? { branch: newBranch } : {}),
|
|
853
874
|
});
|
|
854
875
|
}
|
|
855
|
-
async function handleMarkBranch(issueId,
|
|
876
|
+
async function handleMarkBranch(issueId, options, command) {
|
|
856
877
|
const branch = currentGitBranch();
|
|
857
878
|
if (!branch) {
|
|
858
879
|
throw new Error("Not on a named git branch (detached HEAD or not in a repository).");
|
|
@@ -882,10 +903,22 @@ async function handleMarkBranch(issueId, _options, _command) {
|
|
|
882
903
|
}
|
|
883
904
|
const previous = getBranchLinearIssue(branch);
|
|
884
905
|
setBranchLinearIssue(branch, identifier);
|
|
906
|
+
let claim;
|
|
907
|
+
if (options.claim !== false) {
|
|
908
|
+
try {
|
|
909
|
+
const rootOpts = getRootOpts(command);
|
|
910
|
+
const { issuesService } = await createIssuesService(rootOpts);
|
|
911
|
+
claim = await issuesService.claimIssue(identifier);
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
warnClaimFailure(identifier, error);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
885
917
|
outputSuccess({
|
|
886
918
|
branch,
|
|
887
919
|
linearIssue: identifier,
|
|
888
920
|
...(previous && previous !== identifier ? { previous } : {}),
|
|
921
|
+
...(claim ? { claim } : {}),
|
|
889
922
|
marked: true,
|
|
890
923
|
});
|
|
891
924
|
}
|
|
@@ -965,10 +998,12 @@ export function setupIssuesCommands(program) {
|
|
|
965
998
|
.option("--attachment <path>", "attach a file (image, PDF, etc.) to the created issue (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
|
|
966
999
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
967
1000
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1001
|
+
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
968
1002
|
.option("--skip-validation", "skip all validation (labels, description, assignee, project)")
|
|
969
1003
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
970
1004
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
971
1005
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
|
1006
|
+
.option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
|
|
972
1007
|
.action(handleAsyncCommand((titleArg, options, command) => {
|
|
973
1008
|
// Normalize --label alias to --labels
|
|
974
1009
|
if (options.label && !options.labels) {
|
|
@@ -994,6 +1029,10 @@ export function setupIssuesCommands(program) {
|
|
|
994
1029
|
.alias("get")
|
|
995
1030
|
.alias("show")
|
|
996
1031
|
.description("Get issue details. Accepts multiple IDs for batch retrieval.")
|
|
1032
|
+
.option("--body", "Print the issue's full description as raw markdown text — no JSON " +
|
|
1033
|
+
"envelope, single-issue only. Exits non-zero if the issue has no " +
|
|
1034
|
+
"description. The whole-body sibling of --field; mutually exclusive " +
|
|
1035
|
+
"with --field / --sections / --with.")
|
|
997
1036
|
.option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
|
|
998
1037
|
"Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
|
|
999
1038
|
"Outputs the section text only — no JSON envelope. Single-issue only.")
|
|
@@ -1003,7 +1042,7 @@ export function setupIssuesCommands(program) {
|
|
|
1003
1042
|
.option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
|
|
1004
1043
|
'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
|
|
1005
1044
|
"(adds an array of cross-issue relations under a top-level `relations` key).")
|
|
1006
|
-
.addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"\nWith relations: el-linear issue read DEV-123 --with relations')
|
|
1045
|
+
.addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nFull description: el-linear issue read DEV-123 --body\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"\nWith relations: el-linear issue read DEV-123 --with relations')
|
|
1007
1046
|
.action(handleAsyncCommand(readIssues));
|
|
1008
1047
|
issues
|
|
1009
1048
|
.command("update <issueId>")
|
|
@@ -1041,6 +1080,7 @@ export function setupIssuesCommands(program) {
|
|
|
1041
1080
|
.option("--blocks <issues>", "issues this blocks (comma-separated)")
|
|
1042
1081
|
.option("--blocked-by <issues>", "issues blocking this (comma-separated)")
|
|
1043
1082
|
.option("--duplicate-of <issue>", "mark as duplicate of another issue")
|
|
1083
|
+
.option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
|
|
1044
1084
|
.action(handleAsyncCommand(handleUpdateIssue));
|
|
1045
1085
|
issues
|
|
1046
1086
|
.command("archive <issueId>")
|
|
@@ -1106,6 +1146,8 @@ export function setupIssuesCommands(program) {
|
|
|
1106
1146
|
.description("Record the current branch's Linear issue as git metadata (branch.<branch>.linearIssue).")
|
|
1107
1147
|
.addHelpText("after", "\nManual recovery for branches not created via 'issues create --checkout' / 'retrolink'.\n" +
|
|
1108
1148
|
"With no argument, infers the identifier from the branch name (e.g. dev-4293-slug -> DEV-4293).\n" +
|
|
1109
|
-
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL
|
|
1149
|
+
"Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
|
|
1150
|
+
"By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
|
|
1151
|
+
.option("--no-claim", "only record git metadata; skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1110
1152
|
.action(handleAsyncCommand(handleMarkBranch));
|
|
1111
1153
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../config/config.js";
|
|
2
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
3
|
-
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
3
|
+
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
4
|
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
5
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
6
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
@@ -413,6 +413,30 @@ async function handleDeleteProject(projectNameOrId, _options, command) {
|
|
|
413
413
|
lastSyncId: payload.lastSyncId,
|
|
414
414
|
});
|
|
415
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Read a single project (DEV-4610). Resolves the identifier through the same
|
|
418
|
+
* `resolveProjectId` path the `--project` flags use (UUID / slug / URL / name),
|
|
419
|
+
* fetches the full project, and emits it. `teams.nodes` is flattened to a plain
|
|
420
|
+
* array so the `--format summary` project renderer (and the JSON shape) match
|
|
421
|
+
* `projects list` — the formatter expects a flat team list, not a `{ nodes }`
|
|
422
|
+
* connection. Replaces the raw-`graphql` workaround for grabbing a project's
|
|
423
|
+
* url / content / lead.
|
|
424
|
+
*/
|
|
425
|
+
async function handleReadProject(projectNameOrId, _options, command) {
|
|
426
|
+
const rootOpts = getRootOpts(command);
|
|
427
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
428
|
+
const linearService = await createLinearService(rootOpts);
|
|
429
|
+
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
430
|
+
const result = await graphQLService.rawRequest(PROJECT_READ_QUERY, { id: projectId });
|
|
431
|
+
if (!result.project) {
|
|
432
|
+
throw new Error(`Project "${projectNameOrId}" not found`);
|
|
433
|
+
}
|
|
434
|
+
const { teams, ...rest } = result.project;
|
|
435
|
+
outputSuccess({
|
|
436
|
+
...rest,
|
|
437
|
+
teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
416
440
|
export function setupProjectsCommands(program) {
|
|
417
441
|
const projects = program
|
|
418
442
|
.command("projects")
|
|
@@ -435,6 +459,10 @@ export function setupProjectsCommands(program) {
|
|
|
435
459
|
.command("delete <project>")
|
|
436
460
|
.description("Delete (trash) a project (resolves names)")
|
|
437
461
|
.action(handleAsyncCommand(handleDeleteProject));
|
|
462
|
+
projects
|
|
463
|
+
.command("read <project>")
|
|
464
|
+
.description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
|
|
465
|
+
.action(handleAsyncCommand(handleReadProject));
|
|
438
466
|
projects
|
|
439
467
|
.command("list")
|
|
440
468
|
.description("List projects")
|
|
@@ -23,6 +23,10 @@ export function setupReadShortcut(program) {
|
|
|
23
23
|
.alias("view")
|
|
24
24
|
.alias("show")
|
|
25
25
|
.description("Shortcut for `issues read`. Get issue details by identifier.")
|
|
26
|
+
.option("--body", "Print the issue's full description as raw markdown text — no JSON " +
|
|
27
|
+
"envelope, single-issue only. Exits non-zero if the issue has no " +
|
|
28
|
+
"description. The whole-body sibling of --field; mutually exclusive " +
|
|
29
|
+
"with --field / --sections / --with.")
|
|
26
30
|
.option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
|
|
27
31
|
"Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
|
|
28
32
|
"Outputs the section text only — no JSON envelope.")
|
|
@@ -32,7 +36,7 @@ export function setupReadShortcut(program) {
|
|
|
32
36
|
.option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
|
|
33
37
|
'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
|
|
34
38
|
"(adds an array of cross-issue relations under a top-level `relations` key).")
|
|
35
|
-
.addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
|
|
39
|
+
.addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --body (full description, raw text)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
|
|
36
40
|
.action(handleAsyncCommand(readIssues));
|
|
37
41
|
// Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
|
|
38
42
|
const originalParse = program.parse.bind(program);
|
|
@@ -70,6 +74,7 @@ export async function readIssues(issueIds, options, command) {
|
|
|
70
74
|
const { graphQLService, issuesService } = await createIssuesService(rootOpts);
|
|
71
75
|
const fileService = await createFileService(rootOpts);
|
|
72
76
|
const fieldName = typeof options.field === "string" ? options.field : null;
|
|
77
|
+
const bodyOnly = options.body === true;
|
|
73
78
|
const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
|
|
74
79
|
// DEV-4476: --with opt-in includes (currently `relations`). Throws on
|
|
75
80
|
// unknown values via parseWithIncludes — fail fast in the CLI per the
|
|
@@ -80,12 +85,19 @@ export async function readIssues(issueIds, options, command) {
|
|
|
80
85
|
if (fieldName && sectionsRaw) {
|
|
81
86
|
throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
|
|
82
87
|
}
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
if ((fieldName || sectionsRaw
|
|
87
|
-
throw new Error("--
|
|
88
|
-
"
|
|
88
|
+
// --body prints the WHOLE description as raw text (no envelope) — it's the
|
|
89
|
+
// "all sections" sibling of --field. Pairing it with section extraction or
|
|
90
|
+
// --with would produce two conflicting output shapes, so reject up front.
|
|
91
|
+
if (bodyOnly && (fieldName || sectionsRaw || includes.relations)) {
|
|
92
|
+
throw new Error("--body is mutually exclusive with --field / --sections / --with. " +
|
|
93
|
+
"Use --body for the entire description, --field/--sections for named parts.");
|
|
94
|
+
}
|
|
95
|
+
// --field / --sections / --body are single-issue only — raw text extraction
|
|
96
|
+
// can't sensibly fan out to N different bodies; the caller almost always
|
|
97
|
+
// wants the named sections (or full body) of one issue.
|
|
98
|
+
if ((fieldName || sectionsRaw || bodyOnly) && issueIds.length > 1) {
|
|
99
|
+
throw new Error("--field / --sections / --body are single-issue only; pass exactly one issueId. " +
|
|
100
|
+
"For multiple issues, drop those flags and use --jq or --format summary.");
|
|
89
101
|
}
|
|
90
102
|
// Parse the comma-separated --sections list once. Preserve the caller's
|
|
91
103
|
// order, trim each entry, and drop empties so trailing commas don't
|
|
@@ -108,6 +120,17 @@ export async function readIssues(issueIds, options, command) {
|
|
|
108
120
|
if (issueIds.length === 1) {
|
|
109
121
|
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
110
122
|
const resolved = await downloadLinearUploads(issue, fileService);
|
|
123
|
+
if (bodyOnly) {
|
|
124
|
+
const description = resolved.description ?? "";
|
|
125
|
+
if (description.trim() === "") {
|
|
126
|
+
// Mirror --field's "not found" contract: nothing on stdout,
|
|
127
|
+
// exit non-zero with a stderr hint so scripts can branch.
|
|
128
|
+
process.stderr.write(`el-linear: ${resolved.identifier} has no description\n`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
process.stdout.write(`${description}\n`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
111
134
|
if (fieldName) {
|
|
112
135
|
const section = extractField(resolved.description ?? "", fieldName);
|
|
113
136
|
if (section === null) {
|
package/dist/main.js
CHANGED
|
@@ -31,7 +31,7 @@ import { setActiveProfileForSession } from "./config/paths.js";
|
|
|
31
31
|
import { initCliSentry } from "./sentry.js";
|
|
32
32
|
import { logger } from "./utils/logger.js";
|
|
33
33
|
import { applyIpv4Preference } from "./utils/network-preference.js";
|
|
34
|
-
import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
|
|
34
|
+
import { setFieldsFilter, setJqFilter, setOutputFormat, setQuietMode, setRawMode, } from "./utils/output.js";
|
|
35
35
|
import { outputUsageInfo } from "./utils/usage.js";
|
|
36
36
|
import { splitList } from "./utils/validators.js";
|
|
37
37
|
// Prefer IPv4 for outbound API calls before any network I/O (Sentry init or a
|
|
@@ -79,6 +79,12 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
|
79
79
|
if (rootOpts.fields) {
|
|
80
80
|
setFieldsFilter(splitList(rootOpts.fields));
|
|
81
81
|
}
|
|
82
|
+
// --quiet is registered only on the write commands (issues/comments
|
|
83
|
+
// create|update); it surfaces here via optsWithGlobals. One confirmation
|
|
84
|
+
// line instead of the full envelope — see setQuietMode.
|
|
85
|
+
if (rootOpts.quiet) {
|
|
86
|
+
setQuietMode(true);
|
|
87
|
+
}
|
|
82
88
|
// Subcommand --format wins over the global flag (commander resolves
|
|
83
89
|
// options closest to the action), so `optsWithGlobals` returns the
|
|
84
90
|
// subcommand value when one is set. We accept any of the per-command
|
|
@@ -214,6 +214,29 @@ export interface IssueStartContextResponse {
|
|
|
214
214
|
delegate: AssigneeNode | null;
|
|
215
215
|
} | null;
|
|
216
216
|
}
|
|
217
|
+
export interface IssueClaimContextResponse {
|
|
218
|
+
viewer: {
|
|
219
|
+
id: string;
|
|
220
|
+
name: string;
|
|
221
|
+
displayName: string;
|
|
222
|
+
email: string;
|
|
223
|
+
} | null;
|
|
224
|
+
issue: {
|
|
225
|
+
id: string;
|
|
226
|
+
identifier: string;
|
|
227
|
+
state: {
|
|
228
|
+
id: string;
|
|
229
|
+
name: string;
|
|
230
|
+
type: WorkflowStateType;
|
|
231
|
+
} | null;
|
|
232
|
+
assignee: AssigneeNode | null;
|
|
233
|
+
team: {
|
|
234
|
+
id: string;
|
|
235
|
+
key: string;
|
|
236
|
+
name: string;
|
|
237
|
+
} | null;
|
|
238
|
+
} | null;
|
|
239
|
+
}
|
|
217
240
|
export interface TeamStartedStatusesResponse {
|
|
218
241
|
team: {
|
|
219
242
|
states: {
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -80,6 +80,12 @@ export declare function buildResolveLabelsByNameQuery(labelNames: string[]): {
|
|
|
80
80
|
export declare const GET_ISSUE_STATE_HISTORY_QUERY = "\n query GetIssueStateHistory($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n stateHistory {\n nodes {\n state { id name type }\n startedAt\n endedAt\n }\n }\n }\n }\n";
|
|
81
81
|
export declare const GET_ISSUE_TEAM_QUERY = "\n query GetIssueTeam($issueId: String!) {\n issue(id: $issueId) {\n team { id }\n }\n }\n";
|
|
82
82
|
export declare const GET_ISSUE_START_CONTEXT_QUERY = "\n query GetIssueStartContext($id: String!) {\n issue(id: $id) {\n id\n identifier\n state { id name type }\n team { id key name }\n delegate { id name url }\n }\n }\n";
|
|
83
|
+
/**
|
|
84
|
+
* Batch issue lifecycle/assignee context with the authenticated viewer for the
|
|
85
|
+
* branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
|
|
86
|
+
* issue fields in one round-trip; the SDK would make separate calls.
|
|
87
|
+
*/
|
|
88
|
+
export declare const GET_ISSUE_CLAIM_CONTEXT_QUERY = "\n query GetIssueClaimContext($id: String!) {\n viewer {\n id\n name\n displayName\n email\n }\n issue(id: $id) {\n id\n identifier\n state { id name type }\n assignee { id name url }\n team { id key name }\n }\n }\n";
|
|
83
89
|
export declare const TEAM_STARTED_STATUSES_QUERY = "\n query TeamStartedStatuses($teamId: String!) {\n team(id: $teamId) {\n states(filter: { type: { eq: \"started\" } }) {\n nodes {\n id\n name\n position\n }\n }\n }\n }\n";
|
|
84
90
|
export declare const ISSUE_RELATION_CREATE_MUTATION = "\n mutation IssueRelationCreate($input: IssueRelationCreateInput!) {\n issueRelationCreate(input: $input) {\n success\n issueRelation {\n id\n type\n issue {\n id\n identifier\n title\n }\n relatedIssue {\n id\n identifier\n title\n }\n }\n }\n }\n";
|
|
85
91
|
export declare const GET_ISSUE_RELATIONS_QUERY = "\n query GetIssueRelations($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n relations {\n nodes {\n id\n type\n relatedIssue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n inverseRelations {\n nodes {\n id\n type\n issue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n }\n }\n";
|
package/dist/queries/issues.js
CHANGED
|
@@ -472,6 +472,28 @@ export const GET_ISSUE_START_CONTEXT_QUERY = `
|
|
|
472
472
|
}
|
|
473
473
|
}
|
|
474
474
|
`;
|
|
475
|
+
/**
|
|
476
|
+
* Batch issue lifecycle/assignee context with the authenticated viewer for the
|
|
477
|
+
* branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
|
|
478
|
+
* issue fields in one round-trip; the SDK would make separate calls.
|
|
479
|
+
*/
|
|
480
|
+
export const GET_ISSUE_CLAIM_CONTEXT_QUERY = `
|
|
481
|
+
query GetIssueClaimContext($id: String!) {
|
|
482
|
+
viewer {
|
|
483
|
+
id
|
|
484
|
+
name
|
|
485
|
+
displayName
|
|
486
|
+
email
|
|
487
|
+
}
|
|
488
|
+
issue(id: $id) {
|
|
489
|
+
id
|
|
490
|
+
identifier
|
|
491
|
+
state { id name type }
|
|
492
|
+
assignee { id name url }
|
|
493
|
+
team { id key name }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
`;
|
|
475
497
|
export const TEAM_STARTED_STATUSES_QUERY = `
|
|
476
498
|
query TeamStartedStatuses($teamId: String!) {
|
|
477
499
|
team(id: $teamId) {
|
|
@@ -34,6 +34,24 @@ interface ProjectWithIssuesNode extends ProjectBaseNode {
|
|
|
34
34
|
export interface ProjectByIdResponse {
|
|
35
35
|
project: ProjectBaseNode | null;
|
|
36
36
|
}
|
|
37
|
+
/** Full project read (`PROJECT_READ_QUERY`) — base fields + summary/JSON fields. */
|
|
38
|
+
interface ProjectReadNode extends ProjectBaseNode {
|
|
39
|
+
state: string;
|
|
40
|
+
progress: number;
|
|
41
|
+
url: string;
|
|
42
|
+
startDate: string | null;
|
|
43
|
+
targetDate: string | null;
|
|
44
|
+
description: string | null;
|
|
45
|
+
content: string | null;
|
|
46
|
+
lead: {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
displayName: string;
|
|
50
|
+
} | null;
|
|
51
|
+
}
|
|
52
|
+
export interface ProjectReadResponse {
|
|
53
|
+
project: ProjectReadNode | null;
|
|
54
|
+
}
|
|
37
55
|
export interface GetProjectResponse {
|
|
38
56
|
projects: {
|
|
39
57
|
nodes: ProjectBaseNode[];
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export declare const TEAM_LOOKUP_QUERY = "\n query TeamLookup($key: String) {\n teams(filter: { or: [{ key: { eq: $key } }, { name: { eq: $key } }] }, first: 1) {\n nodes { id key name }\n }\n }\n";
|
|
2
2
|
export declare const PROJECT_BY_ID_QUERY = "\n query ProjectById($id: String!) {\n project(id: $id) {\n id\n name\n teams {\n nodes { id key name }\n }\n }\n }\n";
|
|
3
|
+
/**
|
|
4
|
+
* Full single-project read for `el-linear projects read` (DEV-4610). Carries
|
|
5
|
+
* everything the `--format summary` project renderer shows (name, state, lead,
|
|
6
|
+
* teams, target, progress, url) plus the long-form `description`/`content` the
|
|
7
|
+
* JSON envelope exposes — so callers no longer fall back to a raw `graphql`
|
|
8
|
+
* query for a project URL or its content. The `progress` + `teams` fields also
|
|
9
|
+
* make the payload self-describing to the summary kind-inference.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PROJECT_READ_QUERY = "\n query ProjectRead($id: String!) {\n project(id: $id) {\n id\n name\n state\n progress\n url\n startDate\n targetDate\n description\n content\n lead { id name displayName }\n teams {\n nodes { id key name }\n }\n }\n }\n";
|
|
3
12
|
export declare const GET_PROJECT_QUERY = "\n query GetProject($name: String!) {\n projects(filter: { name: { eqIgnoreCase: $name } }, first: 1) {\n nodes {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
4
13
|
export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIssues($projectId: String!, $teamId: String!) {\n project(id: $projectId) {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n issues(filter: { team: { id: { eq: $teamId } } }, first: 50) {\n nodes {\n id\n identifier\n title\n }\n }\n }\n }\n";
|
|
5
14
|
export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
package/dist/queries/projects.js
CHANGED
|
@@ -16,6 +16,33 @@ export const PROJECT_BY_ID_QUERY = `
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
`;
|
|
19
|
+
/**
|
|
20
|
+
* Full single-project read for `el-linear projects read` (DEV-4610). Carries
|
|
21
|
+
* everything the `--format summary` project renderer shows (name, state, lead,
|
|
22
|
+
* teams, target, progress, url) plus the long-form `description`/`content` the
|
|
23
|
+
* JSON envelope exposes — so callers no longer fall back to a raw `graphql`
|
|
24
|
+
* query for a project URL or its content. The `progress` + `teams` fields also
|
|
25
|
+
* make the payload self-describing to the summary kind-inference.
|
|
26
|
+
*/
|
|
27
|
+
export const PROJECT_READ_QUERY = `
|
|
28
|
+
query ProjectRead($id: String!) {
|
|
29
|
+
project(id: $id) {
|
|
30
|
+
id
|
|
31
|
+
name
|
|
32
|
+
state
|
|
33
|
+
progress
|
|
34
|
+
url
|
|
35
|
+
startDate
|
|
36
|
+
targetDate
|
|
37
|
+
description
|
|
38
|
+
content
|
|
39
|
+
lead { id name displayName }
|
|
40
|
+
teams {
|
|
41
|
+
nodes { id key name }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
19
46
|
export const GET_PROJECT_QUERY = `
|
|
20
47
|
query GetProject($name: String!) {
|
|
21
48
|
projects(filter: { name: { eqIgnoreCase: $name } }, first: 1) {
|
|
@@ -60,3 +60,21 @@ export declare function inferKindFromPayload(value: unknown): ResourceKind;
|
|
|
60
60
|
* array or a `{ data: [...] }` envelope — both are handled.
|
|
61
61
|
*/
|
|
62
62
|
export declare function dispatch(kind: ResourceKind, payload: unknown): string;
|
|
63
|
+
/**
|
|
64
|
+
* One-line confirmation render for the `--quiet` write path.
|
|
65
|
+
*
|
|
66
|
+
* Writes (`issues create|update`, `comments create|update`) otherwise emit
|
|
67
|
+
* the full JSON envelope; agents then `grep` it for the identifier / state /
|
|
68
|
+
* url. `--quiet` routes the same payload here so the caller gets exactly one
|
|
69
|
+
* machine-stable line and nothing else.
|
|
70
|
+
*
|
|
71
|
+
* - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
|
|
72
|
+
* summary header style). Empty fields collapse to `-`.
|
|
73
|
+
* - comment → `comment <id>` (the create/update mutation doesn't fetch a url
|
|
74
|
+
* or the parent identifier, so the id — what you need to edit or
|
|
75
|
+
* reference the comment — is the stable handle).
|
|
76
|
+
* - anything else → compact single-line JSON, so the contract ("one line,
|
|
77
|
+
* always parseable") holds even for payloads with no dedicated
|
|
78
|
+
* shape.
|
|
79
|
+
*/
|
|
80
|
+
export declare function formatLine(payload: unknown): string;
|
|
@@ -758,3 +758,33 @@ export function dispatch(kind, payload) {
|
|
|
758
758
|
return formatGenericSummary(payload);
|
|
759
759
|
}
|
|
760
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* One-line confirmation render for the `--quiet` write path.
|
|
763
|
+
*
|
|
764
|
+
* Writes (`issues create|update`, `comments create|update`) otherwise emit
|
|
765
|
+
* the full JSON envelope; agents then `grep` it for the identifier / state /
|
|
766
|
+
* url. `--quiet` routes the same payload here so the caller gets exactly one
|
|
767
|
+
* machine-stable line and nothing else.
|
|
768
|
+
*
|
|
769
|
+
* - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
|
|
770
|
+
* summary header style). Empty fields collapse to `-`.
|
|
771
|
+
* - comment → `comment <id>` (the create/update mutation doesn't fetch a url
|
|
772
|
+
* or the parent identifier, so the id — what you need to edit or
|
|
773
|
+
* reference the comment — is the stable handle).
|
|
774
|
+
* - anything else → compact single-line JSON, so the contract ("one line,
|
|
775
|
+
* always parseable") holds even for payloads with no dedicated
|
|
776
|
+
* shape.
|
|
777
|
+
*/
|
|
778
|
+
export function formatLine(payload) {
|
|
779
|
+
const kind = inferKindFromPayload(payload);
|
|
780
|
+
const obj = asObj(payload);
|
|
781
|
+
if (kind === "issue" && obj) {
|
|
782
|
+
// s()/getName() already render missing values as the em-dash placeholder
|
|
783
|
+
// used throughout the summary formatter, keeping --quiet consistent.
|
|
784
|
+
return `${s(obj.identifier)} ${getName(obj.state)} ${s(obj.url)}`;
|
|
785
|
+
}
|
|
786
|
+
if (kind === "comment" && obj) {
|
|
787
|
+
return `comment ${s(obj.id)}`;
|
|
788
|
+
}
|
|
789
|
+
return JSON.stringify(payload);
|
|
790
|
+
}
|
|
@@ -151,6 +151,33 @@ export interface StartIssueResult {
|
|
|
151
151
|
name: string;
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
|
+
export interface ClaimIssueResult {
|
|
155
|
+
issue: LinearIssue;
|
|
156
|
+
claimed: boolean;
|
|
157
|
+
/** True when the issue was already started and assigned to the viewer, so the claim was a no-op. */
|
|
158
|
+
alreadyClaimed: boolean;
|
|
159
|
+
assigned: boolean;
|
|
160
|
+
started: boolean;
|
|
161
|
+
assignee: {
|
|
162
|
+
id: string;
|
|
163
|
+
name: string;
|
|
164
|
+
displayName: string;
|
|
165
|
+
email: string;
|
|
166
|
+
};
|
|
167
|
+
previousAssignee?: {
|
|
168
|
+
id: string;
|
|
169
|
+
name: string;
|
|
170
|
+
};
|
|
171
|
+
previousState?: {
|
|
172
|
+
id: string;
|
|
173
|
+
name: string;
|
|
174
|
+
type: string;
|
|
175
|
+
};
|
|
176
|
+
targetState?: {
|
|
177
|
+
id: string;
|
|
178
|
+
name: string;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
154
181
|
export declare class GraphQLIssuesService {
|
|
155
182
|
private readonly graphQLService;
|
|
156
183
|
private readonly linearService;
|
|
@@ -175,7 +202,9 @@ export declare class GraphQLIssuesService {
|
|
|
175
202
|
* for the read handler — the JSON envelope is a positional array.
|
|
176
203
|
*/
|
|
177
204
|
getIssuesByRefs(refs: string[]): Promise<LinearIssue[]>;
|
|
205
|
+
private getFirstStartedStatus;
|
|
178
206
|
startIssue(issueId: string): Promise<StartIssueResult>;
|
|
207
|
+
claimIssue(issueId: string): Promise<ClaimIssueResult>;
|
|
179
208
|
updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
|
|
180
209
|
archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
|
|
181
210
|
deleteIssue(issueId: string, options?: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
2
|
-
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
2
|
+
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
3
3
|
import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
|
|
4
4
|
import { toISOStringOrNow } from "./date-format.js";
|
|
5
5
|
import { extractEmbeds } from "./embed-parser.js";
|
|
@@ -150,6 +150,16 @@ export class GraphQLIssuesService {
|
|
|
150
150
|
}
|
|
151
151
|
return ordered.map((issue) => this.transformIssueData(issue));
|
|
152
152
|
}
|
|
153
|
+
async getFirstStartedStatus(team) {
|
|
154
|
+
const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: team.id });
|
|
155
|
+
const startedStatus = statuses.team?.states.nodes
|
|
156
|
+
.slice()
|
|
157
|
+
.sort((a, b) => a.position - b.position)[0];
|
|
158
|
+
if (!startedStatus) {
|
|
159
|
+
throw new Error(`Team ${team.key} has no workflow status of type "started".`);
|
|
160
|
+
}
|
|
161
|
+
return { id: startedStatus.id, name: startedStatus.name };
|
|
162
|
+
}
|
|
153
163
|
async startIssue(issueId) {
|
|
154
164
|
const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
|
|
155
165
|
const context = await this.graphQLService.rawRequest(GET_ISSUE_START_CONTEXT_QUERY, { id: resolvedIssueId });
|
|
@@ -175,13 +185,7 @@ export class GraphQLIssuesService {
|
|
|
175
185
|
if (!issue.team?.id) {
|
|
176
186
|
throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
|
|
177
187
|
}
|
|
178
|
-
const
|
|
179
|
-
const startedStatus = statuses.team?.states.nodes
|
|
180
|
-
.slice()
|
|
181
|
-
.sort((a, b) => a.position - b.position)[0];
|
|
182
|
-
if (!startedStatus) {
|
|
183
|
-
throw new Error(`Team ${issue.team.key} has no workflow status of type "started".`);
|
|
184
|
-
}
|
|
188
|
+
const startedStatus = await this.getFirstStartedStatus(issue.team);
|
|
185
189
|
const updated = await this.updateIssue({ id: resolvedIssueId, statusId: startedStatus.id }, "adding");
|
|
186
190
|
return {
|
|
187
191
|
issue: updated,
|
|
@@ -190,6 +194,68 @@ export class GraphQLIssuesService {
|
|
|
190
194
|
targetState: { id: startedStatus.id, name: startedStatus.name },
|
|
191
195
|
};
|
|
192
196
|
}
|
|
197
|
+
async claimIssue(issueId) {
|
|
198
|
+
const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
|
|
199
|
+
const context = await this.graphQLService.rawRequest(GET_ISSUE_CLAIM_CONTEXT_QUERY, { id: resolvedIssueId });
|
|
200
|
+
const issue = context.issue;
|
|
201
|
+
if (!issue) {
|
|
202
|
+
throw notFoundError("Issue", issueId);
|
|
203
|
+
}
|
|
204
|
+
const viewer = context.viewer;
|
|
205
|
+
if (!viewer?.id) {
|
|
206
|
+
throw new Error("Could not resolve the authenticated Linear viewer.");
|
|
207
|
+
}
|
|
208
|
+
const previousState = issue.state
|
|
209
|
+
? {
|
|
210
|
+
id: issue.state.id,
|
|
211
|
+
name: issue.state.name,
|
|
212
|
+
type: issue.state.type,
|
|
213
|
+
}
|
|
214
|
+
: undefined;
|
|
215
|
+
const previousAssignee = issue.assignee
|
|
216
|
+
? { id: issue.assignee.id, name: issue.assignee.name }
|
|
217
|
+
: undefined;
|
|
218
|
+
const terminalState = previousState && ["completed", "canceled"].includes(previousState.type);
|
|
219
|
+
const needsStartedState = !terminalState && previousState?.type !== "started";
|
|
220
|
+
const needsAssignee = issue.assignee?.id !== viewer.id;
|
|
221
|
+
if (!needsStartedState && !needsAssignee) {
|
|
222
|
+
// This path is only reachable when the viewer is already the assignee
|
|
223
|
+
// (`needsAssignee` is false), so `assignee: viewer` is the current assignee.
|
|
224
|
+
return {
|
|
225
|
+
issue: await this.getIssueById(resolvedIssueId),
|
|
226
|
+
previousState,
|
|
227
|
+
previousAssignee,
|
|
228
|
+
claimed: false,
|
|
229
|
+
alreadyClaimed: true,
|
|
230
|
+
assigned: false,
|
|
231
|
+
started: false,
|
|
232
|
+
assignee: viewer,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
let targetState;
|
|
236
|
+
if (needsStartedState) {
|
|
237
|
+
if (!issue.team?.id) {
|
|
238
|
+
throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
|
|
239
|
+
}
|
|
240
|
+
targetState = await this.getFirstStartedStatus(issue.team);
|
|
241
|
+
}
|
|
242
|
+
const updated = await this.updateIssue({
|
|
243
|
+
id: resolvedIssueId,
|
|
244
|
+
...(targetState ? { statusId: targetState.id } : {}),
|
|
245
|
+
...(needsAssignee ? { assigneeId: viewer.id } : {}),
|
|
246
|
+
}, "adding");
|
|
247
|
+
return {
|
|
248
|
+
issue: updated,
|
|
249
|
+
previousState,
|
|
250
|
+
previousAssignee,
|
|
251
|
+
...(targetState ? { targetState } : {}),
|
|
252
|
+
claimed: true,
|
|
253
|
+
alreadyClaimed: false,
|
|
254
|
+
assigned: needsAssignee,
|
|
255
|
+
started: Boolean(targetState),
|
|
256
|
+
assignee: viewer,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
193
259
|
async updateIssue(args, labelMode = "overwriting") {
|
|
194
260
|
// Normalize URL/slug-id --project inputs to UUIDs before the batch
|
|
195
261
|
// resolver runs; see comment on `withNormalizedProjectId`.
|
package/dist/utils/output.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
type OutputFormat = "json" | "summary";
|
|
2
2
|
export declare function setRawMode(enabled: boolean): void;
|
|
3
|
+
/**
|
|
4
|
+
* `--quiet` (write commands only): collapse the success payload to a single
|
|
5
|
+
* confirmation line via `formatLine`, bypassing both the JSON envelope and
|
|
6
|
+
* the summary block. Set in main.ts's preAction when the flag is present.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setQuietMode(enabled: boolean): void;
|
|
3
9
|
export declare function setJqFilter(filter: string | null): void;
|
|
4
10
|
export declare function setFieldsFilter(fields: string[] | null): void;
|
|
5
11
|
export declare function setOutputFormat(format: OutputFormat): void;
|
package/dist/utils/output.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
|
|
2
|
+
import { dispatch as dispatchSummary, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
4
|
import { sanitizeForLog } from "./sanitize-for-log.js";
|
|
5
5
|
const warningBuffer = [];
|
|
6
6
|
let rawMode = false;
|
|
7
|
+
let quietMode = false;
|
|
7
8
|
let jqFilter = null;
|
|
8
9
|
let fieldsFilter = null;
|
|
9
10
|
let outputFormat = "json";
|
|
10
11
|
export function setRawMode(enabled) {
|
|
11
12
|
rawMode = enabled;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* `--quiet` (write commands only): collapse the success payload to a single
|
|
16
|
+
* confirmation line via `formatLine`, bypassing both the JSON envelope and
|
|
17
|
+
* the summary block. Set in main.ts's preAction when the flag is present.
|
|
18
|
+
*/
|
|
19
|
+
export function setQuietMode(enabled) {
|
|
20
|
+
quietMode = enabled;
|
|
21
|
+
}
|
|
13
22
|
export function setJqFilter(filter) {
|
|
14
23
|
jqFilter = filter;
|
|
15
24
|
}
|
|
@@ -102,6 +111,15 @@ export function outputSuccess(data) {
|
|
|
102
111
|
// `title` on an issue) breaks shape inference and the summary
|
|
103
112
|
// formatter falls back to the generic key-value dump.
|
|
104
113
|
const inferredKind = outputFormat === "summary" ? inferKindFromPayload(output) : "generic";
|
|
114
|
+
// --quiet: one machine-stable confirmation line, nothing else. Highest
|
|
115
|
+
// precedence on the write path and independent of --raw / --fields / --jq
|
|
116
|
+
// (those reshape the payload the flag exists to avoid) — so we emit from
|
|
117
|
+
// the full pre-filter object, otherwise `--fields identifier` would strip
|
|
118
|
+
// the state/url formatLine needs and break shape inference.
|
|
119
|
+
if (quietMode) {
|
|
120
|
+
logger.info(formatLine(output));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
105
123
|
// --raw: unwrap { data: [...] } to just the array
|
|
106
124
|
if (rawMode &&
|
|
107
125
|
output !== null &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.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",
|