@enrichlayer/el-linear 1.7.0 → 1.9.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 +69 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +34 -215
- package/dist/commands/labels.js +7 -5
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +135 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- package/package.json +1 -1
package/dist/commands/issues.js
CHANGED
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
2
|
import { loadConfig } from "../config/config.js";
|
|
4
3
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
5
4
|
import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
|
|
6
5
|
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
7
6
|
import { enforceTerms } from "../config/term-enforcer.js";
|
|
8
7
|
import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
|
|
9
|
-
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY,
|
|
8
|
+
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, } from "../queries/issues.js";
|
|
10
9
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
11
|
-
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
12
10
|
import { createFileService } from "../utils/file-service.js";
|
|
13
11
|
import { applyFooter } from "../utils/footer.js";
|
|
14
12
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
|
-
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
13
|
+
import { GraphQLIssuesService, } from "../utils/graphql-issues-service.js";
|
|
16
14
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
17
|
-
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
18
|
-
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
19
15
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
20
16
|
import { logger } from "../utils/logger.js";
|
|
21
17
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
22
18
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
23
19
|
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
24
|
-
import { validateReferences } from "../utils/validate-references.js";
|
|
25
20
|
import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
26
|
-
import {
|
|
21
|
+
import { gitCheckoutBranch, toBranchName } from "./issues/branch.js";
|
|
22
|
+
import { maybeAutoLink, prepareAutoLinkedDescription, prepareDescriptionRewrite, pushDescriptionUpdate, readDescriptionFile, resolveDescription, } from "./issues/description.js";
|
|
23
|
+
import { readIssues } from "./read-shortcut.js";
|
|
27
24
|
const IMAGE_EXTENSIONS = new Set([
|
|
28
25
|
".png",
|
|
29
26
|
".jpg",
|
|
@@ -33,90 +30,7 @@ const IMAGE_EXTENSIONS = new Set([
|
|
|
33
30
|
".svg",
|
|
34
31
|
".ico",
|
|
35
32
|
]);
|
|
36
|
-
const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
|
|
37
33
|
const COMMIT_HASH_PREFIX_REGEX = /^[a-f0-9]+ /;
|
|
38
|
-
/**
|
|
39
|
-
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
40
|
-
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
41
|
-
*/
|
|
42
|
-
function toBranchName(linearBranchName, prefix = "feature/") {
|
|
43
|
-
// Linear branch names look like "dev-123-some-slug"
|
|
44
|
-
// We need to uppercase the team key: "DEV-123-some-slug"
|
|
45
|
-
const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
|
|
46
|
-
if (!match) {
|
|
47
|
-
return `${prefix}${linearBranchName}`;
|
|
48
|
-
}
|
|
49
|
-
const [, teamKey, number, slug] = match;
|
|
50
|
-
return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
54
|
-
* Throws if the branch already exists.
|
|
55
|
-
*/
|
|
56
|
-
function gitCheckoutBranch(branchName) {
|
|
57
|
-
try {
|
|
58
|
-
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
59
|
-
stdio: "pipe",
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Read description from a file path or stdin ("-").
|
|
70
|
-
* Avoids shell escaping issues when descriptions contain special characters.
|
|
71
|
-
*/
|
|
72
|
-
function readDescriptionFile(filePath) {
|
|
73
|
-
if (filePath === "-") {
|
|
74
|
-
return fs.readFileSync(0, "utf8").trim();
|
|
75
|
-
}
|
|
76
|
-
if (!fs.existsSync(filePath)) {
|
|
77
|
-
throw new Error(`Description file not found: ${filePath}`);
|
|
78
|
-
}
|
|
79
|
-
return fs.readFileSync(filePath, "utf8").trim();
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Resolve the description from --description, --description-file, or
|
|
83
|
-
* --template (looked up in `config.descriptionTemplates`).
|
|
84
|
-
*
|
|
85
|
-
* Precedence:
|
|
86
|
-
* --description-file > --description > --template
|
|
87
|
-
*
|
|
88
|
-
* Passing --template alongside --description or --description-file is a
|
|
89
|
-
* usage error — the explicit body and a template both producing content
|
|
90
|
-
* would be silently dropping one. We throw so the user picks one.
|
|
91
|
-
*/
|
|
92
|
-
function resolveDescription(options) {
|
|
93
|
-
const hasInline = typeof options.description === "string" && options.description.length > 0;
|
|
94
|
-
const hasFile = Boolean(options.descriptionFile);
|
|
95
|
-
const hasTemplate = typeof options.template === "string" && options.template;
|
|
96
|
-
if (hasTemplate && (hasInline || hasFile)) {
|
|
97
|
-
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
98
|
-
"Pick one.");
|
|
99
|
-
}
|
|
100
|
-
if (hasFile) {
|
|
101
|
-
return readDescriptionFile(options.descriptionFile);
|
|
102
|
-
}
|
|
103
|
-
if (hasInline) {
|
|
104
|
-
return options.description;
|
|
105
|
-
}
|
|
106
|
-
if (hasTemplate) {
|
|
107
|
-
const templates = loadConfig().descriptionTemplates ?? {};
|
|
108
|
-
const body = templates[options.template];
|
|
109
|
-
if (!body) {
|
|
110
|
-
const available = Object.keys(templates).sort();
|
|
111
|
-
const hint = available.length
|
|
112
|
-
? `Available templates: ${available.join(", ")}`
|
|
113
|
-
: "No templates configured. Add one under `descriptionTemplates` in your config.";
|
|
114
|
-
throw new Error(`Template "${options.template}" not found. ${hint}`);
|
|
115
|
-
}
|
|
116
|
-
return body;
|
|
117
|
-
}
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
34
|
function isImageFile(filename) {
|
|
121
35
|
const ext = filename.lastIndexOf(".");
|
|
122
36
|
return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
|
|
@@ -168,67 +82,6 @@ async function createRelations(sourceId, options, graphQLService, linearService)
|
|
|
168
82
|
}
|
|
169
83
|
return relations;
|
|
170
84
|
}
|
|
171
|
-
/**
|
|
172
|
-
* Pre-process a description before sending to Linear:
|
|
173
|
-
* 1. Extract all issue references from the description.
|
|
174
|
-
* 2. Validate them (drop those that don't resolve in this workspace — handles "ISO-1424"-style false positives).
|
|
175
|
-
* 3. Wrap valid identifiers as markdown links — skipping any already inside a link, code block, or backtick span.
|
|
176
|
-
*
|
|
177
|
-
* Returns the rewritten description plus the validated map for downstream auto-linking.
|
|
178
|
-
*
|
|
179
|
-
* No-ops (returns the original description with an empty map) when:
|
|
180
|
-
* - description is empty/undefined
|
|
181
|
-
* - the user passed `--no-auto-link`
|
|
182
|
-
*/
|
|
183
|
-
async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
|
|
184
|
-
if (!description || options.autoLink === false) {
|
|
185
|
-
return { description, preResolved: new Map(), rewritten: false };
|
|
186
|
-
}
|
|
187
|
-
const refs = extractIssueReferences(description, selfIdentifier);
|
|
188
|
-
if (refs.length === 0) {
|
|
189
|
-
return { description, preResolved: new Map(), rewritten: false };
|
|
190
|
-
}
|
|
191
|
-
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
192
|
-
if (preResolved.size === 0) {
|
|
193
|
-
return { description, preResolved, rewritten: false };
|
|
194
|
-
}
|
|
195
|
-
const validIds = new Set(preResolved.keys());
|
|
196
|
-
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
197
|
-
const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
198
|
-
return {
|
|
199
|
-
description: wrapped,
|
|
200
|
-
preResolved,
|
|
201
|
-
rewritten: wrapped !== description,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Run auto-linking for issue references found in a description, unless the user opted out
|
|
206
|
-
* with --no-auto-link or no description was provided. Returns undefined when nothing was linked
|
|
207
|
-
* and nothing was skipped/failed (so callers can omit the field from JSON output).
|
|
208
|
-
*/
|
|
209
|
-
async function maybeAutoLink(input) {
|
|
210
|
-
const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
|
|
211
|
-
if (options.autoLink === false) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (!description) {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const result = await autoLinkReferences({
|
|
218
|
-
issueId,
|
|
219
|
-
identifier,
|
|
220
|
-
description,
|
|
221
|
-
graphQLService,
|
|
222
|
-
linearService,
|
|
223
|
-
preResolved,
|
|
224
|
-
});
|
|
225
|
-
if (result.linked.length === 0 &&
|
|
226
|
-
result.skipped.length === 0 &&
|
|
227
|
-
result.failed.length === 0) {
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
return result;
|
|
231
|
-
}
|
|
232
85
|
function validateUpdateOptions(options) {
|
|
233
86
|
if (options.parentTicket && options.clearParentTicket) {
|
|
234
87
|
throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
|
|
@@ -417,8 +270,14 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
417
270
|
: config.defaultPriority;
|
|
418
271
|
// --- Validation (labels, description, assignee, project, title) ---
|
|
419
272
|
// Controlled by config.validation.enabled (default: true).
|
|
420
|
-
// Bypassed by --skip-validation flag
|
|
421
|
-
|
|
273
|
+
// Bypassed by --skip-validation flag, OR by --from-template which
|
|
274
|
+
// defers field provision to Linear's server-side template
|
|
275
|
+
// instantiation. With --from-template + no overrides, the
|
|
276
|
+
// resulting issue inherits title/description/labels/etc. from the
|
|
277
|
+
// template — running the local validator against undefined fields
|
|
278
|
+
// would produce a false-negative "Missing --labels" error.
|
|
279
|
+
const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
|
|
280
|
+
if (!options.skipValidation && !hasFromTemplate) {
|
|
422
281
|
const rawLabels = options.labels ? splitList(options.labels) : null;
|
|
423
282
|
const description = resolveDescription(options);
|
|
424
283
|
const validationResult = validateIssueCreation({
|
|
@@ -435,7 +294,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
435
294
|
enforceValidation(validationResult);
|
|
436
295
|
}
|
|
437
296
|
if (!effectivePriorityInput) {
|
|
438
|
-
outputWarning("Creating issue without --priority. Consider specifying it for better triage."
|
|
297
|
+
outputWarning("Creating issue without --priority. Consider specifying it for better triage.");
|
|
439
298
|
}
|
|
440
299
|
const teamInput = options.team || config.defaultTeam;
|
|
441
300
|
const teamId = resolveTeam(teamInput);
|
|
@@ -506,7 +365,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
506
365
|
}
|
|
507
366
|
async function handleCreateIssue(title, options, command) {
|
|
508
367
|
const rootOpts = getRootOpts(command);
|
|
509
|
-
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title, options, rootOpts);
|
|
368
|
+
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
|
|
510
369
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
511
370
|
const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
|
|
512
371
|
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
@@ -526,7 +385,9 @@ async function handleCreateIssue(title, options, command) {
|
|
|
526
385
|
// because the issue doesn't exist yet — pass undefined.
|
|
527
386
|
const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
|
|
528
387
|
const result = await issuesService.createIssue({
|
|
529
|
-
title,
|
|
388
|
+
// title is omitted when --from-template is set without an override,
|
|
389
|
+
// so Linear copies the template's title server-side.
|
|
390
|
+
...(title ? { title } : {}),
|
|
530
391
|
teamId,
|
|
531
392
|
teamInput,
|
|
532
393
|
description: prepared.description,
|
|
@@ -540,6 +401,12 @@ async function handleCreateIssue(title, options, command) {
|
|
|
540
401
|
cycleId: options.cycle,
|
|
541
402
|
subscriberIds,
|
|
542
403
|
dueDate: options.dueDate,
|
|
404
|
+
// Linear server-side template instantiation. When set,
|
|
405
|
+
// Linear copies the template's title/description/labels/priority
|
|
406
|
+
// onto the new issue; any explicit field above wins by override.
|
|
407
|
+
...(typeof options.fromTemplate === "string" && options.fromTemplate
|
|
408
|
+
? { templateId: options.fromTemplate }
|
|
409
|
+
: {}),
|
|
543
410
|
});
|
|
544
411
|
const relations = await createRelations(result.id, options, graphQLService, linearService);
|
|
545
412
|
// Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
|
|
@@ -744,40 +611,6 @@ async function linkReferencesForIssue(args) {
|
|
|
744
611
|
preResolved: args.preResolved,
|
|
745
612
|
});
|
|
746
613
|
}
|
|
747
|
-
/**
|
|
748
|
-
* Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
|
|
749
|
-
* and return the rewritten text (or undefined if nothing would change).
|
|
750
|
-
*/
|
|
751
|
-
async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
|
|
752
|
-
if (!description) {
|
|
753
|
-
return { preResolved: undefined, wrapped: undefined };
|
|
754
|
-
}
|
|
755
|
-
const refs = extractIssueReferences(description, selfIdentifier);
|
|
756
|
-
if (refs.length === 0) {
|
|
757
|
-
return { preResolved: undefined, wrapped: undefined };
|
|
758
|
-
}
|
|
759
|
-
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
760
|
-
if (preResolved.size === 0) {
|
|
761
|
-
return { preResolved, wrapped: undefined };
|
|
762
|
-
}
|
|
763
|
-
const validIds = new Set(preResolved.keys());
|
|
764
|
-
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
765
|
-
const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
766
|
-
return {
|
|
767
|
-
preResolved,
|
|
768
|
-
wrapped: wrapped === description ? undefined : wrapped,
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
772
|
-
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
773
|
-
id: issueUuid,
|
|
774
|
-
input: { description },
|
|
775
|
-
});
|
|
776
|
-
const mutation = updateResult.issueUpdate;
|
|
777
|
-
if (!mutation?.success) {
|
|
778
|
-
throw new Error("Failed to rewrite description");
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
614
|
async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
|
|
782
615
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
783
616
|
// GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
|
|
@@ -903,25 +736,6 @@ async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
|
903
736
|
}
|
|
904
737
|
return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
|
|
905
738
|
}
|
|
906
|
-
async function handleReadIssue(issueIds, _options, command) {
|
|
907
|
-
const rootOpts = getRootOpts(command);
|
|
908
|
-
const graphQLService = await createGraphQLService(rootOpts);
|
|
909
|
-
const linearService = await createLinearService(rootOpts);
|
|
910
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
911
|
-
const fileService = await createFileService(rootOpts);
|
|
912
|
-
if (issueIds.length === 1) {
|
|
913
|
-
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
914
|
-
const resolved = await downloadLinearUploads(issue, fileService);
|
|
915
|
-
outputSuccess(resolved);
|
|
916
|
-
}
|
|
917
|
-
else {
|
|
918
|
-
const results = await Promise.all(issueIds.map(async (id) => {
|
|
919
|
-
const issue = await issuesService.getIssueById(id);
|
|
920
|
-
return downloadLinearUploads(issue, fileService);
|
|
921
|
-
}));
|
|
922
|
-
outputSuccess(results);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
739
|
async function handleUpdateIssue(issueId, options, command) {
|
|
926
740
|
// Normalize aliases
|
|
927
741
|
if (options.state && !options.status) {
|
|
@@ -1082,7 +896,7 @@ export function setupIssuesCommands(program) {
|
|
|
1082
896
|
.option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
|
|
1083
897
|
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1084
898
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1085
|
-
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
899
|
+
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1086
900
|
.option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
|
|
1087
901
|
.action(handleAsyncCommand(handleListIssues));
|
|
1088
902
|
issues
|
|
@@ -1097,7 +911,7 @@ export function setupIssuesCommands(program) {
|
|
|
1097
911
|
.option("--label <labels>", "alias for --labels")
|
|
1098
912
|
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1099
913
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1100
|
-
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
914
|
+
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1101
915
|
.option("--fields <fields>", "columns for table/csv output")
|
|
1102
916
|
.option("-l, --limit <number>", "limit results", "10")
|
|
1103
917
|
.action(handleAsyncCommand(handleSearchIssues));
|
|
@@ -1108,6 +922,7 @@ export function setupIssuesCommands(program) {
|
|
|
1108
922
|
.option("-d, --description <desc>", "issue description")
|
|
1109
923
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1110
924
|
.option("--template <name>", "use a named description template from config.descriptionTemplates")
|
|
925
|
+
.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.")
|
|
1111
926
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1112
927
|
.option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
|
|
1113
928
|
.option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
|
|
@@ -1138,8 +953,12 @@ export function setupIssuesCommands(program) {
|
|
|
1138
953
|
options.labels = options.label;
|
|
1139
954
|
}
|
|
1140
955
|
const title = options.title || titleArg;
|
|
1141
|
-
|
|
1142
|
-
|
|
956
|
+
// --from-template lets Linear copy the template's title, so a
|
|
957
|
+
// local title is optional in that path. Without --from-template,
|
|
958
|
+
// the create mutation requires a title up front.
|
|
959
|
+
const fromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
|
|
960
|
+
if (!title && !fromTemplate) {
|
|
961
|
+
throw new Error("Title is required. Provide it as a positional argument, with --title, or use --from-template to copy the template's title.");
|
|
1143
962
|
}
|
|
1144
963
|
return handleCreateIssue(title, options, command);
|
|
1145
964
|
}));
|
|
@@ -1150,7 +969,7 @@ export function setupIssuesCommands(program) {
|
|
|
1150
969
|
.alias("show")
|
|
1151
970
|
.description("Get issue details. Accepts multiple IDs for batch retrieval.")
|
|
1152
971
|
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
|
|
1153
|
-
.action(handleAsyncCommand(
|
|
972
|
+
.action(handleAsyncCommand(readIssues));
|
|
1154
973
|
issues
|
|
1155
974
|
.command("update <issueId>")
|
|
1156
975
|
.alias("edit")
|
package/dist/commands/labels.js
CHANGED
|
@@ -73,22 +73,24 @@ export function setupLabelsCommands(program) {
|
|
|
73
73
|
.command("list")
|
|
74
74
|
.description("List all available labels")
|
|
75
75
|
.option("--team <team>", "filter by team key, name, or ID")
|
|
76
|
+
.option("--name <substring>", "filter by case-insensitive substring on label name")
|
|
76
77
|
.option("-l, --limit <number>", "limit results", "100")
|
|
77
78
|
.action(handleAsyncCommand(async (options, command) => {
|
|
78
79
|
const rootOpts = getRootOpts(command);
|
|
79
80
|
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
81
|
+
const nameFilter = options.name;
|
|
80
82
|
const limit = Number.parseInt(options.limit, 10);
|
|
81
83
|
const ttl = resolveCacheTTL({
|
|
82
84
|
configTTL: loadConfig().cacheTTLSeconds,
|
|
83
85
|
noCacheFlag: rootOpts.cache === false,
|
|
84
86
|
});
|
|
85
|
-
// Cache key includes the team filter
|
|
86
|
-
//
|
|
87
|
-
// a smaller list isn't a valid cached answer for a larger ask.
|
|
88
|
-
const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-limit:${limit}`;
|
|
87
|
+
// Cache key includes the team filter and name filter so different
|
|
88
|
+
// filter combinations don't collide. Same `limit` participates
|
|
89
|
+
// because a smaller list isn't a valid cached answer for a larger ask.
|
|
90
|
+
const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-name:${nameFilter ?? "_all"}-limit:${limit}`;
|
|
89
91
|
const result = await cached(cacheKey, ttl, async () => {
|
|
90
92
|
const service = await createLinearService(rootOpts);
|
|
91
|
-
return service.getLabels(teamFilter, limit);
|
|
93
|
+
return service.getLabels(teamFilter, limit, nameFilter);
|
|
92
94
|
});
|
|
93
95
|
outputSuccess({
|
|
94
96
|
data: result.labels,
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
import { promises as fsp } from "node:fs";
|
|
39
39
|
import path from "node:path";
|
|
40
40
|
import { confirm, input, password } from "@inquirer/prompts";
|
|
41
|
-
import {
|
|
41
|
+
import { atomicWrite } from "../../auth/oauth-fs.js";
|
|
42
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, TOKEN_PATH, } from "../../config/paths.js";
|
|
42
43
|
import { sanitizeForLog, validateToken } from "../init/token.js";
|
|
43
44
|
const DEFAULT_PROMPTS = { input, password, confirm };
|
|
44
45
|
/** Register `el-linear profile migrate-legacy` under the parent `profile` command. */
|
|
@@ -165,10 +166,7 @@ async function resolveAndValidateToken(options, prompts, validate) {
|
|
|
165
166
|
async function copyConfigIntoProfile(destConfigPath, options, prompts) {
|
|
166
167
|
const sourceContent = await fsp.readFile(CONFIG_PATH, "utf8");
|
|
167
168
|
if (!(await pathExists(destConfigPath))) {
|
|
168
|
-
await
|
|
169
|
-
mode: 0o644,
|
|
170
|
-
encoding: "utf8",
|
|
171
|
-
});
|
|
169
|
+
await atomicWrite(destConfigPath, sourceContent, 0o644);
|
|
172
170
|
return;
|
|
173
171
|
}
|
|
174
172
|
const destContent = await fsp.readFile(destConfigPath, "utf8");
|
|
@@ -192,21 +190,15 @@ async function copyConfigIntoProfile(destConfigPath, options, prompts) {
|
|
|
192
190
|
throw new Error("Aborted by user.");
|
|
193
191
|
}
|
|
194
192
|
}
|
|
195
|
-
await
|
|
196
|
-
mode: 0o644,
|
|
197
|
-
encoding: "utf8",
|
|
198
|
-
});
|
|
193
|
+
await atomicWrite(destConfigPath, sourceContent, 0o644);
|
|
199
194
|
}
|
|
200
195
|
async function writeProfileToken(destTokenPath, token, options, prompts) {
|
|
201
196
|
const newline = `${token}\n`;
|
|
202
197
|
if (!(await pathExists(destTokenPath))) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// fs.writeFile mode is only honored on file creation; chmod defensively
|
|
208
|
-
// in case a parent process pre-created the file with permissive perms.
|
|
209
|
-
await fsp.chmod(destTokenPath, 0o600);
|
|
198
|
+
// atomicWrite creates the tmp file with the requested mode and
|
|
199
|
+
// renames into place, so there is no window where the token is
|
|
200
|
+
// readable at a looser mode than 0o600.
|
|
201
|
+
await atomicWrite(destTokenPath, newline, 0o600);
|
|
210
202
|
return;
|
|
211
203
|
}
|
|
212
204
|
const existing = (await fsp.readFile(destTokenPath, "utf8")).trim();
|
|
@@ -230,11 +222,7 @@ async function writeProfileToken(destTokenPath, token, options, prompts) {
|
|
|
230
222
|
throw new Error("Aborted by user.");
|
|
231
223
|
}
|
|
232
224
|
}
|
|
233
|
-
await
|
|
234
|
-
mode: 0o600,
|
|
235
|
-
encoding: "utf8",
|
|
236
|
-
});
|
|
237
|
-
await fsp.chmod(destTokenPath, 0o600);
|
|
225
|
+
await atomicWrite(destTokenPath, newline, 0o600);
|
|
238
226
|
}
|
|
239
227
|
async function ensureActiveProfile(name) {
|
|
240
228
|
await fsp.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
@@ -244,10 +232,7 @@ async function ensureActiveProfile(name) {
|
|
|
244
232
|
}
|
|
245
233
|
if (current === name)
|
|
246
234
|
return;
|
|
247
|
-
await
|
|
248
|
-
mode: 0o644,
|
|
249
|
-
encoding: "utf8",
|
|
250
|
-
});
|
|
235
|
+
await atomicWrite(ACTIVE_PROFILE_FILE, `${name}\n`, 0o644);
|
|
251
236
|
}
|
|
252
237
|
async function pathExists(p) {
|
|
253
238
|
try {
|
|
@@ -258,14 +243,6 @@ async function pathExists(p) {
|
|
|
258
243
|
return false;
|
|
259
244
|
}
|
|
260
245
|
}
|
|
261
|
-
/**
|
|
262
|
-
* Profile names land in filesystem paths and the active-profile marker.
|
|
263
|
-
* Same conservative charset as `profile add` to avoid `..` traversal,
|
|
264
|
-
* shell metacharacters, and Unicode lookalikes.
|
|
265
|
-
*/
|
|
266
|
-
function isSafeName(name) {
|
|
267
|
-
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
268
|
-
}
|
|
269
246
|
// Re-export internals so the integration test (and follow-up commands)
|
|
270
247
|
// can compose them without re-implementing the idempotency rules.
|
|
271
248
|
export { CONFIG_PATH, PROFILES_DIR, TOKEN_PATH };
|
package/dist/commands/profile.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { promises as fsp } from "node:fs";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { confirm } from "@inquirer/prompts";
|
|
26
|
-
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
26
|
+
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
27
27
|
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
28
|
import { runFullWizard } from "./init/index.js";
|
|
29
29
|
import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
|
|
@@ -180,12 +180,3 @@ async function pathExists(p) {
|
|
|
180
180
|
return false;
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
-
/**
|
|
184
|
-
* Profile names land in filesystem paths AND get written to a
|
|
185
|
-
* single-line marker. Restrict to a conservative charset so we can't
|
|
186
|
-
* accidentally pick up `..` traversal, command-line metacharacters, or
|
|
187
|
-
* Unicode lookalikes that would confuse `profile use`.
|
|
188
|
-
*/
|
|
189
|
-
function isSafeName(name) {
|
|
190
|
-
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name);
|
|
191
|
-
}
|
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
1
|
+
import type { Command, OptionValues } from "commander";
|
|
2
|
+
export declare function resolveProjectStateFilter(options: OptionValues): {
|
|
3
|
+
states?: string[];
|
|
4
|
+
excludeStates?: string[];
|
|
5
|
+
};
|
|
2
6
|
export declare function setupProjectsCommands(program: Command): void;
|