@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,309 @@
|
|
|
1
|
+
import { resolveAssignee, resolveLabels, resolveTeam, } from "../config/resolver.js";
|
|
2
|
+
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
3
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
|
+
import { splitList } from "../utils/validators.js";
|
|
8
|
+
/**
|
|
9
|
+
* Parse the --filter string into structured search arguments.
|
|
10
|
+
* Format: "status:Backlog team:DEV label:Bug assignee:Alice project:Sprint12"
|
|
11
|
+
*/
|
|
12
|
+
function parseFilterString(filter) {
|
|
13
|
+
const result = {};
|
|
14
|
+
const parts = filter.match(/(\w+):("[^"]+"|[^\s]+)/g);
|
|
15
|
+
if (!parts) {
|
|
16
|
+
throw new Error(`Invalid filter format: "${filter}". ` +
|
|
17
|
+
'Expected key:value pairs, e.g. "status:Backlog team:DEV label:Bug"');
|
|
18
|
+
}
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
const colonIndex = part.indexOf(":");
|
|
21
|
+
const key = part.slice(0, colonIndex);
|
|
22
|
+
const value = part.slice(colonIndex + 1).replace(/^"|"$/g, "");
|
|
23
|
+
result[key] = value;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve issue identifiers from --issues or --filter.
|
|
29
|
+
* Returns LinearIssue[] so we can show a preview.
|
|
30
|
+
*/
|
|
31
|
+
async function resolveTargetIssues(options, rootOpts) {
|
|
32
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
33
|
+
const linearService = createLinearService(rootOpts);
|
|
34
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
35
|
+
if (options.issues) {
|
|
36
|
+
const ids = splitList(options.issues);
|
|
37
|
+
return Promise.all(ids.map((id) => issuesService.getIssueById(id)));
|
|
38
|
+
}
|
|
39
|
+
if (options.filter) {
|
|
40
|
+
const filters = parseFilterString(options.filter);
|
|
41
|
+
const searchArgs = {
|
|
42
|
+
teamId: filters.team ? resolveTeam(filters.team) : undefined,
|
|
43
|
+
assigneeId: filters.assignee
|
|
44
|
+
? await resolveAssignee(filters.assignee, rootOpts)
|
|
45
|
+
: undefined,
|
|
46
|
+
projectId: filters.project || undefined,
|
|
47
|
+
labelNames: filters.label ? splitList(filters.label) : undefined,
|
|
48
|
+
status: filters.status ? splitList(filters.status) : undefined,
|
|
49
|
+
limit: 50,
|
|
50
|
+
};
|
|
51
|
+
return issuesService.searchIssues(searchArgs);
|
|
52
|
+
}
|
|
53
|
+
throw new Error("Specify --issues (comma-separated IDs) or --filter to select issues.");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Print a preview table of issues that will be affected.
|
|
57
|
+
* Returns false if no issues were found.
|
|
58
|
+
*/
|
|
59
|
+
function printPreview(issues, action) {
|
|
60
|
+
if (issues.length === 0) {
|
|
61
|
+
logger.error("No issues matched the given criteria.");
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
logger.error(`\n${action}\n`);
|
|
65
|
+
logger.error(` ${"Identifier".padEnd(14)} ${"Status".padEnd(16)} Title`);
|
|
66
|
+
logger.error(` ${"─".repeat(14)} ${"─".repeat(16)} ${"─".repeat(40)}`);
|
|
67
|
+
for (const issue of issues) {
|
|
68
|
+
const id = issue.identifier.padEnd(14);
|
|
69
|
+
const status = (issue.state?.name ?? "—").padEnd(16);
|
|
70
|
+
const title = issue.title.length > 50 ? `${issue.title.slice(0, 47)}...` : issue.title;
|
|
71
|
+
logger.error(` ${id} ${status} ${title}`);
|
|
72
|
+
}
|
|
73
|
+
logger.error(`\n ${issues.length} issue${issues.length === 1 ? "" : "s"} will be affected.\n`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Execute a batch operation on each issue, collecting results.
|
|
78
|
+
*/
|
|
79
|
+
async function executeBatch(issues, operation) {
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
try {
|
|
83
|
+
await operation(issue);
|
|
84
|
+
results.push({ identifier: issue.identifier, success: true });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
+
results.push({
|
|
89
|
+
identifier: issue.identifier,
|
|
90
|
+
success: false,
|
|
91
|
+
error: message,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { results };
|
|
96
|
+
}
|
|
97
|
+
async function handleBatchAssign(options, command) {
|
|
98
|
+
const rootOpts = command.parent.parent.opts();
|
|
99
|
+
if (!options.assignee) {
|
|
100
|
+
throw new Error("--assignee is required.");
|
|
101
|
+
}
|
|
102
|
+
const issues = await resolveTargetIssues(options, rootOpts);
|
|
103
|
+
const assigneeId = await resolveAssignee(options.assignee, rootOpts);
|
|
104
|
+
if (!options.yes) {
|
|
105
|
+
const hasPreview = printPreview(issues, `Assign ${issues.length} issues to "${options.assignee}":`);
|
|
106
|
+
if (!hasPreview) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
outputWarning("Dry run — pass --yes to execute.");
|
|
110
|
+
outputSuccess({
|
|
111
|
+
dryRun: true,
|
|
112
|
+
action: "assign",
|
|
113
|
+
assignee: options.assignee,
|
|
114
|
+
issues: issues.map((i) => i.identifier),
|
|
115
|
+
count: issues.length,
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
120
|
+
const linearService = createLinearService(rootOpts);
|
|
121
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
122
|
+
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
|
|
123
|
+
outputSuccess({
|
|
124
|
+
action: "assign",
|
|
125
|
+
assignee: options.assignee,
|
|
126
|
+
results,
|
|
127
|
+
meta: {
|
|
128
|
+
total: results.length,
|
|
129
|
+
succeeded: results.filter((r) => r.success).length,
|
|
130
|
+
failed: results.filter((r) => !r.success).length,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function handleBatchLabel(options, command) {
|
|
135
|
+
const rootOpts = command.parent.parent.opts();
|
|
136
|
+
if (!(options.add || options.remove)) {
|
|
137
|
+
throw new Error("Specify --add and/or --remove for labels.");
|
|
138
|
+
}
|
|
139
|
+
const issues = await resolveTargetIssues(options, rootOpts);
|
|
140
|
+
const addLabels = options.add ? splitList(options.add) : [];
|
|
141
|
+
const removeLabels = options.remove ? splitList(options.remove) : [];
|
|
142
|
+
const actionParts = [];
|
|
143
|
+
if (addLabels.length > 0) {
|
|
144
|
+
actionParts.push(`add [${addLabels.join(", ")}]`);
|
|
145
|
+
}
|
|
146
|
+
if (removeLabels.length > 0) {
|
|
147
|
+
actionParts.push(`remove [${removeLabels.join(", ")}]`);
|
|
148
|
+
}
|
|
149
|
+
const actionDesc = `Labels: ${actionParts.join(", ")} on ${issues.length} issues:`;
|
|
150
|
+
if (!options.yes) {
|
|
151
|
+
const hasPreview = printPreview(issues, actionDesc);
|
|
152
|
+
if (!hasPreview) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
outputWarning("Dry run — pass --yes to execute.");
|
|
156
|
+
outputSuccess({
|
|
157
|
+
dryRun: true,
|
|
158
|
+
action: "label",
|
|
159
|
+
add: addLabels,
|
|
160
|
+
remove: removeLabels,
|
|
161
|
+
issues: issues.map((i) => i.identifier),
|
|
162
|
+
count: issues.length,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
167
|
+
const linearService = createLinearService(rootOpts);
|
|
168
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
169
|
+
const removeLower = removeLabels.map((l) => l.toLowerCase());
|
|
170
|
+
const { results } = await executeBatch(issues, (issue) => {
|
|
171
|
+
// Filter out labels to remove (by name, case-insensitive)
|
|
172
|
+
const keepIds = issue.labels
|
|
173
|
+
.filter((l) => !removeLower.includes(l.name.toLowerCase()))
|
|
174
|
+
.map((l) => l.id);
|
|
175
|
+
// Resolve labels to add (may be names that need resolution)
|
|
176
|
+
const teamKey = issue.team?.key;
|
|
177
|
+
const addIds = teamKey ? resolveLabels(addLabels, teamKey) : addLabels;
|
|
178
|
+
// Merge: keep existing (minus removed) + add new (avoiding duplicates)
|
|
179
|
+
const finalLabelIds = [...keepIds];
|
|
180
|
+
for (const id of addIds) {
|
|
181
|
+
if (!finalLabelIds.includes(id)) {
|
|
182
|
+
finalLabelIds.push(id);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return issuesService.updateIssue({ id: issue.identifier, labelIds: finalLabelIds }, "overwriting");
|
|
186
|
+
});
|
|
187
|
+
outputSuccess({
|
|
188
|
+
action: "label",
|
|
189
|
+
add: addLabels,
|
|
190
|
+
remove: removeLabels,
|
|
191
|
+
results,
|
|
192
|
+
meta: {
|
|
193
|
+
total: results.length,
|
|
194
|
+
succeeded: results.filter((r) => r.success).length,
|
|
195
|
+
failed: results.filter((r) => !r.success).length,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
async function handleBatchMove(options, command) {
|
|
200
|
+
const rootOpts = command.parent.parent.opts();
|
|
201
|
+
if (!options.project) {
|
|
202
|
+
throw new Error("--project is required.");
|
|
203
|
+
}
|
|
204
|
+
const issues = await resolveTargetIssues(options, rootOpts);
|
|
205
|
+
if (!options.yes) {
|
|
206
|
+
const hasPreview = printPreview(issues, `Move ${issues.length} issues to project "${options.project}":`);
|
|
207
|
+
if (!hasPreview) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
outputWarning("Dry run — pass --yes to execute.");
|
|
211
|
+
outputSuccess({
|
|
212
|
+
dryRun: true,
|
|
213
|
+
action: "move",
|
|
214
|
+
project: options.project,
|
|
215
|
+
issues: issues.map((i) => i.identifier),
|
|
216
|
+
count: issues.length,
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
221
|
+
const linearService = createLinearService(rootOpts);
|
|
222
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
223
|
+
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
|
|
224
|
+
outputSuccess({
|
|
225
|
+
action: "move",
|
|
226
|
+
project: options.project,
|
|
227
|
+
results,
|
|
228
|
+
meta: {
|
|
229
|
+
total: results.length,
|
|
230
|
+
succeeded: results.filter((r) => r.success).length,
|
|
231
|
+
failed: results.filter((r) => !r.success).length,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async function handleBatchStatus(options, command) {
|
|
236
|
+
const rootOpts = command.parent.parent.opts();
|
|
237
|
+
if (!options.status) {
|
|
238
|
+
throw new Error("--status is required.");
|
|
239
|
+
}
|
|
240
|
+
const issues = await resolveTargetIssues(options, rootOpts);
|
|
241
|
+
if (!options.yes) {
|
|
242
|
+
const hasPreview = printPreview(issues, `Change status to "${options.status}" on ${issues.length} issues:`);
|
|
243
|
+
if (!hasPreview) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
outputWarning("Dry run — pass --yes to execute.");
|
|
247
|
+
outputSuccess({
|
|
248
|
+
dryRun: true,
|
|
249
|
+
action: "status",
|
|
250
|
+
status: options.status,
|
|
251
|
+
issues: issues.map((i) => i.identifier),
|
|
252
|
+
count: issues.length,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
257
|
+
const linearService = createLinearService(rootOpts);
|
|
258
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
259
|
+
const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
|
|
260
|
+
outputSuccess({
|
|
261
|
+
action: "status",
|
|
262
|
+
status: options.status,
|
|
263
|
+
results,
|
|
264
|
+
meta: {
|
|
265
|
+
total: results.length,
|
|
266
|
+
succeeded: results.filter((r) => r.success).length,
|
|
267
|
+
failed: results.filter((r) => !r.success).length,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
export function setupBatchCommands(program) {
|
|
272
|
+
const batch = program
|
|
273
|
+
.command("batch")
|
|
274
|
+
.description("Batch operations on multiple issues");
|
|
275
|
+
batch.action(() => batch.help());
|
|
276
|
+
batch
|
|
277
|
+
.command("assign")
|
|
278
|
+
.description("Assign multiple issues to a user")
|
|
279
|
+
.option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
|
|
280
|
+
.option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
|
|
281
|
+
.requiredOption("--assignee <assignee>", "assignee (name, alias, or UUID)")
|
|
282
|
+
.option("--yes", "skip confirmation preview and execute immediately")
|
|
283
|
+
.action(handleAsyncCommand(handleBatchAssign));
|
|
284
|
+
batch
|
|
285
|
+
.command("label")
|
|
286
|
+
.description("Add or remove labels on multiple issues")
|
|
287
|
+
.option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
|
|
288
|
+
.option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
|
|
289
|
+
.option("--add <labels>", "labels to add (comma-separated names)")
|
|
290
|
+
.option("--remove <labels>", "labels to remove (comma-separated names)")
|
|
291
|
+
.option("--yes", "skip confirmation preview and execute immediately")
|
|
292
|
+
.action(handleAsyncCommand(handleBatchLabel));
|
|
293
|
+
batch
|
|
294
|
+
.command("move")
|
|
295
|
+
.description("Move multiple issues to a project")
|
|
296
|
+
.option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
|
|
297
|
+
.option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
|
|
298
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
299
|
+
.option("--yes", "skip confirmation preview and execute immediately")
|
|
300
|
+
.action(handleAsyncCommand(handleBatchMove));
|
|
301
|
+
batch
|
|
302
|
+
.command("status")
|
|
303
|
+
.description("Change status on multiple issues")
|
|
304
|
+
.option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
|
|
305
|
+
.option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
|
|
306
|
+
.requiredOption("-s, --status <status>", "new status name or ID")
|
|
307
|
+
.option("--yes", "skip confirmation preview and execute immediately")
|
|
308
|
+
.action(handleAsyncCommand(handleBatchStatus));
|
|
309
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
|
+
import { CREATE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
|
|
4
|
+
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
|
+
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
6
|
+
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
7
|
+
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
8
|
+
import { createLinearService, } from "../utils/linear-service.js";
|
|
9
|
+
import { resolveMentions } from "../utils/mention-resolver.js";
|
|
10
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
11
|
+
import { validateReferences } from "../utils/validate-references.js";
|
|
12
|
+
import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
13
|
+
// Match Linear's bodyData validation error in multiple phrasings so a wording
|
|
14
|
+
// tweak on their side doesn't silently regress the fallback path.
|
|
15
|
+
const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
|
|
16
|
+
const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
17
|
+
function readBody(options) {
|
|
18
|
+
if (options.file) {
|
|
19
|
+
return readFileSync(options.file, "utf-8");
|
|
20
|
+
}
|
|
21
|
+
if (options.body) {
|
|
22
|
+
return options.body;
|
|
23
|
+
}
|
|
24
|
+
throw new Error("Either --body or --file is required");
|
|
25
|
+
}
|
|
26
|
+
async function fetchSelfUserId(graphQLService) {
|
|
27
|
+
try {
|
|
28
|
+
const result = await graphQLService.rawRequest("{ viewer { id } }");
|
|
29
|
+
const viewer = result.viewer;
|
|
30
|
+
return viewer?.id;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function transformComment(comment) {
|
|
37
|
+
const user = comment.user;
|
|
38
|
+
return {
|
|
39
|
+
id: comment.id,
|
|
40
|
+
body: comment.body,
|
|
41
|
+
user: {
|
|
42
|
+
id: user.id,
|
|
43
|
+
name: resolveUserDisplayName(user.id, user.name),
|
|
44
|
+
url: user.url || undefined,
|
|
45
|
+
},
|
|
46
|
+
createdAt: comment.createdAt,
|
|
47
|
+
updatedAt: comment.updatedAt,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap valid issue references in a comment body as markdown links — same logic as for
|
|
52
|
+
* issue descriptions. Returns the rewritten body plus the validated identifier→UUID map
|
|
53
|
+
* so the caller can reuse it for sidebar relation creation without re-resolving.
|
|
54
|
+
*
|
|
55
|
+
* No-op (returns the original body with an empty map) when:
|
|
56
|
+
* - body has no candidate refs
|
|
57
|
+
* - the user passed `--no-auto-link`
|
|
58
|
+
*/
|
|
59
|
+
async function prepareCommentBodyWithLinks(body, options, linearService, graphQLService) {
|
|
60
|
+
if (options.autoLink === false || !body) {
|
|
61
|
+
return { body, preResolved: new Map() };
|
|
62
|
+
}
|
|
63
|
+
const refs = extractIssueReferences(body);
|
|
64
|
+
if (refs.length === 0) {
|
|
65
|
+
return { body, preResolved: new Map() };
|
|
66
|
+
}
|
|
67
|
+
const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
|
|
68
|
+
if (preResolved.size === 0) {
|
|
69
|
+
return { body, preResolved };
|
|
70
|
+
}
|
|
71
|
+
const validIds = new Set(preResolved.keys());
|
|
72
|
+
const urlKey = await getWorkspaceUrlKey(graphQLService);
|
|
73
|
+
const wrapped = wrapIssueReferencesAsLinks(body, validIds, urlKey);
|
|
74
|
+
return { body: wrapped, preResolved };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* After a comment is saved, create sidebar relations on the parent issue for the
|
|
78
|
+
* referenced issues — same de-dup and prose-keyword inference logic as descriptions.
|
|
79
|
+
* Returns undefined when nothing was linked, skipped, or failed.
|
|
80
|
+
*/
|
|
81
|
+
async function autoLinkCommentReferences(args) {
|
|
82
|
+
if (args.options.autoLink === false) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const result = await autoLinkReferences({
|
|
86
|
+
issueId: args.parentIssueUuid,
|
|
87
|
+
identifier: args.parentIssueIdentifier,
|
|
88
|
+
description: args.body,
|
|
89
|
+
preResolved: args.preResolved,
|
|
90
|
+
graphQLService: args.graphQLService,
|
|
91
|
+
linearService: args.linearService,
|
|
92
|
+
});
|
|
93
|
+
if (result.linked.length === 0 &&
|
|
94
|
+
result.skipped.length === 0 &&
|
|
95
|
+
result.failed.length === 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
async function handleCreateComment(issueId, options, command) {
|
|
101
|
+
const rootOpts = command.parent.parent.opts();
|
|
102
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
103
|
+
const linearService = createLinearService(rootOpts);
|
|
104
|
+
const rawBody = readBody(options);
|
|
105
|
+
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
106
|
+
// Wrap valid refs as markdown links before mention resolution. Idempotent against
|
|
107
|
+
// already-wrapped refs.
|
|
108
|
+
const { body, preResolved } = await prepareCommentBodyWithLinks(rawBody, options, linearService, graphQLService);
|
|
109
|
+
const autoMention = options.autoMention !== false;
|
|
110
|
+
const selfUserId = autoMention
|
|
111
|
+
? await fetchSelfUserId(graphQLService)
|
|
112
|
+
: undefined;
|
|
113
|
+
const mentionResult = await resolveMentions(body, linearService, {
|
|
114
|
+
autoMention,
|
|
115
|
+
selfUserId,
|
|
116
|
+
});
|
|
117
|
+
const input = { issueId: resolvedIssueId };
|
|
118
|
+
if (mentionResult) {
|
|
119
|
+
input.bodyData = mentionResult.bodyData;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
input.body = body;
|
|
123
|
+
}
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
|
|
127
|
+
input,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// If the bodyData ProseMirror document is rejected (invalid shape,
|
|
132
|
+
// unsupported node, schema change), fall back to raw body text and let
|
|
133
|
+
// Linear handle the conversion server-side.
|
|
134
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
135
|
+
if (input.bodyData && BODY_DATA_ERROR_RE.test(msg)) {
|
|
136
|
+
const fallbackInput = {
|
|
137
|
+
issueId: resolvedIssueId,
|
|
138
|
+
body,
|
|
139
|
+
};
|
|
140
|
+
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
|
|
141
|
+
input: fallbackInput,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const mutation = result.commentCreate;
|
|
149
|
+
if (!mutation.success) {
|
|
150
|
+
throw new Error("Failed to create comment");
|
|
151
|
+
}
|
|
152
|
+
// We don't have the parent issue's identifier from the create mutation response,
|
|
153
|
+
// but we don't need it for self-exclusion in comments — wrapping a self-ref in a
|
|
154
|
+
// comment body is harmless. Use the user-provided issueId if it looks like one.
|
|
155
|
+
const parentIdentifier = ISSUE_IDENTIFIER_REGEX.test(issueId)
|
|
156
|
+
? issueId.toUpperCase()
|
|
157
|
+
: "";
|
|
158
|
+
// Pass rawBody (pre-wrap) to autoLink so prose-keyword inference ("blocked by", etc.)
|
|
159
|
+
// sees `keyword DEV-100` rather than `keyword [DEV-100](url)`. After wrapping the inserted
|
|
160
|
+
// `[` defeats the trailing-whitespace anchor and inference falls back to "related".
|
|
161
|
+
const autoLinked = await autoLinkCommentReferences({
|
|
162
|
+
parentIssueUuid: resolvedIssueId,
|
|
163
|
+
parentIssueIdentifier: parentIdentifier,
|
|
164
|
+
body: rawBody,
|
|
165
|
+
preResolved,
|
|
166
|
+
options,
|
|
167
|
+
graphQLService,
|
|
168
|
+
linearService,
|
|
169
|
+
});
|
|
170
|
+
const output = transformComment(mutation.comment);
|
|
171
|
+
outputSuccess(autoLinked ? { ...output, autoLinked } : output);
|
|
172
|
+
}
|
|
173
|
+
async function handleUpdateComment(commentId, options, command) {
|
|
174
|
+
const rootOpts = command.parent.parent.opts();
|
|
175
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
176
|
+
const linearService = createLinearService(rootOpts);
|
|
177
|
+
const rawBody = readBody(options);
|
|
178
|
+
const { body, preResolved } = await prepareCommentBodyWithLinks(rawBody, options, linearService, graphQLService);
|
|
179
|
+
const autoMention = options.autoMention !== false;
|
|
180
|
+
const selfUserId = autoMention
|
|
181
|
+
? await fetchSelfUserId(graphQLService)
|
|
182
|
+
: undefined;
|
|
183
|
+
const mentionResult = await resolveMentions(body, linearService, {
|
|
184
|
+
autoMention,
|
|
185
|
+
selfUserId,
|
|
186
|
+
});
|
|
187
|
+
const input = {};
|
|
188
|
+
if (mentionResult) {
|
|
189
|
+
input.bodyData = mentionResult.bodyData;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
input.body = body;
|
|
193
|
+
}
|
|
194
|
+
const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, {
|
|
195
|
+
id: commentId,
|
|
196
|
+
input,
|
|
197
|
+
});
|
|
198
|
+
const mutation = result.commentUpdate;
|
|
199
|
+
if (!mutation.success) {
|
|
200
|
+
throw new Error("Failed to update comment");
|
|
201
|
+
}
|
|
202
|
+
const comment = mutation.comment;
|
|
203
|
+
const issue = comment.issue;
|
|
204
|
+
let autoLinked;
|
|
205
|
+
if (issue) {
|
|
206
|
+
// rawBody (pre-wrap) — see note in handleCreateComment about keyword inference
|
|
207
|
+
autoLinked = await autoLinkCommentReferences({
|
|
208
|
+
parentIssueUuid: issue.id,
|
|
209
|
+
parentIssueIdentifier: issue.identifier ?? "",
|
|
210
|
+
body: rawBody,
|
|
211
|
+
preResolved,
|
|
212
|
+
options,
|
|
213
|
+
graphQLService,
|
|
214
|
+
linearService,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const output = transformComment(comment);
|
|
218
|
+
outputSuccess(autoLinked ? { ...output, autoLinked } : output);
|
|
219
|
+
}
|
|
220
|
+
async function handleListComments(issueId, options, command) {
|
|
221
|
+
const rootOpts = command.parent.parent.opts();
|
|
222
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
223
|
+
const linearService = createLinearService(rootOpts);
|
|
224
|
+
const resolvedId = await linearService.resolveIssueId(issueId);
|
|
225
|
+
const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
|
|
226
|
+
issueId: resolvedId,
|
|
227
|
+
first: Number.parseInt(options.limit, 10),
|
|
228
|
+
});
|
|
229
|
+
const issue = result.issue;
|
|
230
|
+
if (!issue) {
|
|
231
|
+
throw new Error(`Issue "${issueId}" not found`);
|
|
232
|
+
}
|
|
233
|
+
const commentsData = issue.comments;
|
|
234
|
+
const nodes = commentsData.nodes.map(transformComment);
|
|
235
|
+
outputSuccess({
|
|
236
|
+
data: nodes,
|
|
237
|
+
meta: {
|
|
238
|
+
count: nodes.length,
|
|
239
|
+
issue: issue.identifier,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
export function setupCommentsCommands(program) {
|
|
244
|
+
const comments = program
|
|
245
|
+
.command("comments")
|
|
246
|
+
.alias("comment")
|
|
247
|
+
.description("Comment operations");
|
|
248
|
+
comments.action(() => comments.help());
|
|
249
|
+
comments
|
|
250
|
+
.command("create <issueId>")
|
|
251
|
+
.description("Create new comment on issue.")
|
|
252
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nBare references to known team members (e.g. 'Dima') are auto-converted to @mentions — pass --no-auto-mention to disable.\nIssue identifiers (e.g. DEV-123) are wrapped as markdown links and added as 'related' relations on the parent issue — pass --no-auto-link to disable.")
|
|
253
|
+
.option("--body <body>", "comment body (inline)")
|
|
254
|
+
.option("--file <path>", "read comment body from file")
|
|
255
|
+
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
256
|
+
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
257
|
+
.action(handleAsyncCommand(handleCreateComment));
|
|
258
|
+
comments
|
|
259
|
+
.command("update <commentId>")
|
|
260
|
+
.description("Update an existing comment.")
|
|
261
|
+
.option("--body <body>", "new comment body (inline)")
|
|
262
|
+
.option("--file <path>", "read new comment body from file")
|
|
263
|
+
.option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
|
|
264
|
+
.option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
|
|
265
|
+
.action(handleAsyncCommand(handleUpdateComment));
|
|
266
|
+
comments
|
|
267
|
+
.command("list <issueId>")
|
|
268
|
+
.description("List comments on an issue.")
|
|
269
|
+
.addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
|
|
270
|
+
.option("-l, --limit <number>", "limit results", "25")
|
|
271
|
+
.action(handleAsyncCommand(handleListComments));
|
|
272
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadConfig } from "../config/config.js";
|
|
2
|
+
import { outputSuccess } from "../utils/output.js";
|
|
3
|
+
export function setupConfigCommands(program) {
|
|
4
|
+
const config = program
|
|
5
|
+
.command("config")
|
|
6
|
+
.description("Configuration introspection");
|
|
7
|
+
config.action(() => config.help());
|
|
8
|
+
config
|
|
9
|
+
.command("show")
|
|
10
|
+
.description("Show resolved configuration")
|
|
11
|
+
.addHelpText("after", "\nDumps the merged config from ~/.config/el-linear/config.json.\n\nExamples:\n el-linear config show")
|
|
12
|
+
.action(() => {
|
|
13
|
+
outputSuccess({ data: loadConfig() });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolveTeam } from "../config/resolver.js";
|
|
2
|
+
import { invalidParameterError, notFoundError, requiresParameterError, } from "../utils/error-messages.js";
|
|
3
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
4
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
|
+
export function setupCyclesCommands(program) {
|
|
6
|
+
const cycles = program
|
|
7
|
+
.command("cycles")
|
|
8
|
+
.alias("cycle")
|
|
9
|
+
.description("Cycle operations");
|
|
10
|
+
cycles.action(() => cycles.help());
|
|
11
|
+
cycles
|
|
12
|
+
.command("list")
|
|
13
|
+
.description("List cycles")
|
|
14
|
+
.option("--team <team>", "team key, name, or ID")
|
|
15
|
+
.option("--active", "only active cycles")
|
|
16
|
+
.option("--around-active <n>", "return active +/- n cycles (requires --team)")
|
|
17
|
+
.option("-l, --limit <number>", "limit results", "50")
|
|
18
|
+
.action(handleAsyncCommand(async (options, command) => {
|
|
19
|
+
if (options.aroundActive && !options.team) {
|
|
20
|
+
throw requiresParameterError("--around-active", "--team");
|
|
21
|
+
}
|
|
22
|
+
const rootOpts = command.parent.parent.opts();
|
|
23
|
+
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
24
|
+
const linearService = createLinearService(rootOpts);
|
|
25
|
+
const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, Number.parseInt(options.limit, 10));
|
|
26
|
+
if (options.aroundActive) {
|
|
27
|
+
const n = Number.parseInt(options.aroundActive, 10);
|
|
28
|
+
if (Number.isNaN(n) || n < 0) {
|
|
29
|
+
throw invalidParameterError("--around-active", "requires a non-negative integer");
|
|
30
|
+
}
|
|
31
|
+
const activeCycle = allCycles.find((c) => c.isActive);
|
|
32
|
+
if (!activeCycle) {
|
|
33
|
+
throw notFoundError("Active cycle", options.team, "for team");
|
|
34
|
+
}
|
|
35
|
+
const activeNumber = Number(activeCycle.number || 0);
|
|
36
|
+
const min = activeNumber - n;
|
|
37
|
+
const max = activeNumber + n;
|
|
38
|
+
const filtered = allCycles
|
|
39
|
+
.filter((c) => typeof c.number === "number" &&
|
|
40
|
+
c.number >= min &&
|
|
41
|
+
c.number <= max)
|
|
42
|
+
.sort((a, b) => a.number - b.number);
|
|
43
|
+
outputSuccess({ data: filtered, meta: { count: filtered.length } });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
outputSuccess({ data: allCycles, meta: { count: allCycles.length } });
|
|
47
|
+
}));
|
|
48
|
+
cycles
|
|
49
|
+
.command("read <cycleIdOrName>")
|
|
50
|
+
.description("Get cycle details including issues.")
|
|
51
|
+
.option("--team <team>", "team key, name, or ID to scope name lookup")
|
|
52
|
+
.option("--issues-first <n>", "how many issues to fetch (default 50)", "50")
|
|
53
|
+
.action(handleAsyncCommand(async (cycleIdOrName, options, command) => {
|
|
54
|
+
const rootOpts = command.parent.parent.opts();
|
|
55
|
+
const teamFilter = options.team
|
|
56
|
+
? resolveTeam(options.team)
|
|
57
|
+
: undefined;
|
|
58
|
+
const linearService = createLinearService(rootOpts);
|
|
59
|
+
const cycleId = await linearService.resolveCycleId(cycleIdOrName, teamFilter);
|
|
60
|
+
const cycle = await linearService.getCycleById(cycleId, Number.parseInt(options.issuesFirst || "50", 10));
|
|
61
|
+
outputSuccess(cycle);
|
|
62
|
+
}));
|
|
63
|
+
}
|