@enrichlayer/el-linear 1.6.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 +101 -5
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-app-config.d.ts +21 -0
- package/dist/auth/oauth-app-config.js +82 -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/defaults.d.ts +18 -1
- package/dist/commands/init/defaults.js +83 -2
- package/dist/commands/init/index.js +9 -1
- package/dist/commands/init/oauth.d.ts +10 -4
- package/dist/commands/init/oauth.js +32 -8
- 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 +76 -222
- package/dist/commands/labels.js +17 -2
- 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 +144 -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/teams.js +12 -2
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +12 -0
- package/dist/config/paths.js +45 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +39 -3
- 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.d.ts +51 -0
- package/dist/utils/disk-cache.js +179 -0
- 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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Description-handling helpers for issues create/update and the
|
|
3
|
+
* `issues link-references --rewrite-description` flow.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns live here:
|
|
6
|
+
*
|
|
7
|
+
* 1. Resolving the description value from `--description`,
|
|
8
|
+
* `--description-file`, or `--template`.
|
|
9
|
+
* 2. The shared wrap-and-resolve pipeline (`wrapAndResolveRefs`)
|
|
10
|
+
* used by the create/update path and the rewrite-description
|
|
11
|
+
* path. Both wrap valid identifiers as markdown links and
|
|
12
|
+
* return the resolved id→uuid map for downstream auto-link.
|
|
13
|
+
* 3. The post-create/update `maybeAutoLink` hook that creates
|
|
14
|
+
* sidebar relations for refs in the description.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
|
+
* focus on commander wiring + handlers.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import { loadConfig } from "../../config/config.js";
|
|
21
|
+
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
|
+
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
|
+
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
|
+
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
|
+
import { validateReferences } from "../../utils/validate-references.js";
|
|
26
|
+
import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
|
|
27
|
+
/**
|
|
28
|
+
* Read description from a file path or stdin ("-").
|
|
29
|
+
* Avoids shell escaping issues when descriptions contain special characters.
|
|
30
|
+
*/
|
|
31
|
+
export function readDescriptionFile(filePath) {
|
|
32
|
+
if (filePath === "-") {
|
|
33
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
34
|
+
}
|
|
35
|
+
if (!fs.existsSync(filePath)) {
|
|
36
|
+
throw new Error(`Description file not found: ${filePath}`);
|
|
37
|
+
}
|
|
38
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the description from --description, --description-file, or
|
|
42
|
+
* --template (looked up in `config.descriptionTemplates`).
|
|
43
|
+
*
|
|
44
|
+
* Precedence: --description-file > --description > --template
|
|
45
|
+
*
|
|
46
|
+
* Passing --template alongside --description / --description-file is
|
|
47
|
+
* a usage error — the explicit body and a template both producing
|
|
48
|
+
* content would silently drop one. We throw so the user picks one.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveDescription(options) {
|
|
51
|
+
const hasInline = typeof options.description === "string" && options.description.length > 0;
|
|
52
|
+
const hasFile = Boolean(options.descriptionFile);
|
|
53
|
+
const hasTemplate = typeof options.template === "string" && options.template;
|
|
54
|
+
if (hasTemplate && (hasInline || hasFile)) {
|
|
55
|
+
throw new Error("--template is mutually exclusive with --description / --description-file. " +
|
|
56
|
+
"Pick one.");
|
|
57
|
+
}
|
|
58
|
+
if (hasFile) {
|
|
59
|
+
return readDescriptionFile(options.descriptionFile);
|
|
60
|
+
}
|
|
61
|
+
if (hasInline) {
|
|
62
|
+
return options.description;
|
|
63
|
+
}
|
|
64
|
+
if (hasTemplate) {
|
|
65
|
+
const templates = loadConfig().descriptionTemplates ?? {};
|
|
66
|
+
const body = templates[options.template];
|
|
67
|
+
if (!body) {
|
|
68
|
+
const available = Object.keys(templates).sort();
|
|
69
|
+
const hint = available.length
|
|
70
|
+
? `Available templates: ${available.join(", ")}`
|
|
71
|
+
: "No templates configured. Add one under `descriptionTemplates` in your config.";
|
|
72
|
+
throw new Error(`Template "${options.template}" not found. ${hint}`);
|
|
73
|
+
}
|
|
74
|
+
return body;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Shared core for the wrap-and-resolve pipeline used by both the
|
|
80
|
+
* create/update path (`prepareAutoLinkedDescription`) and the
|
|
81
|
+
* `link-references --rewrite-description` path
|
|
82
|
+
* (`prepareDescriptionRewrite`).
|
|
83
|
+
*
|
|
84
|
+
* Returns:
|
|
85
|
+
* - `wrapped: undefined` when the original description had no
|
|
86
|
+
* resolvable refs to wrap (no-op for the caller),
|
|
87
|
+
* - `wrapped: <string>` when at least one ref was wrapped,
|
|
88
|
+
* - `preResolved` — the validated id→uuid map, passed downstream
|
|
89
|
+
* to `autoLinkReferences` to skip a second resolve roundtrip.
|
|
90
|
+
*/
|
|
91
|
+
async function wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService) {
|
|
92
|
+
const refs = extractIssueReferences(description, selfIdentifier);
|
|
93
|
+
if (refs.length === 0) {
|
|
94
|
+
return { wrapped: undefined, preResolved: new Map() };
|
|
95
|
+
}
|
|
96
|
+
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
97
|
+
if (preResolved.size === 0) {
|
|
98
|
+
return { wrapped: undefined, preResolved };
|
|
99
|
+
}
|
|
100
|
+
const validIds = new Set(preResolved.keys());
|
|
101
|
+
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
102
|
+
const rewritten = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
103
|
+
return {
|
|
104
|
+
wrapped: rewritten === description ? undefined : rewritten,
|
|
105
|
+
preResolved,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Pre-process a description before sending to Linear:
|
|
110
|
+
* 1. Extract all issue references.
|
|
111
|
+
* 2. Validate them (drop ones that don't resolve in the workspace —
|
|
112
|
+
* handles "ISO-1424"-style false positives).
|
|
113
|
+
* 3. Wrap valid identifiers as markdown links — skipping any
|
|
114
|
+
* already inside a link, code block, or backtick span.
|
|
115
|
+
*
|
|
116
|
+
* No-ops (returns the original description with an empty map) when:
|
|
117
|
+
* - description is empty/undefined
|
|
118
|
+
* - the user passed `--no-auto-link`
|
|
119
|
+
*/
|
|
120
|
+
export async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
|
|
121
|
+
if (!description || options.autoLink === false) {
|
|
122
|
+
return { description, preResolved: new Map(), rewritten: false };
|
|
123
|
+
}
|
|
124
|
+
const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
|
|
125
|
+
return {
|
|
126
|
+
description: wrapped ?? description,
|
|
127
|
+
preResolved,
|
|
128
|
+
rewritten: wrapped !== undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Prepare a description rewrite for `link-references --rewrite-description`.
|
|
133
|
+
* Same wrap-and-resolve core; different return shape so the caller can
|
|
134
|
+
* skip the rewrite mutation when nothing would change.
|
|
135
|
+
*/
|
|
136
|
+
export async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
|
|
137
|
+
if (!description) {
|
|
138
|
+
return { preResolved: undefined, wrapped: undefined };
|
|
139
|
+
}
|
|
140
|
+
const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
|
|
141
|
+
return {
|
|
142
|
+
// `link-references --rewrite-description` historically used
|
|
143
|
+
// `preResolved: undefined` to mean "no refs at all"; preserve
|
|
144
|
+
// that signal so the existing call site keeps the same shape.
|
|
145
|
+
preResolved: preResolved.size === 0 ? undefined : preResolved,
|
|
146
|
+
wrapped,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
150
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
151
|
+
id: issueUuid,
|
|
152
|
+
input: { description },
|
|
153
|
+
});
|
|
154
|
+
const mutation = updateResult.issueUpdate;
|
|
155
|
+
if (!mutation?.success) {
|
|
156
|
+
throw new Error("Failed to rewrite description");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Run auto-linking for issue references found in a description, unless
|
|
161
|
+
* the user opted out with `--no-auto-link` or no description was
|
|
162
|
+
* provided. Returns undefined when nothing was linked / skipped /
|
|
163
|
+
* failed (so callers can omit the field from JSON output).
|
|
164
|
+
*/
|
|
165
|
+
export async function maybeAutoLink(input) {
|
|
166
|
+
const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
|
|
167
|
+
if (options.autoLink === false) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!description) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const result = await autoLinkReferences({
|
|
174
|
+
issueId,
|
|
175
|
+
identifier,
|
|
176
|
+
description,
|
|
177
|
+
graphQLService,
|
|
178
|
+
linearService,
|
|
179
|
+
preResolved,
|
|
180
|
+
});
|
|
181
|
+
if (result.linked.length === 0 &&
|
|
182
|
+
result.skipped.length === 0 &&
|
|
183
|
+
result.failed.length === 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
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");
|
|
@@ -263,12 +116,20 @@ function buildUpdateArgs(issueId, options, assigneeId) {
|
|
|
263
116
|
else if (options.labels) {
|
|
264
117
|
labelIds = splitList(options.labels);
|
|
265
118
|
}
|
|
119
|
+
// On update, fall back to config.defaultPriority only when --priority
|
|
120
|
+
// wasn't passed. This keeps every update setting a priority for users who
|
|
121
|
+
// want a workspace-wide baseline (e.g. all unassigned triage tickets bump
|
|
122
|
+
// to "medium"). To leave priority untouched, omit defaultPriority from
|
|
123
|
+
// config — the field is opt-in.
|
|
124
|
+
const priorityInput = typeof options.priority === "string"
|
|
125
|
+
? options.priority
|
|
126
|
+
: loadConfig().defaultPriority;
|
|
266
127
|
return {
|
|
267
128
|
id: issueId,
|
|
268
129
|
title: options.title,
|
|
269
130
|
description: options.description,
|
|
270
131
|
statusId: options.status,
|
|
271
|
-
priority:
|
|
132
|
+
priority: priorityInput ? validatePriority(priorityInput) : undefined,
|
|
272
133
|
assigneeId,
|
|
273
134
|
projectId: options.project,
|
|
274
135
|
labelIds,
|
|
@@ -392,17 +253,38 @@ async function handleSearchIssues(query, options, command) {
|
|
|
392
253
|
async function resolveCreateInputs(title, options, rootOpts) {
|
|
393
254
|
const config = loadConfig();
|
|
394
255
|
enforceTerms([title, options.description], { strict: options.strict });
|
|
256
|
+
// Effective assignee: explicit --assignee wins; --no-assignee (commander
|
|
257
|
+
// parses as `assignee === false`) skips both flag and config; otherwise
|
|
258
|
+
// fall back to config.defaultAssignee. Computed BEFORE validation so the
|
|
259
|
+
// "assignee required" rule sees the resolved value, not undefined.
|
|
260
|
+
const noAssignee = options.assignee === false;
|
|
261
|
+
const explicitAssignee = typeof options.assignee === "string" ? options.assignee : undefined;
|
|
262
|
+
const effectiveAssignee = noAssignee
|
|
263
|
+
? undefined
|
|
264
|
+
: (explicitAssignee ?? config.defaultAssignee);
|
|
265
|
+
// Effective priority: explicit --priority wins; otherwise fall back to
|
|
266
|
+
// config.defaultPriority. Both go through validatePriority so a bad config
|
|
267
|
+
// value fails fast with a useful error.
|
|
268
|
+
const effectivePriorityInput = typeof options.priority === "string"
|
|
269
|
+
? options.priority
|
|
270
|
+
: config.defaultPriority;
|
|
395
271
|
// --- Validation (labels, description, assignee, project, title) ---
|
|
396
272
|
// Controlled by config.validation.enabled (default: true).
|
|
397
|
-
// Bypassed by --skip-validation flag
|
|
398
|
-
|
|
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) {
|
|
399
281
|
const rawLabels = options.labels ? splitList(options.labels) : null;
|
|
400
282
|
const description = resolveDescription(options);
|
|
401
283
|
const validationResult = validateIssueCreation({
|
|
402
284
|
labels: rawLabels,
|
|
403
285
|
description: description || undefined,
|
|
404
286
|
title,
|
|
405
|
-
assignee:
|
|
287
|
+
assignee: effectiveAssignee,
|
|
406
288
|
project: options.project,
|
|
407
289
|
});
|
|
408
290
|
// Apply normalized labels back so resolution uses the canonical names
|
|
@@ -411,13 +293,13 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
411
293
|
}
|
|
412
294
|
enforceValidation(validationResult);
|
|
413
295
|
}
|
|
414
|
-
if (!
|
|
415
|
-
outputWarning("Creating issue without --priority. Consider specifying it for better triage."
|
|
296
|
+
if (!effectivePriorityInput) {
|
|
297
|
+
outputWarning("Creating issue without --priority. Consider specifying it for better triage.");
|
|
416
298
|
}
|
|
417
299
|
const teamInput = options.team || config.defaultTeam;
|
|
418
300
|
const teamId = resolveTeam(teamInput);
|
|
419
|
-
const assigneeId =
|
|
420
|
-
? await resolveAssignee(
|
|
301
|
+
const assigneeId = effectiveAssignee
|
|
302
|
+
? await resolveAssignee(effectiveAssignee, rootOpts)
|
|
421
303
|
: undefined;
|
|
422
304
|
let labelIds = [];
|
|
423
305
|
if (options.labels) {
|
|
@@ -438,7 +320,18 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
438
320
|
if (options.subscriber) {
|
|
439
321
|
subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
|
|
440
322
|
}
|
|
441
|
-
|
|
323
|
+
const priority = effectivePriorityInput
|
|
324
|
+
? validatePriority(effectivePriorityInput)
|
|
325
|
+
: undefined;
|
|
326
|
+
return {
|
|
327
|
+
teamInput,
|
|
328
|
+
teamId,
|
|
329
|
+
assigneeId,
|
|
330
|
+
labelIds,
|
|
331
|
+
status,
|
|
332
|
+
subscriberIds,
|
|
333
|
+
priority,
|
|
334
|
+
};
|
|
442
335
|
}
|
|
443
336
|
async function uploadAttachmentsIfNeeded(options, rootOpts) {
|
|
444
337
|
if (!options.attachment) {
|
|
@@ -472,7 +365,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
472
365
|
}
|
|
473
366
|
async function handleCreateIssue(title, options, command) {
|
|
474
367
|
const rootOpts = getRootOpts(command);
|
|
475
|
-
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
|
|
368
|
+
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
|
|
476
369
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
477
370
|
const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
|
|
478
371
|
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
@@ -492,12 +385,14 @@ async function handleCreateIssue(title, options, command) {
|
|
|
492
385
|
// because the issue doesn't exist yet — pass undefined.
|
|
493
386
|
const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
|
|
494
387
|
const result = await issuesService.createIssue({
|
|
495
|
-
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 } : {}),
|
|
496
391
|
teamId,
|
|
497
392
|
teamInput,
|
|
498
393
|
description: prepared.description,
|
|
499
394
|
assigneeId,
|
|
500
|
-
priority
|
|
395
|
+
priority,
|
|
501
396
|
projectId: options.project,
|
|
502
397
|
statusId: status,
|
|
503
398
|
labelIds: labelIds.length > 0 ? labelIds : undefined,
|
|
@@ -506,6 +401,12 @@ async function handleCreateIssue(title, options, command) {
|
|
|
506
401
|
cycleId: options.cycle,
|
|
507
402
|
subscriberIds,
|
|
508
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
|
+
: {}),
|
|
509
410
|
});
|
|
510
411
|
const relations = await createRelations(result.id, options, graphQLService, linearService);
|
|
511
412
|
// Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
|
|
@@ -710,40 +611,6 @@ async function linkReferencesForIssue(args) {
|
|
|
710
611
|
preResolved: args.preResolved,
|
|
711
612
|
});
|
|
712
613
|
}
|
|
713
|
-
/**
|
|
714
|
-
* Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
|
|
715
|
-
* and return the rewritten text (or undefined if nothing would change).
|
|
716
|
-
*/
|
|
717
|
-
async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
|
|
718
|
-
if (!description) {
|
|
719
|
-
return { preResolved: undefined, wrapped: undefined };
|
|
720
|
-
}
|
|
721
|
-
const refs = extractIssueReferences(description, selfIdentifier);
|
|
722
|
-
if (refs.length === 0) {
|
|
723
|
-
return { preResolved: undefined, wrapped: undefined };
|
|
724
|
-
}
|
|
725
|
-
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
726
|
-
if (preResolved.size === 0) {
|
|
727
|
-
return { preResolved, wrapped: undefined };
|
|
728
|
-
}
|
|
729
|
-
const validIds = new Set(preResolved.keys());
|
|
730
|
-
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
731
|
-
const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
732
|
-
return {
|
|
733
|
-
preResolved,
|
|
734
|
-
wrapped: wrapped === description ? undefined : wrapped,
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
738
|
-
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
739
|
-
id: issueUuid,
|
|
740
|
-
input: { description },
|
|
741
|
-
});
|
|
742
|
-
const mutation = updateResult.issueUpdate;
|
|
743
|
-
if (!mutation?.success) {
|
|
744
|
-
throw new Error("Failed to rewrite description");
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
614
|
async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
|
|
748
615
|
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
749
616
|
// GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
|
|
@@ -869,25 +736,6 @@ async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
|
869
736
|
}
|
|
870
737
|
return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
|
|
871
738
|
}
|
|
872
|
-
async function handleReadIssue(issueIds, _options, command) {
|
|
873
|
-
const rootOpts = getRootOpts(command);
|
|
874
|
-
const graphQLService = await createGraphQLService(rootOpts);
|
|
875
|
-
const linearService = await createLinearService(rootOpts);
|
|
876
|
-
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
877
|
-
const fileService = await createFileService(rootOpts);
|
|
878
|
-
if (issueIds.length === 1) {
|
|
879
|
-
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
880
|
-
const resolved = await downloadLinearUploads(issue, fileService);
|
|
881
|
-
outputSuccess(resolved);
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
const results = await Promise.all(issueIds.map(async (id) => {
|
|
885
|
-
const issue = await issuesService.getIssueById(id);
|
|
886
|
-
return downloadLinearUploads(issue, fileService);
|
|
887
|
-
}));
|
|
888
|
-
outputSuccess(results);
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
739
|
async function handleUpdateIssue(issueId, options, command) {
|
|
892
740
|
// Normalize aliases
|
|
893
741
|
if (options.state && !options.status) {
|
|
@@ -1048,7 +896,7 @@ export function setupIssuesCommands(program) {
|
|
|
1048
896
|
.option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
|
|
1049
897
|
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1050
898
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1051
|
-
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
899
|
+
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1052
900
|
.option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
|
|
1053
901
|
.action(handleAsyncCommand(handleListIssues));
|
|
1054
902
|
issues
|
|
@@ -1063,7 +911,7 @@ export function setupIssuesCommands(program) {
|
|
|
1063
911
|
.option("--label <labels>", "alias for --labels")
|
|
1064
912
|
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1065
913
|
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1066
|
-
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
914
|
+
.option("--format <format>", "output format (json, summary, table, md, csv)", "json")
|
|
1067
915
|
.option("--fields <fields>", "columns for table/csv output")
|
|
1068
916
|
.option("-l, --limit <number>", "limit results", "10")
|
|
1069
917
|
.action(handleAsyncCommand(handleSearchIssues));
|
|
@@ -1074,7 +922,9 @@ export function setupIssuesCommands(program) {
|
|
|
1074
922
|
.option("-d, --description <desc>", "issue description")
|
|
1075
923
|
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1076
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.")
|
|
1077
926
|
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
927
|
+
.option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
|
|
1078
928
|
.option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
|
|
1079
929
|
.option("--project <project>", "add to project (name or ID)")
|
|
1080
930
|
.option("--team <team>", "team key or name (default: from config)")
|
|
@@ -1103,8 +953,12 @@ export function setupIssuesCommands(program) {
|
|
|
1103
953
|
options.labels = options.label;
|
|
1104
954
|
}
|
|
1105
955
|
const title = options.title || titleArg;
|
|
1106
|
-
|
|
1107
|
-
|
|
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.");
|
|
1108
962
|
}
|
|
1109
963
|
return handleCreateIssue(title, options, command);
|
|
1110
964
|
}));
|
|
@@ -1115,7 +969,7 @@ export function setupIssuesCommands(program) {
|
|
|
1115
969
|
.alias("show")
|
|
1116
970
|
.description("Get issue details. Accepts multiple IDs for batch retrieval.")
|
|
1117
971
|
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
|
|
1118
|
-
.action(handleAsyncCommand(
|
|
972
|
+
.action(handleAsyncCommand(readIssues));
|
|
1119
973
|
issues
|
|
1120
974
|
.command("update <issueId>")
|
|
1121
975
|
.alias("edit")
|
package/dist/commands/labels.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
1
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
2
3
|
import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
|
|
4
|
+
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
3
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
5
7
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
@@ -71,12 +73,25 @@ export function setupLabelsCommands(program) {
|
|
|
71
73
|
.command("list")
|
|
72
74
|
.description("List all available labels")
|
|
73
75
|
.option("--team <team>", "filter by team key, name, or ID")
|
|
76
|
+
.option("--name <substring>", "filter by case-insensitive substring on label name")
|
|
74
77
|
.option("-l, --limit <number>", "limit results", "100")
|
|
75
78
|
.action(handleAsyncCommand(async (options, command) => {
|
|
76
79
|
const rootOpts = getRootOpts(command);
|
|
77
80
|
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
78
|
-
const
|
|
79
|
-
const
|
|
81
|
+
const nameFilter = options.name;
|
|
82
|
+
const limit = Number.parseInt(options.limit, 10);
|
|
83
|
+
const ttl = resolveCacheTTL({
|
|
84
|
+
configTTL: loadConfig().cacheTTLSeconds,
|
|
85
|
+
noCacheFlag: rootOpts.cache === false,
|
|
86
|
+
});
|
|
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}`;
|
|
91
|
+
const result = await cached(cacheKey, ttl, async () => {
|
|
92
|
+
const service = await createLinearService(rootOpts);
|
|
93
|
+
return service.getLabels(teamFilter, limit, nameFilter);
|
|
94
|
+
});
|
|
80
95
|
outputSuccess({
|
|
81
96
|
data: result.labels,
|
|
82
97
|
meta: { count: result.labels.length },
|