@enrichlayer/el-linear 1.2.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/LICENSE +21 -0
- package/README.md +219 -0
- package/claude-skills/linear-operations/SKILL.md +315 -0
- package/claude-skills/linear-operations/evals/evals.json +46 -0
- package/dist/commands/attachments.d.ts +2 -0
- package/dist/commands/attachments.js +57 -0
- package/dist/commands/batch.d.ts +2 -0
- package/dist/commands/batch.js +309 -0
- package/dist/commands/comments.d.ts +2 -0
- package/dist/commands/comments.js +272 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +15 -0
- package/dist/commands/cycles.d.ts +2 -0
- package/dist/commands/cycles.js +63 -0
- package/dist/commands/documents.d.ts +2 -0
- package/dist/commands/documents.js +175 -0
- package/dist/commands/embeds.d.ts +2 -0
- package/dist/commands/embeds.js +65 -0
- package/dist/commands/gdoc.d.ts +2 -0
- package/dist/commands/gdoc.js +37 -0
- package/dist/commands/graphql.d.ts +2 -0
- package/dist/commands/graphql.js +70 -0
- package/dist/commands/init/aliases.d.ts +109 -0
- package/dist/commands/init/aliases.js +569 -0
- package/dist/commands/init/defaults.d.ts +25 -0
- package/dist/commands/init/defaults.js +112 -0
- package/dist/commands/init/index.d.ts +18 -0
- package/dist/commands/init/index.js +182 -0
- package/dist/commands/init/shared.d.ts +88 -0
- package/dist/commands/init/shared.js +164 -0
- package/dist/commands/init/token.d.ts +50 -0
- package/dist/commands/init/token.js +141 -0
- package/dist/commands/init/workspace.d.ts +20 -0
- package/dist/commands/init/workspace.js +80 -0
- package/dist/commands/issue-id.d.ts +28 -0
- package/dist/commands/issue-id.js +81 -0
- package/dist/commands/issues.d.ts +2 -0
- package/dist/commands/issues.js +1145 -0
- package/dist/commands/labels.d.ts +2 -0
- package/dist/commands/labels.js +100 -0
- package/dist/commands/project-milestones.d.ts +2 -0
- package/dist/commands/project-milestones.js +143 -0
- package/dist/commands/projects.d.ts +2 -0
- package/dist/commands/projects.js +336 -0
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +69 -0
- package/dist/commands/releases.d.ts +2 -0
- package/dist/commands/releases.js +142 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +171 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +19 -0
- package/dist/commands/templates.d.ts +2 -0
- package/dist/commands/templates.js +58 -0
- package/dist/commands/users.d.ts +2 -0
- package/dist/commands/users.js +17 -0
- package/dist/config/config.d.ts +43 -0
- package/dist/config/config.js +81 -0
- package/dist/config/issue-validation.d.ts +39 -0
- package/dist/config/issue-validation.js +264 -0
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +22 -0
- package/dist/config/resolver.d.ts +25 -0
- package/dist/config/resolver.js +183 -0
- package/dist/config/status-defaults.d.ts +13 -0
- package/dist/config/status-defaults.js +20 -0
- package/dist/config/term-enforcer.d.ts +31 -0
- package/dist/config/term-enforcer.js +69 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +76 -0
- package/dist/queries/attachments.d.ts +3 -0
- package/dist/queries/attachments.js +35 -0
- package/dist/queries/comments.d.ts +3 -0
- package/dist/queries/comments.js +64 -0
- package/dist/queries/common.d.ts +2 -0
- package/dist/queries/common.js +109 -0
- package/dist/queries/cycles.d.ts +2 -0
- package/dist/queries/cycles.js +40 -0
- package/dist/queries/documents.d.ts +5 -0
- package/dist/queries/documents.js +67 -0
- package/dist/queries/introspect.d.ts +2 -0
- package/dist/queries/introspect.js +24 -0
- package/dist/queries/issues.d.ts +23 -0
- package/dist/queries/issues.js +380 -0
- package/dist/queries/labels.d.ts +4 -0
- package/dist/queries/labels.js +53 -0
- package/dist/queries/project-milestones.d.ts +6 -0
- package/dist/queries/project-milestones.js +125 -0
- package/dist/queries/projects.d.ts +7 -0
- package/dist/queries/projects.js +104 -0
- package/dist/queries/releases.d.ts +4 -0
- package/dist/queries/releases.js +85 -0
- package/dist/queries/search.d.ts +1 -0
- package/dist/queries/search.js +35 -0
- package/dist/queries/templates.d.ts +2 -0
- package/dist/queries/templates.js +30 -0
- package/dist/types/linear.d.ts +217 -0
- package/dist/types/linear.js +6 -0
- package/dist/utils/auth.d.ts +4 -0
- package/dist/utils/auth.js +23 -0
- package/dist/utils/auto-link-references.d.ts +47 -0
- package/dist/utils/auto-link-references.js +188 -0
- package/dist/utils/date-format.d.ts +4 -0
- package/dist/utils/date-format.js +8 -0
- package/dist/utils/download-uploads.d.ts +7 -0
- package/dist/utils/download-uploads.js +88 -0
- package/dist/utils/embed-parser.d.ts +8 -0
- package/dist/utils/embed-parser.js +54 -0
- package/dist/utils/error-messages.d.ts +4 -0
- package/dist/utils/error-messages.js +17 -0
- package/dist/utils/file-service.d.ts +13 -0
- package/dist/utils/file-service.js +239 -0
- package/dist/utils/gdoc-parser.d.ts +45 -0
- package/dist/utils/gdoc-parser.js +107 -0
- package/dist/utils/graphql-attachments-service.d.ts +12 -0
- package/dist/utils/graphql-attachments-service.js +46 -0
- package/dist/utils/graphql-documents-service.d.ts +18 -0
- package/dist/utils/graphql-documents-service.js +97 -0
- package/dist/utils/graphql-issues-service.d.ts +49 -0
- package/dist/utils/graphql-issues-service.js +925 -0
- package/dist/utils/graphql-service.d.ts +8 -0
- package/dist/utils/graphql-service.js +35 -0
- package/dist/utils/identifier-parser.d.ts +7 -0
- package/dist/utils/identifier-parser.js +20 -0
- package/dist/utils/issue-reference-extractor.d.ts +18 -0
- package/dist/utils/issue-reference-extractor.js +95 -0
- package/dist/utils/issue-reference-wrapper.d.ts +12 -0
- package/dist/utils/issue-reference-wrapper.js +91 -0
- package/dist/utils/linear-service.d.ts +26 -0
- package/dist/utils/linear-service.js +442 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.js +8 -0
- package/dist/utils/markdown-prosemirror.d.ts +24 -0
- package/dist/utils/markdown-prosemirror.js +325 -0
- package/dist/utils/mention-resolver.d.ts +31 -0
- package/dist/utils/mention-resolver.js +234 -0
- package/dist/utils/output.d.ts +7 -0
- package/dist/utils/output.js +125 -0
- package/dist/utils/table-formatter.d.ts +4 -0
- package/dist/utils/table-formatter.js +249 -0
- package/dist/utils/usage.d.ts +2 -0
- package/dist/utils/usage.js +24 -0
- package/dist/utils/uuid.d.ts +2 -0
- package/dist/utils/uuid.js +8 -0
- package/dist/utils/validate-references.d.ts +10 -0
- package/dist/utils/validate-references.js +33 -0
- package/dist/utils/validators.d.ts +7 -0
- package/dist/utils/validators.js +71 -0
- package/dist/utils/workspace-url.d.ts +4 -0
- package/dist/utils/workspace-url.js +44 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { loadConfig } from "../config/config.js";
|
|
4
|
+
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
5
|
+
import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
|
|
6
|
+
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
7
|
+
import { enforceTerms } from "../config/term-enforcer.js";
|
|
8
|
+
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, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
10
|
+
import { getApiToken } from "../utils/auth.js";
|
|
11
|
+
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
12
|
+
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
13
|
+
import { FileService } from "../utils/file-service.js";
|
|
14
|
+
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
|
+
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
16
|
+
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
|
+
import { createLinearService, } from "../utils/linear-service.js";
|
|
20
|
+
import { logger } from "../utils/logger.js";
|
|
21
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
22
|
+
import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
|
|
23
|
+
import { validateReferences } from "../utils/validate-references.js";
|
|
24
|
+
import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
|
|
25
|
+
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
26
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
27
|
+
".png",
|
|
28
|
+
".jpg",
|
|
29
|
+
".jpeg",
|
|
30
|
+
".gif",
|
|
31
|
+
".webp",
|
|
32
|
+
".svg",
|
|
33
|
+
".ico",
|
|
34
|
+
]);
|
|
35
|
+
const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
|
|
36
|
+
const COMMIT_HASH_PREFIX_REGEX = /^[a-f0-9]+ /;
|
|
37
|
+
/**
|
|
38
|
+
* Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
|
|
39
|
+
* "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
|
|
40
|
+
*/
|
|
41
|
+
function toBranchName(linearBranchName, prefix = "feature/") {
|
|
42
|
+
// Linear branch names look like "dev-123-some-slug"
|
|
43
|
+
// We need to uppercase the team key: "DEV-123-some-slug"
|
|
44
|
+
const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
|
|
45
|
+
if (!match) {
|
|
46
|
+
return `${prefix}${linearBranchName}`;
|
|
47
|
+
}
|
|
48
|
+
const [, teamKey, number, slug] = match;
|
|
49
|
+
return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check out a new git branch. Warns and skips if not in a git repo.
|
|
53
|
+
* Throws if the branch already exists.
|
|
54
|
+
*/
|
|
55
|
+
function gitCheckoutBranch(branchName) {
|
|
56
|
+
try {
|
|
57
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
outputWarning("Not inside a git repository — skipping branch checkout.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read description from a file path or stdin ("-").
|
|
69
|
+
* Avoids shell escaping issues when descriptions contain special characters.
|
|
70
|
+
*/
|
|
71
|
+
function readDescriptionFile(filePath) {
|
|
72
|
+
if (filePath === "-") {
|
|
73
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
74
|
+
}
|
|
75
|
+
if (!fs.existsSync(filePath)) {
|
|
76
|
+
throw new Error(`Description file not found: ${filePath}`);
|
|
77
|
+
}
|
|
78
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the description from --description, --description-file, or both.
|
|
82
|
+
* --description-file takes precedence when both are provided.
|
|
83
|
+
*/
|
|
84
|
+
function resolveDescription(options) {
|
|
85
|
+
if (options.descriptionFile) {
|
|
86
|
+
return readDescriptionFile(options.descriptionFile);
|
|
87
|
+
}
|
|
88
|
+
return options.description;
|
|
89
|
+
}
|
|
90
|
+
function isImageFile(filename) {
|
|
91
|
+
const ext = filename.lastIndexOf(".");
|
|
92
|
+
return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
|
|
93
|
+
}
|
|
94
|
+
function transformIssueRelation(rel) {
|
|
95
|
+
const issue = rel.issue;
|
|
96
|
+
const relatedIssue = rel.relatedIssue;
|
|
97
|
+
return {
|
|
98
|
+
id: rel.id,
|
|
99
|
+
type: rel.type,
|
|
100
|
+
issue: {
|
|
101
|
+
id: issue.id,
|
|
102
|
+
identifier: issue.identifier,
|
|
103
|
+
title: issue.title,
|
|
104
|
+
},
|
|
105
|
+
relatedIssue: {
|
|
106
|
+
id: relatedIssue.id,
|
|
107
|
+
identifier: relatedIssue.identifier,
|
|
108
|
+
title: relatedIssue.title,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function createRelations(sourceId, options, graphQLService, linearService) {
|
|
113
|
+
const relations = [];
|
|
114
|
+
const relationSpecs = [
|
|
115
|
+
{ option: "relatedTo", type: "related" },
|
|
116
|
+
{ option: "blocks", type: "blocks" },
|
|
117
|
+
{ option: "blockedBy", type: "blocks", reverse: true },
|
|
118
|
+
{ option: "duplicateOf", type: "duplicate" },
|
|
119
|
+
];
|
|
120
|
+
for (const spec of relationSpecs) {
|
|
121
|
+
const value = options[spec.option];
|
|
122
|
+
if (!value) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const id of splitList(value)) {
|
|
126
|
+
const targetId = await linearService.resolveIssueId(id);
|
|
127
|
+
const rel = await graphQLService.rawRequest(ISSUE_RELATION_CREATE_MUTATION, {
|
|
128
|
+
input: {
|
|
129
|
+
issueId: spec.reverse ? targetId : sourceId,
|
|
130
|
+
relatedIssueId: spec.reverse ? sourceId : targetId,
|
|
131
|
+
type: spec.type,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const create = rel.issueRelationCreate;
|
|
135
|
+
const issueRelation = create?.issueRelation;
|
|
136
|
+
relations.push(transformIssueRelation(issueRelation));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return relations;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Pre-process a description before sending to Linear:
|
|
143
|
+
* 1. Extract all issue references from the description.
|
|
144
|
+
* 2. Validate them (drop those that don't resolve in this workspace — handles "ISO-1424"-style false positives).
|
|
145
|
+
* 3. Wrap valid identifiers as markdown links — skipping any already inside a link, code block, or backtick span.
|
|
146
|
+
*
|
|
147
|
+
* Returns the rewritten description plus the validated map for downstream auto-linking.
|
|
148
|
+
*
|
|
149
|
+
* No-ops (returns the original description with an empty map) when:
|
|
150
|
+
* - description is empty/undefined
|
|
151
|
+
* - the user passed `--no-auto-link`
|
|
152
|
+
*/
|
|
153
|
+
async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
|
|
154
|
+
if (!description || options.autoLink === false) {
|
|
155
|
+
return { description, preResolved: new Map(), rewritten: false };
|
|
156
|
+
}
|
|
157
|
+
const refs = extractIssueReferences(description, selfIdentifier);
|
|
158
|
+
if (refs.length === 0) {
|
|
159
|
+
return { description, preResolved: new Map(), rewritten: false };
|
|
160
|
+
}
|
|
161
|
+
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
162
|
+
if (preResolved.size === 0) {
|
|
163
|
+
return { description, preResolved, rewritten: false };
|
|
164
|
+
}
|
|
165
|
+
const validIds = new Set(preResolved.keys());
|
|
166
|
+
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
167
|
+
const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
168
|
+
return {
|
|
169
|
+
description: wrapped,
|
|
170
|
+
preResolved,
|
|
171
|
+
rewritten: wrapped !== description,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Run auto-linking for issue references found in a description, unless the user opted out
|
|
176
|
+
* with --no-auto-link or no description was provided. Returns undefined when nothing was linked
|
|
177
|
+
* and nothing was skipped/failed (so callers can omit the field from JSON output).
|
|
178
|
+
*/
|
|
179
|
+
async function maybeAutoLink(input) {
|
|
180
|
+
const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
|
|
181
|
+
if (options.autoLink === false) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!description) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const result = await autoLinkReferences({
|
|
188
|
+
issueId,
|
|
189
|
+
identifier,
|
|
190
|
+
description,
|
|
191
|
+
graphQLService,
|
|
192
|
+
linearService,
|
|
193
|
+
preResolved,
|
|
194
|
+
});
|
|
195
|
+
if (result.linked.length === 0 &&
|
|
196
|
+
result.skipped.length === 0 &&
|
|
197
|
+
result.failed.length === 0) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
function validateUpdateOptions(options) {
|
|
203
|
+
if (options.parentTicket && options.clearParentTicket) {
|
|
204
|
+
throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
|
|
205
|
+
}
|
|
206
|
+
if (options.projectMilestone && options.clearProjectMilestone) {
|
|
207
|
+
throw new Error("Cannot use --project-milestone and --clear-project-milestone together");
|
|
208
|
+
}
|
|
209
|
+
if (options.cycle && options.clearCycle) {
|
|
210
|
+
throw new Error("Cannot use --cycle and --clear-cycle together");
|
|
211
|
+
}
|
|
212
|
+
if (options.labelBy && !options.labels) {
|
|
213
|
+
throw new Error("--label-by requires --labels to be specified");
|
|
214
|
+
}
|
|
215
|
+
if (options.clearLabels && options.labels) {
|
|
216
|
+
throw new Error("--clear-labels cannot be used with --labels");
|
|
217
|
+
}
|
|
218
|
+
if (options.clearLabels && options.labelBy) {
|
|
219
|
+
throw new Error("--clear-labels cannot be used with --label-by");
|
|
220
|
+
}
|
|
221
|
+
if (options.labelBy && !["adding", "overwriting"].includes(options.labelBy)) {
|
|
222
|
+
throw new Error("--label-by must be either 'adding' or 'overwriting'");
|
|
223
|
+
}
|
|
224
|
+
if (options.description && options.appendDescription) {
|
|
225
|
+
throw new Error("Cannot use --description and --append-description together");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function buildUpdateArgs(issueId, options, assigneeId) {
|
|
229
|
+
let labelIds;
|
|
230
|
+
if (options.clearLabels) {
|
|
231
|
+
labelIds = [];
|
|
232
|
+
}
|
|
233
|
+
else if (options.labels) {
|
|
234
|
+
labelIds = splitList(options.labels);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
id: issueId,
|
|
238
|
+
title: options.title,
|
|
239
|
+
description: options.description,
|
|
240
|
+
statusId: options.status,
|
|
241
|
+
priority: options.priority ? validatePriority(options.priority) : undefined,
|
|
242
|
+
assigneeId,
|
|
243
|
+
projectId: options.project,
|
|
244
|
+
labelIds,
|
|
245
|
+
parentId: options.parentTicket || (options.clearParentTicket ? null : undefined),
|
|
246
|
+
milestoneId: options.projectMilestone ||
|
|
247
|
+
(options.clearProjectMilestone ? null : undefined),
|
|
248
|
+
cycleId: options.cycle || (options.clearCycle ? null : undefined),
|
|
249
|
+
dueDate: options.dueDate || (options.clearDueDate ? null : undefined),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function outputIssues(issues, format, fields, meta) {
|
|
253
|
+
const fieldList = fields ? splitList(fields) : undefined;
|
|
254
|
+
if (format === "table") {
|
|
255
|
+
logger.info(formatTable(issues, fieldList));
|
|
256
|
+
logger.info(`\n${issues.length} issues`);
|
|
257
|
+
}
|
|
258
|
+
else if (format === "md" || format === "markdown") {
|
|
259
|
+
logger.info(formatMarkdown(issues, fieldList));
|
|
260
|
+
}
|
|
261
|
+
else if (format === "csv") {
|
|
262
|
+
logger.info(formatCsv(issues, fieldList));
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
outputSuccess({ data: issues, meta: { count: issues.length, ...meta } });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function sortIssues(issues, sort) {
|
|
269
|
+
if (!sort) {
|
|
270
|
+
return issues;
|
|
271
|
+
}
|
|
272
|
+
const sorted = [...issues];
|
|
273
|
+
switch (sort) {
|
|
274
|
+
case "priority":
|
|
275
|
+
sorted.sort((a, b) => {
|
|
276
|
+
const pa = a.priority === 0 ? 5 : a.priority;
|
|
277
|
+
const pb = b.priority === 0 ? 5 : b.priority;
|
|
278
|
+
return pa - pb;
|
|
279
|
+
});
|
|
280
|
+
break;
|
|
281
|
+
case "status":
|
|
282
|
+
sorted.sort((a, b) => (a.state?.name ?? "").localeCompare(b.state?.name ?? ""));
|
|
283
|
+
break;
|
|
284
|
+
case "created":
|
|
285
|
+
sorted.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
286
|
+
break;
|
|
287
|
+
case "updated":
|
|
288
|
+
sorted.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
289
|
+
break;
|
|
290
|
+
default:
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
return sorted;
|
|
294
|
+
}
|
|
295
|
+
async function handleListIssues(options, command) {
|
|
296
|
+
if (options.label && !options.labels) {
|
|
297
|
+
options.labels = options.label;
|
|
298
|
+
}
|
|
299
|
+
const rootOpts = command.parent.parent.opts();
|
|
300
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
301
|
+
const linearService = createLinearService(rootOpts);
|
|
302
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
303
|
+
const hasFilters = options.team ||
|
|
304
|
+
options.labels ||
|
|
305
|
+
options.status ||
|
|
306
|
+
options.assignee ||
|
|
307
|
+
options.project ||
|
|
308
|
+
options.project === false ||
|
|
309
|
+
options.priority;
|
|
310
|
+
if (hasFilters) {
|
|
311
|
+
const searchArgs = {
|
|
312
|
+
teamId: options.team ? resolveTeam(options.team) : undefined,
|
|
313
|
+
assigneeId: options.assignee
|
|
314
|
+
? await resolveAssignee(options.assignee, rootOpts)
|
|
315
|
+
: undefined,
|
|
316
|
+
projectId: options.project || undefined,
|
|
317
|
+
noProject: options.project === false,
|
|
318
|
+
labelNames: options.labels ? splitList(options.labels) : undefined,
|
|
319
|
+
status: options.status ? splitList(options.status) : undefined,
|
|
320
|
+
priority: options.priority
|
|
321
|
+
? parsePriorityFilter(options.priority)
|
|
322
|
+
: undefined,
|
|
323
|
+
orderBy: options.sort === "created" ? "createdAt" : "updatedAt",
|
|
324
|
+
limit: Number.parseInt(options.limit, 10),
|
|
325
|
+
};
|
|
326
|
+
const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
|
|
327
|
+
outputIssues(result, options.format, options.fields, {
|
|
328
|
+
team: options.team,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const result = sortIssues(await issuesService.getIssues(Number.parseInt(options.limit, 10)), options.sort);
|
|
333
|
+
outputIssues(result, options.format, options.fields, {});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function handleSearchIssues(query, options, command) {
|
|
337
|
+
if (options.label && !options.labels) {
|
|
338
|
+
options.labels = options.label;
|
|
339
|
+
}
|
|
340
|
+
const rootOpts = command.parent.parent.opts();
|
|
341
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
342
|
+
const linearService = createLinearService(rootOpts);
|
|
343
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
344
|
+
const searchArgs = {
|
|
345
|
+
query,
|
|
346
|
+
teamId: options.team ? resolveTeam(options.team) : undefined,
|
|
347
|
+
assigneeId: options.assignee
|
|
348
|
+
? await resolveAssignee(options.assignee, rootOpts)
|
|
349
|
+
: undefined,
|
|
350
|
+
projectId: options.project || undefined,
|
|
351
|
+
noProject: options.project === false,
|
|
352
|
+
status: options.status ? splitList(options.status) : undefined,
|
|
353
|
+
labelNames: options.labels ? splitList(options.labels) : undefined,
|
|
354
|
+
priority: options.priority
|
|
355
|
+
? parsePriorityFilter(options.priority)
|
|
356
|
+
: undefined,
|
|
357
|
+
limit: Number.parseInt(options.limit, 10),
|
|
358
|
+
};
|
|
359
|
+
const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
|
|
360
|
+
outputIssues(result, options.format, options.fields, { query });
|
|
361
|
+
}
|
|
362
|
+
async function resolveCreateInputs(title, options, rootOpts) {
|
|
363
|
+
const config = loadConfig();
|
|
364
|
+
enforceTerms([title, options.description], { strict: options.strict });
|
|
365
|
+
// --- Validation (labels, description, assignee, project, title) ---
|
|
366
|
+
// Controlled by config.validation.enabled (default: true).
|
|
367
|
+
// Bypassed by --skip-validation flag.
|
|
368
|
+
if (!options.skipValidation) {
|
|
369
|
+
const rawLabels = options.labels ? splitList(options.labels) : null;
|
|
370
|
+
const description = resolveDescription(options);
|
|
371
|
+
const validationResult = validateIssueCreation({
|
|
372
|
+
labels: rawLabels,
|
|
373
|
+
description: description || undefined,
|
|
374
|
+
title,
|
|
375
|
+
assignee: options.assignee,
|
|
376
|
+
project: options.project,
|
|
377
|
+
});
|
|
378
|
+
// Apply normalized labels back so resolution uses the canonical names
|
|
379
|
+
if (validationResult.normalizedLabels) {
|
|
380
|
+
options.labels = validationResult.normalizedLabels.join(",");
|
|
381
|
+
}
|
|
382
|
+
enforceValidation(validationResult);
|
|
383
|
+
}
|
|
384
|
+
if (!options.priority) {
|
|
385
|
+
outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
|
|
386
|
+
}
|
|
387
|
+
const teamInput = options.team || config.defaultTeam;
|
|
388
|
+
const teamId = resolveTeam(teamInput);
|
|
389
|
+
const assigneeId = options.assignee
|
|
390
|
+
? await resolveAssignee(options.assignee, rootOpts)
|
|
391
|
+
: undefined;
|
|
392
|
+
let labelIds = [];
|
|
393
|
+
if (options.labels) {
|
|
394
|
+
labelIds = resolveLabels(splitList(options.labels), teamInput);
|
|
395
|
+
}
|
|
396
|
+
if (options.claude) {
|
|
397
|
+
const claudeId = config.labels.workspace.claude;
|
|
398
|
+
if (claudeId && !labelIds.includes(claudeId)) {
|
|
399
|
+
labelIds.push(claudeId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const status = resolveDefaultStatus({
|
|
403
|
+
explicitStatus: options.status,
|
|
404
|
+
hasAssignee: !!assigneeId,
|
|
405
|
+
hasProject: !!options.project,
|
|
406
|
+
});
|
|
407
|
+
let subscriberIds;
|
|
408
|
+
if (options.subscriber) {
|
|
409
|
+
subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
|
|
410
|
+
}
|
|
411
|
+
return { teamInput, teamId, assigneeId, labelIds, status, subscriberIds };
|
|
412
|
+
}
|
|
413
|
+
async function uploadAttachmentsIfNeeded(options, rootOpts) {
|
|
414
|
+
if (!options.attachment) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
const paths = Array.isArray(options.attachment)
|
|
418
|
+
? options.attachment
|
|
419
|
+
: [options.attachment];
|
|
420
|
+
const apiToken = getApiToken(rootOpts);
|
|
421
|
+
const fileService = new FileService(apiToken);
|
|
422
|
+
const results = [];
|
|
423
|
+
for (const filePath of paths) {
|
|
424
|
+
const result = await fileService.uploadFile(filePath);
|
|
425
|
+
if (!result.success) {
|
|
426
|
+
throw new Error(`Attachment upload failed for ${filePath}: ${result.error}`);
|
|
427
|
+
}
|
|
428
|
+
results.push(result);
|
|
429
|
+
}
|
|
430
|
+
return results;
|
|
431
|
+
}
|
|
432
|
+
function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
433
|
+
let description = baseDescription;
|
|
434
|
+
for (const uploadResult of uploadResults) {
|
|
435
|
+
if (isImageFile(uploadResult.filename)) {
|
|
436
|
+
const imageMarkdown = `\n\n`;
|
|
437
|
+
description = description
|
|
438
|
+
? description + imageMarkdown
|
|
439
|
+
: imageMarkdown.trimStart();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return description;
|
|
443
|
+
}
|
|
444
|
+
async function handleCreateIssue(title, options, command) {
|
|
445
|
+
const rootOpts = command.parent.parent.opts();
|
|
446
|
+
const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
|
|
447
|
+
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
448
|
+
const description = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
|
|
449
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
450
|
+
const linearService = createLinearService(rootOpts);
|
|
451
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
452
|
+
// Wrap valid issue identifiers as markdown links before creating, so the description
|
|
453
|
+
// saved on Linear has clickable refs from the start. Self-reference can't apply here
|
|
454
|
+
// because the issue doesn't exist yet — pass undefined.
|
|
455
|
+
const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
|
|
456
|
+
const result = await issuesService.createIssue({
|
|
457
|
+
title,
|
|
458
|
+
teamId,
|
|
459
|
+
teamInput,
|
|
460
|
+
description: prepared.description,
|
|
461
|
+
assigneeId,
|
|
462
|
+
priority: options.priority ? validatePriority(options.priority) : undefined,
|
|
463
|
+
projectId: options.project,
|
|
464
|
+
statusId: status,
|
|
465
|
+
labelIds: labelIds.length > 0 ? labelIds : undefined,
|
|
466
|
+
parentId: options.parentTicket,
|
|
467
|
+
milestoneId: options.projectMilestone,
|
|
468
|
+
cycleId: options.cycle,
|
|
469
|
+
subscriberIds,
|
|
470
|
+
dueDate: options.dueDate,
|
|
471
|
+
});
|
|
472
|
+
const relations = await createRelations(result.id, options, graphQLService, linearService);
|
|
473
|
+
// Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
|
|
474
|
+
// ("blocked by", "duplicates", etc.) sees `keyword DEV-100` instead of `keyword [DEV-100](url)`.
|
|
475
|
+
// After wrapping, the keyword regex's trailing-whitespace anchor fails to match the inserted `[`.
|
|
476
|
+
const autoLinked = await maybeAutoLink({
|
|
477
|
+
issueId: result.id,
|
|
478
|
+
identifier: result.identifier,
|
|
479
|
+
description: description || undefined,
|
|
480
|
+
options,
|
|
481
|
+
preResolved: prepared.preResolved,
|
|
482
|
+
graphQLService,
|
|
483
|
+
linearService,
|
|
484
|
+
});
|
|
485
|
+
// Images are already embedded inline as markdown in the description,
|
|
486
|
+
// so only create separate attachment records for non-image files.
|
|
487
|
+
const attachments = [];
|
|
488
|
+
if (uploadResults.length > 0) {
|
|
489
|
+
const attachmentsService = createGraphQLAttachmentsService(rootOpts);
|
|
490
|
+
for (const uploadResult of uploadResults) {
|
|
491
|
+
if (!isImageFile(uploadResult.filename)) {
|
|
492
|
+
const attachment = await attachmentsService.createAttachment({
|
|
493
|
+
issueId: result.id,
|
|
494
|
+
url: uploadResult.assetUrl,
|
|
495
|
+
title: uploadResult.filename,
|
|
496
|
+
});
|
|
497
|
+
attachments.push(attachment);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
let branch;
|
|
502
|
+
if (options.checkout && result.branchName) {
|
|
503
|
+
branch = toBranchName(result.branchName);
|
|
504
|
+
gitCheckoutBranch(branch);
|
|
505
|
+
}
|
|
506
|
+
const output = {
|
|
507
|
+
...result,
|
|
508
|
+
...(branch ? { branch } : {}),
|
|
509
|
+
...(relations.length > 0 ? { relations } : {}),
|
|
510
|
+
...(autoLinked ? { autoLinked } : {}),
|
|
511
|
+
...(attachments.length === 1
|
|
512
|
+
? { attachment: attachments[0] }
|
|
513
|
+
: attachments.length > 1
|
|
514
|
+
? { attachments }
|
|
515
|
+
: {}),
|
|
516
|
+
};
|
|
517
|
+
outputSuccess(output);
|
|
518
|
+
}
|
|
519
|
+
async function handleHistoryIssue(issueId, _options, command) {
|
|
520
|
+
const rootOpts = command.parent.parent.opts();
|
|
521
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
522
|
+
const linearService = createLinearService(rootOpts);
|
|
523
|
+
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
524
|
+
const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
|
|
525
|
+
id: resolvedId,
|
|
526
|
+
});
|
|
527
|
+
if (!result.issue) {
|
|
528
|
+
throw new Error(`Issue "${issueId}" not found`);
|
|
529
|
+
}
|
|
530
|
+
const issue = result.issue;
|
|
531
|
+
const stateHistory = issue.stateHistory;
|
|
532
|
+
const spans = (stateHistory?.nodes ?? []).map((span) => ({
|
|
533
|
+
state: {
|
|
534
|
+
id: span.state.id,
|
|
535
|
+
name: span.state.name,
|
|
536
|
+
type: span.state.type,
|
|
537
|
+
},
|
|
538
|
+
startedAt: span.startedAt,
|
|
539
|
+
endedAt: span.endedAt || undefined,
|
|
540
|
+
}));
|
|
541
|
+
outputSuccess({
|
|
542
|
+
id: issue.id,
|
|
543
|
+
identifier: issue.identifier,
|
|
544
|
+
title: issue.title,
|
|
545
|
+
stateHistory: spans,
|
|
546
|
+
meta: { count: spans.length },
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async function handleRelateIssue(issueId, options, command) {
|
|
550
|
+
const rootOpts = command.parent.parent.opts();
|
|
551
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
552
|
+
const linearService = createLinearService(rootOpts);
|
|
553
|
+
const sourceId = await linearService.resolveIssueId(issueId);
|
|
554
|
+
const relations = await createRelations(sourceId, options, graphQLService, linearService);
|
|
555
|
+
if (relations.length === 0) {
|
|
556
|
+
throw new Error("Specify at least one of: --related-to, --blocks, --blocked-by, --duplicate-of");
|
|
557
|
+
}
|
|
558
|
+
outputSuccess({
|
|
559
|
+
data: relations,
|
|
560
|
+
meta: { count: relations.length, source: issueId },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Invert relation type for incoming (inverse) relations so the output
|
|
565
|
+
* reads naturally from the perspective of the queried issue.
|
|
566
|
+
* e.g. if DEV-100 "blocks" DEV-200, and we query DEV-200,
|
|
567
|
+
* the inverse relation type is "blocks" but direction is incoming → "blockedBy".
|
|
568
|
+
*/
|
|
569
|
+
function normalizeInverseType(type) {
|
|
570
|
+
if (type === "blocks") {
|
|
571
|
+
return "blockedBy";
|
|
572
|
+
}
|
|
573
|
+
return type;
|
|
574
|
+
}
|
|
575
|
+
function buildRelatedIssueSummary(peer) {
|
|
576
|
+
const state = peer.state;
|
|
577
|
+
const assignee = peer.assignee;
|
|
578
|
+
const team = peer.team;
|
|
579
|
+
return {
|
|
580
|
+
id: peer.id,
|
|
581
|
+
identifier: peer.identifier,
|
|
582
|
+
title: peer.title,
|
|
583
|
+
...(state
|
|
584
|
+
? { state: { id: state.id, name: state.name } }
|
|
585
|
+
: {}),
|
|
586
|
+
...(peer.priority == null ? {} : { priority: peer.priority }),
|
|
587
|
+
...(assignee
|
|
588
|
+
? {
|
|
589
|
+
assignee: {
|
|
590
|
+
id: assignee.id,
|
|
591
|
+
name: assignee.name,
|
|
592
|
+
},
|
|
593
|
+
}
|
|
594
|
+
: {}),
|
|
595
|
+
...(team
|
|
596
|
+
? {
|
|
597
|
+
team: {
|
|
598
|
+
id: team.id,
|
|
599
|
+
key: team.key,
|
|
600
|
+
name: team.name,
|
|
601
|
+
},
|
|
602
|
+
}
|
|
603
|
+
: {}),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
|
|
607
|
+
const entries = [];
|
|
608
|
+
for (const rel of nodes ?? []) {
|
|
609
|
+
const peer = rel[peerKey];
|
|
610
|
+
if (!peer) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
entries.push({
|
|
614
|
+
id: rel.id,
|
|
615
|
+
type: normalizeType(rel.type),
|
|
616
|
+
direction,
|
|
617
|
+
issue: buildRelatedIssueSummary(peer),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return entries;
|
|
621
|
+
}
|
|
622
|
+
async function handleRelatedIssues(issueId, _options, command) {
|
|
623
|
+
const rootOpts = command.parent.parent.opts();
|
|
624
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
625
|
+
const linearService = createLinearService(rootOpts);
|
|
626
|
+
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
627
|
+
const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
|
|
628
|
+
id: resolvedId,
|
|
629
|
+
});
|
|
630
|
+
if (!result.issue) {
|
|
631
|
+
throw new Error(`Issue "${issueId}" not found`);
|
|
632
|
+
}
|
|
633
|
+
const issue = result.issue;
|
|
634
|
+
const relations = issue.relations;
|
|
635
|
+
const inverseRelations = issue.inverseRelations;
|
|
636
|
+
const entries = [
|
|
637
|
+
...buildRelationEntries(relations?.nodes, "relatedIssue", "outgoing", (raw) => raw),
|
|
638
|
+
...buildRelationEntries(inverseRelations?.nodes, "issue", "incoming", normalizeInverseType),
|
|
639
|
+
];
|
|
640
|
+
outputSuccess({
|
|
641
|
+
id: issue.id,
|
|
642
|
+
identifier: issue.identifier,
|
|
643
|
+
title: issue.title,
|
|
644
|
+
data: entries,
|
|
645
|
+
meta: { count: entries.length },
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
async function fetchCommentBodies(issueUuid, graphQLService) {
|
|
649
|
+
const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
|
|
650
|
+
issueId: issueUuid,
|
|
651
|
+
first: 250,
|
|
652
|
+
});
|
|
653
|
+
const issue = result.issue;
|
|
654
|
+
const comments = issue?.comments;
|
|
655
|
+
const nodes = comments?.nodes ?? [];
|
|
656
|
+
return nodes
|
|
657
|
+
.map((c) => c.body)
|
|
658
|
+
.filter((b) => typeof b === "string" && b.length > 0);
|
|
659
|
+
}
|
|
660
|
+
async function linkReferencesForIssue(args) {
|
|
661
|
+
const comments = args.includeComments
|
|
662
|
+
? await fetchCommentBodies(args.issueUuid, args.graphQLService)
|
|
663
|
+
: undefined;
|
|
664
|
+
return autoLinkReferences({
|
|
665
|
+
issueId: args.issueUuid,
|
|
666
|
+
identifier: args.identifier,
|
|
667
|
+
description: args.description,
|
|
668
|
+
comments,
|
|
669
|
+
dryRun: args.dryRun,
|
|
670
|
+
graphQLService: args.graphQLService,
|
|
671
|
+
linearService: args.linearService,
|
|
672
|
+
preResolved: args.preResolved,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
|
|
677
|
+
* and return the rewritten text (or undefined if nothing would change).
|
|
678
|
+
*/
|
|
679
|
+
async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
|
|
680
|
+
if (!description) {
|
|
681
|
+
return { preResolved: undefined, wrapped: undefined };
|
|
682
|
+
}
|
|
683
|
+
const refs = extractIssueReferences(description, selfIdentifier);
|
|
684
|
+
if (refs.length === 0) {
|
|
685
|
+
return { preResolved: undefined, wrapped: undefined };
|
|
686
|
+
}
|
|
687
|
+
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
688
|
+
if (preResolved.size === 0) {
|
|
689
|
+
return { preResolved, wrapped: undefined };
|
|
690
|
+
}
|
|
691
|
+
const validIds = new Set(preResolved.keys());
|
|
692
|
+
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
693
|
+
const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
|
|
694
|
+
return {
|
|
695
|
+
preResolved,
|
|
696
|
+
wrapped: wrapped === description ? undefined : wrapped,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
|
|
700
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
|
|
701
|
+
id: issueUuid,
|
|
702
|
+
input: { description },
|
|
703
|
+
});
|
|
704
|
+
const mutation = updateResult.issueUpdate;
|
|
705
|
+
if (!mutation?.success) {
|
|
706
|
+
throw new Error("Failed to rewrite description");
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
|
|
710
|
+
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
711
|
+
// GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
|
|
712
|
+
// we need for the description-only path. Comments are fetched separately on demand.
|
|
713
|
+
const issueResult = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
|
|
714
|
+
id: resolvedId,
|
|
715
|
+
});
|
|
716
|
+
if (!issueResult.issue) {
|
|
717
|
+
throw new Error(`Issue "${issueId}" not found`);
|
|
718
|
+
}
|
|
719
|
+
const issue = issueResult.issue;
|
|
720
|
+
const identifier = issue.identifier;
|
|
721
|
+
const description = issue.description ?? "";
|
|
722
|
+
const dryRun = Boolean(options.dryRun);
|
|
723
|
+
const includeComments = Boolean(options.includeComments);
|
|
724
|
+
const rewriteDescription = Boolean(options.rewriteDescription);
|
|
725
|
+
// If rewriting, validate refs once up front so we can both wrap the description and
|
|
726
|
+
// pass the resolved map down to autoLink — avoids a second resolve round trip.
|
|
727
|
+
const rewrite = rewriteDescription
|
|
728
|
+
? await prepareDescriptionRewrite(description, identifier, linearService, graphQLService)
|
|
729
|
+
: { preResolved: undefined, wrapped: undefined };
|
|
730
|
+
const autoLinked = await linkReferencesForIssue({
|
|
731
|
+
issueUuid: resolvedId,
|
|
732
|
+
identifier,
|
|
733
|
+
description,
|
|
734
|
+
includeComments,
|
|
735
|
+
dryRun,
|
|
736
|
+
graphQLService,
|
|
737
|
+
linearService,
|
|
738
|
+
preResolved: rewrite.preResolved,
|
|
739
|
+
});
|
|
740
|
+
let descriptionRewritten = false;
|
|
741
|
+
if (rewriteDescription && rewrite.wrapped && !dryRun) {
|
|
742
|
+
await pushDescriptionUpdate(resolvedId, rewrite.wrapped, graphQLService);
|
|
743
|
+
descriptionRewritten = true;
|
|
744
|
+
}
|
|
745
|
+
outputSuccess({
|
|
746
|
+
id: resolvedId,
|
|
747
|
+
identifier,
|
|
748
|
+
title: issue.title,
|
|
749
|
+
autoLinked,
|
|
750
|
+
...(rewriteDescription
|
|
751
|
+
? {
|
|
752
|
+
descriptionRewritten,
|
|
753
|
+
...(dryRun && rewrite.wrapped
|
|
754
|
+
? { descriptionPreview: rewrite.wrapped }
|
|
755
|
+
: {}),
|
|
756
|
+
}
|
|
757
|
+
: {}),
|
|
758
|
+
meta: {
|
|
759
|
+
dryRun,
|
|
760
|
+
includeComments,
|
|
761
|
+
...(rewriteDescription ? { rewriteDescription: true } : {}),
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
async function handleLinkReferencesBatch(teamInput, options, graphQLService, linearService) {
|
|
766
|
+
const teamId = resolveTeam(teamInput);
|
|
767
|
+
const limit = options.limit
|
|
768
|
+
? Number.parseInt(options.limit, 10)
|
|
769
|
+
: 100;
|
|
770
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
771
|
+
throw new Error(`--limit must be a positive integer, got: ${options.limit}`);
|
|
772
|
+
}
|
|
773
|
+
const dryRun = Boolean(options.dryRun);
|
|
774
|
+
const includeComments = Boolean(options.includeComments);
|
|
775
|
+
const scan = await graphQLService.rawRequest(SCAN_ISSUES_QUERY, {
|
|
776
|
+
filter: { team: { id: { eq: teamId } } },
|
|
777
|
+
first: limit,
|
|
778
|
+
});
|
|
779
|
+
const issues = scan.issues?.nodes;
|
|
780
|
+
const nodes = issues ?? [];
|
|
781
|
+
const entries = [];
|
|
782
|
+
for (const node of nodes) {
|
|
783
|
+
const issueUuid = node.id;
|
|
784
|
+
const identifier = node.identifier;
|
|
785
|
+
const description = node.description ?? "";
|
|
786
|
+
const autoLinked = await linkReferencesForIssue({
|
|
787
|
+
issueUuid,
|
|
788
|
+
identifier,
|
|
789
|
+
description,
|
|
790
|
+
includeComments,
|
|
791
|
+
dryRun,
|
|
792
|
+
graphQLService,
|
|
793
|
+
linearService,
|
|
794
|
+
});
|
|
795
|
+
entries.push({ identifier, autoLinked });
|
|
796
|
+
}
|
|
797
|
+
// Summary counts across the batch
|
|
798
|
+
const totalLinked = entries.reduce((n, e) => n + e.autoLinked.linked.length, 0);
|
|
799
|
+
const totalSkipped = entries.reduce((n, e) => n + e.autoLinked.skipped.length, 0);
|
|
800
|
+
const totalFailed = entries.reduce((n, e) => n + e.autoLinked.failed.length, 0);
|
|
801
|
+
outputSuccess({
|
|
802
|
+
data: entries,
|
|
803
|
+
meta: {
|
|
804
|
+
count: entries.length,
|
|
805
|
+
team: teamInput,
|
|
806
|
+
dryRun,
|
|
807
|
+
includeComments,
|
|
808
|
+
totals: {
|
|
809
|
+
linked: totalLinked,
|
|
810
|
+
skipped: totalSkipped,
|
|
811
|
+
failed: totalFailed,
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
|
|
817
|
+
const rootOpts = command.parent.parent.opts();
|
|
818
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
819
|
+
const linearService = createLinearService(rootOpts);
|
|
820
|
+
if (options.team) {
|
|
821
|
+
if (issueIdOrTeamFlag) {
|
|
822
|
+
throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
|
|
823
|
+
}
|
|
824
|
+
if (options.rewriteDescription) {
|
|
825
|
+
throw new Error("--rewrite-description is single-issue only. Use it case by case (one issueId per invocation) — bulk description rewrites bot-author the whole team's history.");
|
|
826
|
+
}
|
|
827
|
+
return handleLinkReferencesBatch(options.team, options, graphQLService, linearService);
|
|
828
|
+
}
|
|
829
|
+
if (!issueIdOrTeamFlag) {
|
|
830
|
+
throw new Error("issueId is required (or use --team <key> to scan a whole team)");
|
|
831
|
+
}
|
|
832
|
+
return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
|
|
833
|
+
}
|
|
834
|
+
async function handleReadIssue(issueIds, _options, command) {
|
|
835
|
+
const rootOpts = command.parent.parent.opts();
|
|
836
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
837
|
+
const linearService = createLinearService(rootOpts);
|
|
838
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
839
|
+
const apiToken = getApiToken(rootOpts);
|
|
840
|
+
if (issueIds.length === 1) {
|
|
841
|
+
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
842
|
+
const resolved = await downloadLinearUploads(issue, apiToken);
|
|
843
|
+
outputSuccess(resolved);
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const results = await Promise.all(issueIds.map(async (id) => {
|
|
847
|
+
const issue = await issuesService.getIssueById(id);
|
|
848
|
+
return downloadLinearUploads(issue, apiToken);
|
|
849
|
+
}));
|
|
850
|
+
outputSuccess(results);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async function handleUpdateIssue(issueId, options, command) {
|
|
854
|
+
// Normalize aliases
|
|
855
|
+
if (options.state && !options.status) {
|
|
856
|
+
options.status = options.state;
|
|
857
|
+
}
|
|
858
|
+
if (options.label && !options.labels) {
|
|
859
|
+
options.labels = options.label;
|
|
860
|
+
}
|
|
861
|
+
// Resolve --description-file before validation
|
|
862
|
+
if (options.descriptionFile) {
|
|
863
|
+
options.description = readDescriptionFile(options.descriptionFile);
|
|
864
|
+
}
|
|
865
|
+
validateUpdateOptions(options);
|
|
866
|
+
const rootOpts = command.parent.parent.opts();
|
|
867
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
868
|
+
const linearService = createLinearService(rootOpts);
|
|
869
|
+
if (options.appendDescription) {
|
|
870
|
+
const resolved = await linearService.resolveIssueId(issueId);
|
|
871
|
+
const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
|
|
872
|
+
const issue = current.issue;
|
|
873
|
+
const existing = issue?.description ?? "";
|
|
874
|
+
options.description = `${existing}\n${options.appendDescription}`;
|
|
875
|
+
}
|
|
876
|
+
if (options.title || options.description) {
|
|
877
|
+
enforceTerms([options.title, options.description], {
|
|
878
|
+
strict: options.strict,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
// Save the original (pre-wrap) description so we can pass it to maybeAutoLink later.
|
|
882
|
+
// The wrapped form breaks prose-keyword inference because the inserted `[` defeats the
|
|
883
|
+
// trailing-whitespace anchor in patterns like /\bblocked by\s*$/.
|
|
884
|
+
const originalDescription = options.description;
|
|
885
|
+
// Wrap valid refs as markdown links before sending the update. Idempotent against
|
|
886
|
+
// already-wrapped refs (the wrapper skips text inside existing markdown link syntax).
|
|
887
|
+
const prepared = options.description
|
|
888
|
+
? await prepareAutoLinkedDescription(options.description, options, undefined, linearService, graphQLService)
|
|
889
|
+
: {
|
|
890
|
+
description: undefined,
|
|
891
|
+
preResolved: new Map(),
|
|
892
|
+
rewritten: false,
|
|
893
|
+
};
|
|
894
|
+
if (prepared.description !== undefined) {
|
|
895
|
+
options.description = prepared.description;
|
|
896
|
+
}
|
|
897
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
898
|
+
const assigneeId = options.assignee
|
|
899
|
+
? await resolveAssignee(options.assignee, rootOpts)
|
|
900
|
+
: undefined;
|
|
901
|
+
const updateArgs = buildUpdateArgs(issueId, options, assigneeId);
|
|
902
|
+
const result = await issuesService.updateIssue(updateArgs, options.labelBy || "adding");
|
|
903
|
+
const autoLinked = originalDescription
|
|
904
|
+
? await maybeAutoLink({
|
|
905
|
+
issueId: result.id,
|
|
906
|
+
identifier: result.identifier,
|
|
907
|
+
description: originalDescription,
|
|
908
|
+
options,
|
|
909
|
+
preResolved: prepared.preResolved,
|
|
910
|
+
graphQLService,
|
|
911
|
+
linearService,
|
|
912
|
+
})
|
|
913
|
+
: undefined;
|
|
914
|
+
outputSuccess(autoLinked ? { ...result, autoLinked } : result);
|
|
915
|
+
}
|
|
916
|
+
async function handleRetrolink(options, command) {
|
|
917
|
+
// 1. Read current branch
|
|
918
|
+
let currentBranch;
|
|
919
|
+
try {
|
|
920
|
+
currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
921
|
+
stdio: "pipe",
|
|
922
|
+
})
|
|
923
|
+
.toString()
|
|
924
|
+
.trim();
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
throw new Error("Not inside a git repository.");
|
|
928
|
+
}
|
|
929
|
+
if (currentBranch === "main" || currentBranch === "master") {
|
|
930
|
+
throw new Error(`Cannot retrolink the '${currentBranch}' branch.`);
|
|
931
|
+
}
|
|
932
|
+
// 2. Read commit messages on this branch vs base
|
|
933
|
+
const base = options.base || "main";
|
|
934
|
+
let commitLog;
|
|
935
|
+
try {
|
|
936
|
+
commitLog = execFileSync("git", ["log", `${base}..HEAD`, "--oneline"], {
|
|
937
|
+
stdio: "pipe",
|
|
938
|
+
})
|
|
939
|
+
.toString()
|
|
940
|
+
.trim();
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
commitLog = "";
|
|
944
|
+
}
|
|
945
|
+
const commits = commitLog
|
|
946
|
+
? commitLog
|
|
947
|
+
.split("\n")
|
|
948
|
+
.map((line) => line.replace(COMMIT_HASH_PREFIX_REGEX, ""))
|
|
949
|
+
: [];
|
|
950
|
+
// 3. Build title and description
|
|
951
|
+
const title = options.title || (commits.length > 0 ? commits[0] : currentBranch);
|
|
952
|
+
const description = commits.length > 0
|
|
953
|
+
? commits.map((msg) => `- ${msg}`).join("\n")
|
|
954
|
+
: `Retrolinked from branch: ${currentBranch}`;
|
|
955
|
+
const rootOpts = command.parent.parent.opts();
|
|
956
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
957
|
+
const linearService = createLinearService(rootOpts);
|
|
958
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
959
|
+
let result;
|
|
960
|
+
if (options.issue) {
|
|
961
|
+
// Link to existing issue — fetch it and rename branch
|
|
962
|
+
const issueId = options.issue;
|
|
963
|
+
result = await issuesService.getIssueById(issueId);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
// Create new issue
|
|
967
|
+
const config = loadConfig();
|
|
968
|
+
const teamInput = options.team || config.defaultTeam;
|
|
969
|
+
if (!teamInput) {
|
|
970
|
+
throw new Error("Team is required. Use --team or set a defaultTeam in config.");
|
|
971
|
+
}
|
|
972
|
+
const teamId = resolveTeam(teamInput);
|
|
973
|
+
result = await issuesService.createIssue({
|
|
974
|
+
title,
|
|
975
|
+
teamId,
|
|
976
|
+
description,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
// Rename branch to match issue
|
|
980
|
+
const oldBranch = currentBranch;
|
|
981
|
+
let newBranch;
|
|
982
|
+
if (result.branchName) {
|
|
983
|
+
newBranch = toBranchName(result.branchName);
|
|
984
|
+
execFileSync("git", ["branch", "-m", oldBranch, newBranch], {
|
|
985
|
+
stdio: "pipe",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
outputSuccess({
|
|
989
|
+
...result,
|
|
990
|
+
oldBranch,
|
|
991
|
+
...(newBranch ? { branch: newBranch } : {}),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
export function setupIssuesCommands(program) {
|
|
995
|
+
const issues = program
|
|
996
|
+
.command("issues")
|
|
997
|
+
.alias("issue")
|
|
998
|
+
.description("Issue operations");
|
|
999
|
+
issues.action(() => issues.help());
|
|
1000
|
+
issues
|
|
1001
|
+
.command("list")
|
|
1002
|
+
.description("List issues.")
|
|
1003
|
+
.option("-l, --limit <number>", "limit results", "25")
|
|
1004
|
+
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1005
|
+
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
1006
|
+
.option("--project <project>", "filter by project name or ID")
|
|
1007
|
+
.option("--no-project", "filter issues with no project assigned")
|
|
1008
|
+
.option("--labels <labels>", "filter by labels (comma-separated names)")
|
|
1009
|
+
.option("--label <labels>", "alias for --labels")
|
|
1010
|
+
.option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
|
|
1011
|
+
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1012
|
+
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1013
|
+
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
1014
|
+
.option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
|
|
1015
|
+
.action(handleAsyncCommand(handleListIssues));
|
|
1016
|
+
issues
|
|
1017
|
+
.command("search <query>")
|
|
1018
|
+
.description("Search issues.")
|
|
1019
|
+
.option("--team <team>", "filter by team key, name, or ID")
|
|
1020
|
+
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
1021
|
+
.option("--project <project>", "filter by project name or ID")
|
|
1022
|
+
.option("--no-project", "filter issues with no project assigned")
|
|
1023
|
+
.option("--status <status>", "filter by status (comma-separated)")
|
|
1024
|
+
.option("--labels <labels>", "filter by labels (comma-separated names)")
|
|
1025
|
+
.option("--label <labels>", "alias for --labels")
|
|
1026
|
+
.option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
|
|
1027
|
+
.option("--sort <field>", "sort results (priority, status, created, updated)")
|
|
1028
|
+
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
1029
|
+
.option("--fields <fields>", "columns for table/csv output")
|
|
1030
|
+
.option("-l, --limit <number>", "limit results", "10")
|
|
1031
|
+
.action(handleAsyncCommand(handleSearchIssues));
|
|
1032
|
+
issues
|
|
1033
|
+
.command("create [title]")
|
|
1034
|
+
.description("Create new issue (EL-enhanced: name/alias resolution, brand validation, status defaults).")
|
|
1035
|
+
.option("-t, --title <title>", "issue title (alternative to positional argument)")
|
|
1036
|
+
.option("-d, --description <desc>", "issue description")
|
|
1037
|
+
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1038
|
+
.option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
|
|
1039
|
+
.option("-p, --priority <priority>", "priority: name (urgent/high/medium/low) or number (1-4)")
|
|
1040
|
+
.option("--project <project>", "add to project (name or ID)")
|
|
1041
|
+
.option("--team <team>", "team key or name (default: from config)")
|
|
1042
|
+
.option("--labels <labels>", "labels (comma-separated names, auto-resolved per team)")
|
|
1043
|
+
.option("--label <labels>", "alias for --labels")
|
|
1044
|
+
.option("--claude", "add the workspace-level 'claude' label")
|
|
1045
|
+
.option("--project-milestone <milestone>", "project milestone name or ID")
|
|
1046
|
+
.option("--cycle <cycle>", "cycle name or ID")
|
|
1047
|
+
.option("--status <status>", "status name or ID")
|
|
1048
|
+
.option("--parent-ticket <parentId>", "parent issue ID or identifier")
|
|
1049
|
+
.option("--subscriber <subscribers>", "subscribers (comma-separated names, aliases, or UUIDs)")
|
|
1050
|
+
.option("--strict", "strict brand validation (error instead of warning)")
|
|
1051
|
+
.option("--related-to <issues>", "related issues (comma-separated identifiers)")
|
|
1052
|
+
.option("--blocks <issues>", "issues this blocks (comma-separated identifiers)")
|
|
1053
|
+
.option("--blocked-by <issues>", "issues blocking this (comma-separated identifiers)")
|
|
1054
|
+
.option("--attachment <path>", "attach a file (image, PDF, etc.) to the created issue (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
|
|
1055
|
+
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1056
|
+
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1057
|
+
.option("--skip-validation", "skip all validation (labels, description, assignee, project)")
|
|
1058
|
+
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1059
|
+
.action(handleAsyncCommand((titleArg, options, command) => {
|
|
1060
|
+
// Normalize --label alias to --labels
|
|
1061
|
+
if (options.label && !options.labels) {
|
|
1062
|
+
options.labels = options.label;
|
|
1063
|
+
}
|
|
1064
|
+
const title = options.title || titleArg;
|
|
1065
|
+
if (!title) {
|
|
1066
|
+
throw new Error("Title is required. Provide it as a positional argument or with --title.");
|
|
1067
|
+
}
|
|
1068
|
+
return handleCreateIssue(title, options, command);
|
|
1069
|
+
}));
|
|
1070
|
+
issues
|
|
1071
|
+
.command("read <issueId...>")
|
|
1072
|
+
.alias("view")
|
|
1073
|
+
.alias("get")
|
|
1074
|
+
.alias("show")
|
|
1075
|
+
.description("Get issue details. Accepts multiple IDs for batch retrieval.")
|
|
1076
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
|
|
1077
|
+
.action(handleAsyncCommand(handleReadIssue));
|
|
1078
|
+
issues
|
|
1079
|
+
.command("update <issueId>")
|
|
1080
|
+
.alias("edit")
|
|
1081
|
+
.alias("set")
|
|
1082
|
+
.description("Update an issue (EL-enhanced: name/alias resolution).")
|
|
1083
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
1084
|
+
.option("-t, --title <title>", "new title")
|
|
1085
|
+
.option("-d, --description <desc>", "new description")
|
|
1086
|
+
.option("--description-file <path>", "read description from file (use - for stdin)")
|
|
1087
|
+
.option("--append-description <text>", "append text to the existing description")
|
|
1088
|
+
.option("-s, --status <status>", "new status name or ID")
|
|
1089
|
+
.option("--state <status>", "alias for --status (new status name or ID)")
|
|
1090
|
+
.option("-p, --priority <priority>", "new priority: name (urgent/high/medium/low) or number (1-4)")
|
|
1091
|
+
.option("--assignee <assignee>", "new assignee (name, alias, or UUID)")
|
|
1092
|
+
.option("--project <project>", "new project (name or ID)")
|
|
1093
|
+
.option("--labels <labels>", "labels (comma-separated names or IDs)")
|
|
1094
|
+
.option("--label <labels>", "alias for --labels")
|
|
1095
|
+
.option("--label-by <mode>", "how to apply labels: 'adding' (default) or 'overwriting'")
|
|
1096
|
+
.option("--clear-labels", "remove all labels from issue")
|
|
1097
|
+
.option("--parent-ticket <parentId>", "set parent issue")
|
|
1098
|
+
.option("--clear-parent-ticket", "clear parent relationship")
|
|
1099
|
+
.option("--project-milestone <milestone>", "set project milestone")
|
|
1100
|
+
.option("--clear-project-milestone", "clear project milestone")
|
|
1101
|
+
.option("--cycle <cycle>", "set cycle")
|
|
1102
|
+
.option("--clear-cycle", "clear cycle")
|
|
1103
|
+
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1104
|
+
.option("--clear-due-date", "clear due date")
|
|
1105
|
+
.option("--strict", "strict brand validation")
|
|
1106
|
+
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1107
|
+
.action(handleAsyncCommand(handleUpdateIssue));
|
|
1108
|
+
issues
|
|
1109
|
+
.command("history <issueId>")
|
|
1110
|
+
.description("Show issue state transition history (time in each status).")
|
|
1111
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
1112
|
+
.action(handleAsyncCommand(handleHistoryIssue));
|
|
1113
|
+
issues
|
|
1114
|
+
.command("relate <issueId>")
|
|
1115
|
+
.description("Create issue relations.")
|
|
1116
|
+
.option("--related-to <issues>", "related issues (comma-separated)")
|
|
1117
|
+
.option("--blocks <issues>", "issues this blocks (comma-separated)")
|
|
1118
|
+
.option("--blocked-by <issues>", "issues blocking this (comma-separated)")
|
|
1119
|
+
.option("--duplicate-of <issue>", "mark as duplicate of another issue")
|
|
1120
|
+
.action(handleAsyncCommand(handleRelateIssue));
|
|
1121
|
+
issues
|
|
1122
|
+
.command("related <issueId>")
|
|
1123
|
+
.alias("relations")
|
|
1124
|
+
.description("List all relations for an issue (related, blocks, blockedBy, duplicate).")
|
|
1125
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
1126
|
+
.action(handleAsyncCommand(handleRelatedIssues));
|
|
1127
|
+
issues
|
|
1128
|
+
.command("link-references [issueId]")
|
|
1129
|
+
.description("Scan an issue's description (and optionally comments) for issue identifiers and auto-create relations for any not already linked. Default relation is 'related'; prose keywords like 'blocked by' or 'duplicates' upgrade the type.")
|
|
1130
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nSkips references that already have any relation (related, blocks, blockedBy, duplicate) to the source issue.\nKeyword detection: 'blocked by'/'depends on' → blocks, 'blocks' → blocks, 'duplicates'/'duplicate of' → duplicate, 'duplicated by' → duplicate (reversed).\nUse --team <key> to backfill an entire team instead of a single issue.\nUse --rewrite-description to also wrap bare identifiers as markdown links inside the description (single-issue only).")
|
|
1131
|
+
.option("--dry-run", "show what would be linked without creating relations")
|
|
1132
|
+
.option("--include-comments", "also scan comment bodies for references")
|
|
1133
|
+
.option("--team <team>", "batch mode: scan all issues in a team (omit issueId)")
|
|
1134
|
+
.option("--limit <number>", "max issues to scan in --team mode", "100")
|
|
1135
|
+
.option("--rewrite-description", "also rewrite the issue description to wrap bare identifiers as markdown links (single-issue only; not allowed with --team)")
|
|
1136
|
+
.action(handleAsyncCommand(handleLinkReferencesIssue));
|
|
1137
|
+
issues
|
|
1138
|
+
.command("retrolink")
|
|
1139
|
+
.description("Link current branch to a Linear issue. Creates a new issue by default, or links to an existing one with --issue.")
|
|
1140
|
+
.option("--issue <issueId>", "link to an existing issue instead of creating one (e.g. INF-459)")
|
|
1141
|
+
.option("--team <team>", "team key or name (required if creating, not needed with --issue)")
|
|
1142
|
+
.option("--title <title>", "override auto-generated title (default: first commit message)")
|
|
1143
|
+
.option("--base <branch>", "base branch for log comparison", "main")
|
|
1144
|
+
.action(handleAsyncCommand(handleRetrolink));
|
|
1145
|
+
}
|