@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,100 @@
|
|
|
1
|
+
import { resolveTeam } from "../config/resolver.js";
|
|
2
|
+
import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
|
|
3
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
4
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
5
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
6
|
+
import { validateHexColor } from "../utils/validators.js";
|
|
7
|
+
async function handleCreateLabel(name, options, command) {
|
|
8
|
+
const rootOpts = command.parent.parent.opts();
|
|
9
|
+
const teamId = resolveTeam(options.team);
|
|
10
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
11
|
+
const input = { name, teamId };
|
|
12
|
+
if (options.color) {
|
|
13
|
+
input.color = validateHexColor(options.color);
|
|
14
|
+
}
|
|
15
|
+
if (options.description) {
|
|
16
|
+
input.description = options.description;
|
|
17
|
+
}
|
|
18
|
+
if (options.parent) {
|
|
19
|
+
const parentResult = await graphQLService.rawRequest(FIND_PARENT_LABEL_QUERY, {
|
|
20
|
+
name: options.parent,
|
|
21
|
+
teamId,
|
|
22
|
+
});
|
|
23
|
+
const issueLabels = parentResult.issueLabels;
|
|
24
|
+
const nodes = issueLabels?.nodes;
|
|
25
|
+
const parentLabel = nodes?.[0];
|
|
26
|
+
if (parentLabel) {
|
|
27
|
+
input.parentId = parentLabel.id;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const result = await graphQLService.rawRequest(CREATE_LABEL_MUTATION, {
|
|
31
|
+
input,
|
|
32
|
+
});
|
|
33
|
+
const createResult = result.issueLabelCreate;
|
|
34
|
+
if (!createResult?.success) {
|
|
35
|
+
throw new Error(`Failed to create label "${name}"`);
|
|
36
|
+
}
|
|
37
|
+
outputSuccess(createResult.issueLabel);
|
|
38
|
+
}
|
|
39
|
+
async function handleRetireLabel(labelId, _options, command) {
|
|
40
|
+
const rootOpts = command.parent.parent.opts();
|
|
41
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
42
|
+
const result = await graphQLService.rawRequest(RETIRE_LABEL_MUTATION, {
|
|
43
|
+
id: labelId,
|
|
44
|
+
});
|
|
45
|
+
const retireResult = result.issueLabelRetire;
|
|
46
|
+
if (!retireResult?.success) {
|
|
47
|
+
throw new Error(`Failed to retire label "${labelId}"`);
|
|
48
|
+
}
|
|
49
|
+
outputSuccess(retireResult.issueLabel);
|
|
50
|
+
}
|
|
51
|
+
async function handleRestoreLabel(labelId, _options, command) {
|
|
52
|
+
const rootOpts = command.parent.parent.opts();
|
|
53
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
54
|
+
const result = await graphQLService.rawRequest(RESTORE_LABEL_MUTATION, {
|
|
55
|
+
id: labelId,
|
|
56
|
+
});
|
|
57
|
+
const restoreResult = result.issueLabelRestore;
|
|
58
|
+
if (!restoreResult?.success) {
|
|
59
|
+
throw new Error(`Failed to restore label "${labelId}"`);
|
|
60
|
+
}
|
|
61
|
+
outputSuccess(restoreResult.issueLabel);
|
|
62
|
+
}
|
|
63
|
+
export function setupLabelsCommands(program) {
|
|
64
|
+
const labels = program
|
|
65
|
+
.command("labels")
|
|
66
|
+
.alias("label")
|
|
67
|
+
.description("Label operations");
|
|
68
|
+
labels.action(() => labels.help());
|
|
69
|
+
labels
|
|
70
|
+
.command("list")
|
|
71
|
+
.description("List all available labels")
|
|
72
|
+
.option("--team <team>", "filter by team key, name, or ID")
|
|
73
|
+
.option("-l, --limit <number>", "limit results", "100")
|
|
74
|
+
.action(handleAsyncCommand(async (options, command) => {
|
|
75
|
+
const rootOpts = command.parent.parent.opts();
|
|
76
|
+
const teamFilter = options.team ? resolveTeam(options.team) : undefined;
|
|
77
|
+
const service = createLinearService(rootOpts);
|
|
78
|
+
const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
|
|
79
|
+
outputSuccess({
|
|
80
|
+
data: result.labels,
|
|
81
|
+
meta: { count: result.labels.length },
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
labels
|
|
85
|
+
.command("create <name>")
|
|
86
|
+
.description("Create a new label on a team")
|
|
87
|
+
.requiredOption("--team <team>", "team key, name, or ID")
|
|
88
|
+
.option("--color <color>", "label color (hex, e.g. #e06666)")
|
|
89
|
+
.option("--description <desc>", "label description")
|
|
90
|
+
.option("--parent <parent>", "parent label group name or ID")
|
|
91
|
+
.action(handleAsyncCommand(handleCreateLabel));
|
|
92
|
+
labels
|
|
93
|
+
.command("retire <labelId>")
|
|
94
|
+
.description("Retire a label (soft-delete — can be restored later)")
|
|
95
|
+
.action(handleAsyncCommand(handleRetireLabel));
|
|
96
|
+
labels
|
|
97
|
+
.command("restore <labelId>")
|
|
98
|
+
.description("Restore a previously retired label")
|
|
99
|
+
.action(handleAsyncCommand(handleRestoreLabel));
|
|
100
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { CREATE_PROJECT_MILESTONE_MUTATION, FIND_PROJECT_MILESTONE_BY_NAME_GLOBAL, FIND_PROJECT_MILESTONE_BY_NAME_SCOPED, GET_PROJECT_MILESTONE_BY_ID_QUERY, LIST_PROJECT_MILESTONES_QUERY, UPDATE_PROJECT_MILESTONE_MUTATION, } from "../queries/project-milestones.js";
|
|
2
|
+
import { multipleMatchesError, notFoundError, } from "../utils/error-messages.js";
|
|
3
|
+
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
4
|
+
import { createLinearService, } from "../utils/linear-service.js";
|
|
5
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
6
|
+
import { isUuid } from "../utils/uuid.js";
|
|
7
|
+
import { validateIsoDate } from "../utils/validators.js";
|
|
8
|
+
async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearService, projectNameOrId) {
|
|
9
|
+
if (isUuid(milestoneNameOrId)) {
|
|
10
|
+
return milestoneNameOrId;
|
|
11
|
+
}
|
|
12
|
+
let nodes = [];
|
|
13
|
+
if (projectNameOrId) {
|
|
14
|
+
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
15
|
+
const findRes = await graphQLService.rawRequest(FIND_PROJECT_MILESTONE_BY_NAME_SCOPED, {
|
|
16
|
+
name: milestoneNameOrId,
|
|
17
|
+
projectId,
|
|
18
|
+
});
|
|
19
|
+
const projectData = findRes.project;
|
|
20
|
+
const milestonesData = projectData?.projectMilestones;
|
|
21
|
+
nodes = milestonesData?.nodes || [];
|
|
22
|
+
}
|
|
23
|
+
if (nodes.length === 0) {
|
|
24
|
+
const globalRes = await graphQLService.rawRequest(FIND_PROJECT_MILESTONE_BY_NAME_GLOBAL, {
|
|
25
|
+
name: milestoneNameOrId,
|
|
26
|
+
});
|
|
27
|
+
const globalMilestones = globalRes.projectMilestones;
|
|
28
|
+
nodes = globalMilestones?.nodes || [];
|
|
29
|
+
}
|
|
30
|
+
if (nodes.length === 0) {
|
|
31
|
+
throw notFoundError("Milestone", milestoneNameOrId);
|
|
32
|
+
}
|
|
33
|
+
if (nodes.length > 1) {
|
|
34
|
+
const matches = nodes.map((m) => `"${m.name}" in project "${m.project?.name}"`);
|
|
35
|
+
throw multipleMatchesError("milestone", milestoneNameOrId, matches, "specify --project or use the milestone ID");
|
|
36
|
+
}
|
|
37
|
+
return nodes[0].id;
|
|
38
|
+
}
|
|
39
|
+
async function handleListMilestones(options, command) {
|
|
40
|
+
const rootOpts = command.parent.parent.opts();
|
|
41
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
42
|
+
const linearService = createLinearService(rootOpts);
|
|
43
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
44
|
+
const result = await graphQLService.rawRequest(LIST_PROJECT_MILESTONES_QUERY, {
|
|
45
|
+
projectId,
|
|
46
|
+
first: Number.parseInt(options.limit || "50", 10),
|
|
47
|
+
});
|
|
48
|
+
const projectData = result.project;
|
|
49
|
+
const milestonesData = projectData?.projectMilestones;
|
|
50
|
+
const nodes = milestonesData?.nodes || [];
|
|
51
|
+
outputSuccess({ data: nodes, meta: { count: nodes.length } });
|
|
52
|
+
}
|
|
53
|
+
async function handleReadMilestone(milestoneIdOrName, options, command) {
|
|
54
|
+
const rootOpts = command.parent.parent.opts();
|
|
55
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
56
|
+
const linearService = createLinearService(rootOpts);
|
|
57
|
+
const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
|
|
58
|
+
const result = await graphQLService.rawRequest(GET_PROJECT_MILESTONE_BY_ID_QUERY, {
|
|
59
|
+
id: milestoneId,
|
|
60
|
+
issuesFirst: Number.parseInt(options.issuesFirst || "50", 10),
|
|
61
|
+
});
|
|
62
|
+
outputSuccess(result.projectMilestone);
|
|
63
|
+
}
|
|
64
|
+
async function handleCreateMilestone(name, options, command) {
|
|
65
|
+
const rootOpts = command.parent.parent.opts();
|
|
66
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
67
|
+
const linearService = createLinearService(rootOpts);
|
|
68
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
69
|
+
if (options.targetDate) {
|
|
70
|
+
validateIsoDate(options.targetDate);
|
|
71
|
+
}
|
|
72
|
+
const result = await graphQLService.rawRequest(CREATE_PROJECT_MILESTONE_MUTATION, {
|
|
73
|
+
projectId,
|
|
74
|
+
name,
|
|
75
|
+
description: options.description,
|
|
76
|
+
targetDate: options.targetDate,
|
|
77
|
+
});
|
|
78
|
+
const createData = result.projectMilestoneCreate;
|
|
79
|
+
if (!createData?.success) {
|
|
80
|
+
throw new Error("Failed to create project milestone");
|
|
81
|
+
}
|
|
82
|
+
outputSuccess(createData.projectMilestone);
|
|
83
|
+
}
|
|
84
|
+
async function handleUpdateMilestone(milestoneIdOrName, options, command) {
|
|
85
|
+
const rootOpts = command.parent.parent.opts();
|
|
86
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
87
|
+
const linearService = createLinearService(rootOpts);
|
|
88
|
+
const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
|
|
89
|
+
const updateVars = { id: milestoneId };
|
|
90
|
+
if (options.name !== undefined) {
|
|
91
|
+
updateVars.name = options.name;
|
|
92
|
+
}
|
|
93
|
+
if (options.description !== undefined) {
|
|
94
|
+
updateVars.description = options.description;
|
|
95
|
+
}
|
|
96
|
+
if (options.targetDate !== undefined) {
|
|
97
|
+
validateIsoDate(options.targetDate);
|
|
98
|
+
updateVars.targetDate = options.targetDate;
|
|
99
|
+
}
|
|
100
|
+
if (options.sortOrder !== undefined) {
|
|
101
|
+
updateVars.sortOrder = Number.parseFloat(options.sortOrder);
|
|
102
|
+
}
|
|
103
|
+
const result = await graphQLService.rawRequest(UPDATE_PROJECT_MILESTONE_MUTATION, updateVars);
|
|
104
|
+
const updateData = result.projectMilestoneUpdate;
|
|
105
|
+
if (!updateData?.success) {
|
|
106
|
+
throw new Error("Failed to update project milestone");
|
|
107
|
+
}
|
|
108
|
+
outputSuccess(updateData.projectMilestone);
|
|
109
|
+
}
|
|
110
|
+
export function setupProjectMilestonesCommands(program) {
|
|
111
|
+
const projectMilestones = program
|
|
112
|
+
.command("project-milestones")
|
|
113
|
+
.description("Project milestone operations");
|
|
114
|
+
projectMilestones.action(() => projectMilestones.help());
|
|
115
|
+
projectMilestones
|
|
116
|
+
.command("list")
|
|
117
|
+
.description("List milestones in a project")
|
|
118
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
119
|
+
.option("-l, --limit <number>", "limit results", "50")
|
|
120
|
+
.action(handleAsyncCommand(handleListMilestones));
|
|
121
|
+
projectMilestones
|
|
122
|
+
.command("read <milestoneIdOrName>")
|
|
123
|
+
.description("Get milestone details including issues.")
|
|
124
|
+
.option("--project <project>", "project name or ID to scope name lookup")
|
|
125
|
+
.option("--issues-first <n>", "how many issues to fetch (default 50)", "50")
|
|
126
|
+
.action(handleAsyncCommand(handleReadMilestone));
|
|
127
|
+
projectMilestones
|
|
128
|
+
.command("create <name>")
|
|
129
|
+
.description("Create a new project milestone")
|
|
130
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
131
|
+
.option("-d, --description <description>", "milestone description")
|
|
132
|
+
.option("--target-date <date>", "target date in ISO format (YYYY-MM-DD)")
|
|
133
|
+
.action(handleAsyncCommand(handleCreateMilestone));
|
|
134
|
+
projectMilestones
|
|
135
|
+
.command("update <milestoneIdOrName>")
|
|
136
|
+
.description("Update an existing project milestone.")
|
|
137
|
+
.option("--project <project>", "project name or ID to scope name lookup")
|
|
138
|
+
.option("-n, --name <name>", "new milestone name")
|
|
139
|
+
.option("-d, --description <description>", "new milestone description")
|
|
140
|
+
.option("--target-date <date>", "new target date in ISO format (YYYY-MM-DD)")
|
|
141
|
+
.option("--sort-order <number>", "new sort order")
|
|
142
|
+
.action(handleAsyncCommand(handleUpdateMilestone));
|
|
143
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { resolveTeam } from "../config/resolver.js";
|
|
2
|
+
import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
3
|
+
import { 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 } from "../utils/output.js";
|
|
7
|
+
import { isUuid } from "../utils/uuid.js";
|
|
8
|
+
import { splitList } from "../utils/validators.js";
|
|
9
|
+
function formatProjectsOutput(projects, format, fieldNames) {
|
|
10
|
+
const fields = fieldNames ?? [
|
|
11
|
+
"name",
|
|
12
|
+
"state",
|
|
13
|
+
"progress",
|
|
14
|
+
"teams",
|
|
15
|
+
"lead",
|
|
16
|
+
"targetDate",
|
|
17
|
+
];
|
|
18
|
+
const rows = projects.map((p) => {
|
|
19
|
+
const row = {};
|
|
20
|
+
for (const f of fields) {
|
|
21
|
+
switch (f) {
|
|
22
|
+
case "name":
|
|
23
|
+
row[f] = p.name;
|
|
24
|
+
break;
|
|
25
|
+
case "state":
|
|
26
|
+
row[f] = p.state ?? "";
|
|
27
|
+
break;
|
|
28
|
+
case "progress":
|
|
29
|
+
row[f] =
|
|
30
|
+
p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "";
|
|
31
|
+
break;
|
|
32
|
+
case "teams":
|
|
33
|
+
row[f] = p.teams?.map((t) => t.key).join(", ") ?? "";
|
|
34
|
+
break;
|
|
35
|
+
case "lead":
|
|
36
|
+
row[f] = p.lead?.name ?? "";
|
|
37
|
+
break;
|
|
38
|
+
case "targetDate":
|
|
39
|
+
row[f] = p.targetDate ?? "";
|
|
40
|
+
break;
|
|
41
|
+
case "id":
|
|
42
|
+
row[f] = p.id;
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
row[f] = String(p[f] ?? "");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return row;
|
|
49
|
+
});
|
|
50
|
+
if (format === "csv") {
|
|
51
|
+
logger.info(fields.join(","));
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
logger.info(fields.map((f) => `"${(row[f] ?? "").replace(/"/g, '""')}"`).join(","));
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// table and md formats
|
|
58
|
+
const widths = fields.map((f) => Math.max(f.length, ...rows.map((r) => (r[f] ?? "").length)));
|
|
59
|
+
const separator = format === "md"
|
|
60
|
+
? `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`
|
|
61
|
+
: widths.map((w) => "-".repeat(w + 2)).join("+");
|
|
62
|
+
const header = format === "md"
|
|
63
|
+
? `| ${fields.map((f, i) => f.padEnd(widths[i])).join(" | ")} |`
|
|
64
|
+
: fields.map((f, i) => ` ${f.padEnd(widths[i])} `).join("|");
|
|
65
|
+
logger.info(header);
|
|
66
|
+
logger.info(separator);
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const line = format === "md"
|
|
69
|
+
? `| ${fields.map((f, i) => (row[f] ?? "").padEnd(widths[i])).join(" | ")} |`
|
|
70
|
+
: fields
|
|
71
|
+
.map((f, i) => ` ${(row[f] ?? "").padEnd(widths[i])} `)
|
|
72
|
+
.join("|");
|
|
73
|
+
logger.info(line);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function resolveProjectWithTeams(graphQLService, projectNameOrId) {
|
|
77
|
+
if (isUuid(projectNameOrId)) {
|
|
78
|
+
const result = await graphQLService.rawRequest(PROJECT_BY_ID_QUERY, {
|
|
79
|
+
id: projectNameOrId,
|
|
80
|
+
});
|
|
81
|
+
const project = result.project;
|
|
82
|
+
if (!project) {
|
|
83
|
+
throw new Error(`Project "${projectNameOrId}" not found`);
|
|
84
|
+
}
|
|
85
|
+
const teams = project.teams;
|
|
86
|
+
return {
|
|
87
|
+
projectId: project.id,
|
|
88
|
+
projectName: project.name,
|
|
89
|
+
currentTeams: (teams?.nodes || []).map((t) => ({
|
|
90
|
+
id: t.id,
|
|
91
|
+
key: t.key,
|
|
92
|
+
name: t.name,
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const result = await graphQLService.rawRequest(GET_PROJECT_QUERY, {
|
|
97
|
+
name: projectNameOrId,
|
|
98
|
+
});
|
|
99
|
+
const projectNodes = result.projects?.nodes;
|
|
100
|
+
if (!projectNodes?.length) {
|
|
101
|
+
throw new Error(`Project "${projectNameOrId}" not found`);
|
|
102
|
+
}
|
|
103
|
+
const project = projectNodes[0];
|
|
104
|
+
const teams = project.teams;
|
|
105
|
+
return {
|
|
106
|
+
projectId: project.id,
|
|
107
|
+
projectName: project.name,
|
|
108
|
+
currentTeams: (teams?.nodes || []).map((t) => ({
|
|
109
|
+
id: t.id,
|
|
110
|
+
key: t.key,
|
|
111
|
+
name: t.name,
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function formatTeamsOutput(projectUpdate) {
|
|
116
|
+
const updatedProject = projectUpdate.project;
|
|
117
|
+
const updatedTeams = updatedProject.teams;
|
|
118
|
+
return {
|
|
119
|
+
id: updatedProject.id,
|
|
120
|
+
name: updatedProject.name,
|
|
121
|
+
teams: (updatedTeams.nodes || []).map((t) => ({
|
|
122
|
+
id: t.id,
|
|
123
|
+
key: t.key,
|
|
124
|
+
name: t.name,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
129
|
+
const rootOpts = command.parent.parent.opts();
|
|
130
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
131
|
+
const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
|
|
132
|
+
const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
133
|
+
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
134
|
+
if (currentTeamIds.includes(finalTeamId)) {
|
|
135
|
+
outputSuccess({
|
|
136
|
+
message: `Team "${teamInput}" is already associated with the project`,
|
|
137
|
+
projectId,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
142
|
+
id: projectId,
|
|
143
|
+
input: { teamIds: [...currentTeamIds, finalTeamId] },
|
|
144
|
+
});
|
|
145
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
146
|
+
if (!projectUpdate.success) {
|
|
147
|
+
throw new Error("Failed to update project");
|
|
148
|
+
}
|
|
149
|
+
outputSuccess(formatTeamsOutput(projectUpdate));
|
|
150
|
+
}
|
|
151
|
+
async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
|
|
152
|
+
const rootOpts = command.parent.parent.opts();
|
|
153
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
154
|
+
const resolvedTeamIds = await Promise.all(teamInputs.map((t) => createLinearService(rootOpts).resolveTeamId(resolveTeam(t))));
|
|
155
|
+
const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
156
|
+
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
157
|
+
const newTeamIds = resolvedTeamIds.filter((id) => !currentTeamIds.includes(id));
|
|
158
|
+
const skippedInputs = teamInputs.filter((_, i) => currentTeamIds.includes(resolvedTeamIds[i]));
|
|
159
|
+
if (newTeamIds.length === 0) {
|
|
160
|
+
outputSuccess({
|
|
161
|
+
message: "All teams are already associated with the project",
|
|
162
|
+
projectId,
|
|
163
|
+
skipped: skippedInputs,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const mergedTeamIds = [...currentTeamIds, ...newTeamIds];
|
|
168
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
169
|
+
id: projectId,
|
|
170
|
+
input: { teamIds: mergedTeamIds },
|
|
171
|
+
});
|
|
172
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
173
|
+
if (!projectUpdate.success) {
|
|
174
|
+
throw new Error("Failed to update project");
|
|
175
|
+
}
|
|
176
|
+
const result = formatTeamsOutput(projectUpdate);
|
|
177
|
+
outputSuccess({
|
|
178
|
+
...result,
|
|
179
|
+
added: newTeamIds.length,
|
|
180
|
+
skipped: skippedInputs.length > 0 ? skippedInputs : undefined,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
|
|
184
|
+
const rootOpts = command.parent.parent.opts();
|
|
185
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
186
|
+
const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
|
|
187
|
+
const { projectId, projectName, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
|
|
188
|
+
const currentTeamIds = currentTeams.map((t) => t.id);
|
|
189
|
+
if (!currentTeamIds.includes(finalTeamId)) {
|
|
190
|
+
throw new Error(`Team "${teamInput}" is not associated with project "${projectName}"`);
|
|
191
|
+
}
|
|
192
|
+
if (!options.force) {
|
|
193
|
+
const issueCheck = await graphQLService.rawRequest(GET_PROJECT_TEAM_ISSUES_QUERY, {
|
|
194
|
+
projectId,
|
|
195
|
+
teamId: finalTeamId,
|
|
196
|
+
});
|
|
197
|
+
const project = issueCheck.project;
|
|
198
|
+
const issues = project.issues;
|
|
199
|
+
const issueNodes = issues.nodes || [];
|
|
200
|
+
if (issueNodes.length > 0) {
|
|
201
|
+
const examples = issueNodes
|
|
202
|
+
.slice(0, 5)
|
|
203
|
+
.map((i) => `${i.identifier}: ${i.title}`)
|
|
204
|
+
.join("\n ");
|
|
205
|
+
throw new Error(`Cannot remove team "${teamInput}" — it has ${issueNodes.length}${issueNodes.length >= 50 ? "+" : ""} issues in project "${projectName}". Reassign or remove those issues first.\n ${examples}${issueNodes.length > 5 ? "\n ..." : ""}\n\nUse --force to bypass this check.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const updatedTeamIds = currentTeamIds.filter((id) => id !== finalTeamId);
|
|
209
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
210
|
+
id: projectId,
|
|
211
|
+
input: { teamIds: updatedTeamIds },
|
|
212
|
+
});
|
|
213
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
214
|
+
if (!projectUpdate.success) {
|
|
215
|
+
throw new Error("Failed to update project");
|
|
216
|
+
}
|
|
217
|
+
outputSuccess({
|
|
218
|
+
...formatTeamsOutput(projectUpdate),
|
|
219
|
+
removed: teamInput,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
async function handleCreateProject(name, options, command) {
|
|
223
|
+
const rootOpts = command.parent.parent.opts();
|
|
224
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
225
|
+
// Step 1: Check for duplicate projects (case-insensitive)
|
|
226
|
+
const searchResult = await graphQLService.rawRequest(SEARCH_PROJECTS_BY_NAME_QUERY, { name });
|
|
227
|
+
const existing = (searchResult.projects
|
|
228
|
+
?.nodes ?? []).filter((p) => p.name.toLowerCase() === name.toLowerCase());
|
|
229
|
+
if (existing.length > 0 && !options.force) {
|
|
230
|
+
const match = existing[0];
|
|
231
|
+
const teams = (match.teams?.nodes ??
|
|
232
|
+
[])
|
|
233
|
+
.map((t) => t.key)
|
|
234
|
+
.join(", ");
|
|
235
|
+
throw new Error(`Project "${match.name}" already exists (state: ${match.state}, teams: ${teams}). ` +
|
|
236
|
+
`Use --force to create anyway, or use the existing project.`);
|
|
237
|
+
}
|
|
238
|
+
// Step 2: Resolve team IDs
|
|
239
|
+
const service = createLinearService(rootOpts);
|
|
240
|
+
const teamKeys = options.team ? splitList(options.team) : [];
|
|
241
|
+
const teamIds = [];
|
|
242
|
+
for (const key of teamKeys) {
|
|
243
|
+
teamIds.push(await service.resolveTeamId(resolveTeam(key)));
|
|
244
|
+
}
|
|
245
|
+
// Step 3: Create the project
|
|
246
|
+
const input = {
|
|
247
|
+
name,
|
|
248
|
+
...(options.description ? { description: options.description } : {}),
|
|
249
|
+
...(teamIds.length > 0 ? { teamIds } : {}),
|
|
250
|
+
};
|
|
251
|
+
const createResult = await graphQLService.rawRequest(CREATE_PROJECT_MUTATION, { input });
|
|
252
|
+
const created = createResult.projectCreate;
|
|
253
|
+
if (!created?.success) {
|
|
254
|
+
throw new Error("Failed to create project");
|
|
255
|
+
}
|
|
256
|
+
const project = created.project;
|
|
257
|
+
// Step 4: Set content if provided (separate mutation — Linear API quirk)
|
|
258
|
+
if (options.content) {
|
|
259
|
+
await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
260
|
+
id: project.id,
|
|
261
|
+
input: { content: options.content },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const teamList = (project.teams?.nodes ??
|
|
265
|
+
[])
|
|
266
|
+
.map((t) => t.key)
|
|
267
|
+
.join(", ");
|
|
268
|
+
outputSuccess({
|
|
269
|
+
id: project.id,
|
|
270
|
+
name: project.name,
|
|
271
|
+
state: project.state ?? "planned",
|
|
272
|
+
teams: teamList,
|
|
273
|
+
...(existing.length > 0
|
|
274
|
+
? {
|
|
275
|
+
_warnings: [
|
|
276
|
+
`Created despite existing project "${existing[0].name}" (--force used)`,
|
|
277
|
+
],
|
|
278
|
+
}
|
|
279
|
+
: {}),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
export function setupProjectsCommands(program) {
|
|
283
|
+
const projects = program
|
|
284
|
+
.command("projects")
|
|
285
|
+
.alias("project")
|
|
286
|
+
.description("Project operations");
|
|
287
|
+
projects.action(() => projects.help());
|
|
288
|
+
projects
|
|
289
|
+
.command("create <name>")
|
|
290
|
+
.description("Create a project (checks for duplicates first)")
|
|
291
|
+
.option("--team <teams>", "comma-separated team keys (e.g., FE,DEV)")
|
|
292
|
+
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
293
|
+
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
294
|
+
.option("--force", "create even if a project with the same name exists")
|
|
295
|
+
.action(handleAsyncCommand(handleCreateProject));
|
|
296
|
+
projects
|
|
297
|
+
.command("list")
|
|
298
|
+
.description("List projects")
|
|
299
|
+
.option("-l, --limit <number>", "limit results", "100")
|
|
300
|
+
.option("--format <format>", "output format (json, table, md, csv)", "json")
|
|
301
|
+
.option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
|
|
302
|
+
.action(handleAsyncCommand(async (options, command) => {
|
|
303
|
+
const rootOpts = command.parent.parent.opts();
|
|
304
|
+
const service = createLinearService(rootOpts);
|
|
305
|
+
const result = await service.getProjects(Number.parseInt(options.limit, 10));
|
|
306
|
+
const format = options.format;
|
|
307
|
+
if (format === "table" ||
|
|
308
|
+
format === "md" ||
|
|
309
|
+
format === "markdown" ||
|
|
310
|
+
format === "csv") {
|
|
311
|
+
const fieldList = options.fields
|
|
312
|
+
? splitList(options.fields)
|
|
313
|
+
: undefined;
|
|
314
|
+
formatProjectsOutput(result, format, fieldList);
|
|
315
|
+
if (format === "table") {
|
|
316
|
+
logger.info(`\n${result.length} projects`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
outputSuccess({ data: result, meta: { count: result.length } });
|
|
321
|
+
}
|
|
322
|
+
}));
|
|
323
|
+
projects
|
|
324
|
+
.command("add-team <project> <team>")
|
|
325
|
+
.description("Associate a project with an additional team (resolves names)")
|
|
326
|
+
.action(handleAsyncCommand(handleAddTeam));
|
|
327
|
+
projects
|
|
328
|
+
.command("add-teams <project> <teams...>")
|
|
329
|
+
.description("Associate a project with multiple teams in one operation (resolves names)")
|
|
330
|
+
.action(handleAsyncCommand(handleAddTeams));
|
|
331
|
+
projects
|
|
332
|
+
.command("remove-team <project> <team>")
|
|
333
|
+
.description("Remove a team from a project (checks for issues first)")
|
|
334
|
+
.option("--force", "remove even if the team has issues in the project")
|
|
335
|
+
.action(handleAsyncCommand(handleRemoveTeam));
|
|
336
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* Registers a top-level `read` command and a catch-all that auto-detects
|
|
4
|
+
* issue identifiers so `el-linear ADM-652` works without the `issues read` prefix.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setupReadShortcut(program: Command): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getApiToken } from "../utils/auth.js";
|
|
2
|
+
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
3
|
+
import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
|
|
4
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
5
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
6
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
|
+
/**
|
|
8
|
+
* Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
|
|
9
|
+
*/
|
|
10
|
+
const ISSUE_ID_PATTERN = /^[A-Z]{1,5}-\d+$/;
|
|
11
|
+
/**
|
|
12
|
+
* Registers a top-level `read` command and a catch-all that auto-detects
|
|
13
|
+
* issue identifiers so `el-linear ADM-652` works without the `issues read` prefix.
|
|
14
|
+
*/
|
|
15
|
+
export function setupReadShortcut(program) {
|
|
16
|
+
// Top-level `read` / `get` / `view` / `show` command
|
|
17
|
+
program
|
|
18
|
+
.command("read <issueId...>")
|
|
19
|
+
.alias("get")
|
|
20
|
+
.alias("view")
|
|
21
|
+
.alias("show")
|
|
22
|
+
.description("Shortcut for `issues read`. Get issue details by identifier.")
|
|
23
|
+
.addHelpText("after", "\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)")
|
|
24
|
+
.action(handleAsyncCommand(readIssues));
|
|
25
|
+
// Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
|
|
26
|
+
const originalParse = program.parse.bind(program);
|
|
27
|
+
program.parse = (argv, parseOptions) => {
|
|
28
|
+
const args = argv ?? process.argv;
|
|
29
|
+
const userArgs = args.slice(2); // skip node + script
|
|
30
|
+
// Only intercept when every positional arg looks like an issue ID
|
|
31
|
+
// and there are no flags (to avoid hijacking other commands)
|
|
32
|
+
const positionals = userArgs.filter((a) => !a.startsWith("-"));
|
|
33
|
+
const flags = userArgs.filter((a) => a.startsWith("-"));
|
|
34
|
+
const onlyCompatFlags = flags.every((f) => f === "--json" || f.startsWith("--api-token"));
|
|
35
|
+
if (positionals.length > 0 &&
|
|
36
|
+
positionals.every((a) => ISSUE_ID_PATTERN.test(a)) &&
|
|
37
|
+
onlyCompatFlags) {
|
|
38
|
+
// Check the first positional isn't a known command
|
|
39
|
+
const knownCommands = new Set(program.commands
|
|
40
|
+
.map((c) => c.name())
|
|
41
|
+
.concat(program.commands.flatMap((c) => c.aliases())));
|
|
42
|
+
if (!knownCommands.has(positionals[0])) {
|
|
43
|
+
// Rewrite to `read <ids...>`
|
|
44
|
+
const rewritten = [...args.slice(0, 2), "read", ...userArgs];
|
|
45
|
+
return originalParse(rewritten, parseOptions);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return originalParse(argv, parseOptions);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function readIssues(issueIds, _options, command) {
|
|
52
|
+
const rootOpts = command.parent.opts();
|
|
53
|
+
const graphQLService = createGraphQLService(rootOpts);
|
|
54
|
+
const linearService = createLinearService(rootOpts);
|
|
55
|
+
const issuesService = new GraphQLIssuesService(graphQLService, linearService);
|
|
56
|
+
const apiToken = getApiToken(rootOpts);
|
|
57
|
+
if (issueIds.length === 1) {
|
|
58
|
+
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
59
|
+
const resolved = await downloadLinearUploads(issue, apiToken);
|
|
60
|
+
outputSuccess(resolved);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const results = await Promise.all(issueIds.map(async (id) => {
|
|
64
|
+
const issue = await issuesService.getIssueById(id);
|
|
65
|
+
return downloadLinearUploads(issue, apiToken);
|
|
66
|
+
}));
|
|
67
|
+
outputSuccess(results);
|
|
68
|
+
}
|
|
69
|
+
}
|