@gallopsystems/agent-skills 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/package.json +26 -0
- package/plugins/doctl/.claude-plugin/plugin.json +8 -0
- package/plugins/doctl/skills/doctl/SKILL.md +93 -0
- package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
- package/plugins/linear/.claude-plugin/plugin.json +8 -0
- package/plugins/linear/skills/linear/SKILL.md +1040 -0
- package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
- package/plugins/linear/skills/linear/tech-stack.md +273 -0
- package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
- package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
- package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
- package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
- package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
- package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
- package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
- package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
- package/scripts/link-skills.mjs +252 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
//
|
|
4
|
+
// Linear CLI for Claude Code workflows — zero-dependency, runs on bare `node`
|
|
5
|
+
// (Node 18.3+ for the built-in `node:util` parseArgs and global `fetch`).
|
|
6
|
+
//
|
|
7
|
+
// Usage: node linear.mjs <command> [args] [--flags]
|
|
8
|
+
// node linear.mjs --help
|
|
9
|
+
//
|
|
10
|
+
// Requires: LINEAR_API_KEY exported in the environment.
|
|
11
|
+
// Recommended: add `export LINEAR_API_KEY=lin_api_xxx` to ~/.zshenv (zsh) or
|
|
12
|
+
// ~/.bashrc (bash) so it's available in every shell — including the
|
|
13
|
+
// non-interactive ones spawned by Claude Code.
|
|
14
|
+
//
|
|
15
|
+
// Workspace identifiers (team UUID, member UUIDs, state/label UUIDs) are loaded
|
|
16
|
+
// from a per-user JSON file (default: ~/.config/linctl/workspace.json). On first
|
|
17
|
+
// use, run `node linear.mjs init` to generate it from your Linear workspace.
|
|
18
|
+
|
|
19
|
+
import { parseArgs } from "node:util";
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { createInterface } from "node:readline/promises";
|
|
24
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
25
|
+
|
|
26
|
+
const API_URL = "https://api.linear.app/graphql";
|
|
27
|
+
const API_KEY = process.env.LINEAR_API_KEY;
|
|
28
|
+
const WORKSPACE_FILE =
|
|
29
|
+
process.env.LINCTL_WORKSPACE_FILE ||
|
|
30
|
+
join(homedir(), ".config", "linctl", "workspace.json");
|
|
31
|
+
|
|
32
|
+
// --- Small utilities -------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** @param {string} msg */
|
|
35
|
+
function fail(msg) {
|
|
36
|
+
console.error(`Error: ${msg}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {number} ms */
|
|
41
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
|
|
43
|
+
/** @param {unknown} obj */
|
|
44
|
+
const printJson = (obj) => console.log(JSON.stringify(obj, null, 2));
|
|
45
|
+
|
|
46
|
+
/** @param {string} s */
|
|
47
|
+
const isUuid = (s) =>
|
|
48
|
+
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
|
49
|
+
s,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** @param {string|undefined} v @returns {number|undefined} */
|
|
53
|
+
function toInt(v) {
|
|
54
|
+
if (v == null) return undefined;
|
|
55
|
+
const n = Number.parseInt(v, 10);
|
|
56
|
+
if (Number.isNaN(n)) fail(`expected an integer, got: ${v}`);
|
|
57
|
+
return n;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a flag value, preferring a `<key>-file` path if present so callers can
|
|
62
|
+
* pass long markdown without shell-escaping it.
|
|
63
|
+
* @param {Record<string, any>} v
|
|
64
|
+
* @param {string} key
|
|
65
|
+
* @returns {string|undefined}
|
|
66
|
+
*/
|
|
67
|
+
function readMaybeFile(v, key) {
|
|
68
|
+
const path = v[`${key}-file`];
|
|
69
|
+
if (path) return readFileSync(path, "utf8");
|
|
70
|
+
return v[key];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Workspace config ------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {{
|
|
77
|
+
* teamId: string,
|
|
78
|
+
* members: { frontend?: string, backend?: string, frontendName?: string, backendName?: string },
|
|
79
|
+
* states: Record<string, string>,
|
|
80
|
+
* labels: Record<string, string>,
|
|
81
|
+
* }} Config
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/** @returns {Config | null} */
|
|
85
|
+
function loadConfig() {
|
|
86
|
+
if (!existsSync(WORKSPACE_FILE)) return null;
|
|
87
|
+
const raw = JSON.parse(readFileSync(WORKSPACE_FILE, "utf8"));
|
|
88
|
+
const teams = raw.teams ?? [];
|
|
89
|
+
const roles = raw.roles ?? {};
|
|
90
|
+
return {
|
|
91
|
+
teamId: teams[0]?.id ?? "",
|
|
92
|
+
members: {
|
|
93
|
+
frontend: roles.frontend_lead?.id,
|
|
94
|
+
backend: roles.backend_lead?.id,
|
|
95
|
+
frontendName: roles.frontend_lead?.name,
|
|
96
|
+
backendName: roles.backend_lead?.name,
|
|
97
|
+
},
|
|
98
|
+
states: raw.states ?? {},
|
|
99
|
+
labels: raw.labels ?? {},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @returns {Config} */
|
|
104
|
+
function requireConfig() {
|
|
105
|
+
const cfg = loadConfig();
|
|
106
|
+
if (!cfg || !cfg.teamId) {
|
|
107
|
+
fail(
|
|
108
|
+
`workspace config not found or incomplete at ${WORKSPACE_FILE}\n` +
|
|
109
|
+
` Run \`node linear.mjs init\` to generate it.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// @ts-ignore — fail() exits, so cfg is non-null past here.
|
|
113
|
+
return cfg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Symbolic-name resolution ----------------------------------------------
|
|
117
|
+
|
|
118
|
+
const STATE_ALIASES = {
|
|
119
|
+
backlog: "Backlog",
|
|
120
|
+
todo: "Todo",
|
|
121
|
+
"in progress": "In Progress",
|
|
122
|
+
"in-progress": "In Progress",
|
|
123
|
+
in_progress: "In Progress",
|
|
124
|
+
inprogress: "In Progress",
|
|
125
|
+
started: "In Progress",
|
|
126
|
+
"in review": "In Review",
|
|
127
|
+
"in-review": "In Review",
|
|
128
|
+
in_review: "In Review",
|
|
129
|
+
inreview: "In Review",
|
|
130
|
+
done: "Done",
|
|
131
|
+
completed: "Done",
|
|
132
|
+
canceled: "Canceled",
|
|
133
|
+
cancelled: "Canceled",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const PRIORITY = {
|
|
137
|
+
none: 0,
|
|
138
|
+
urgent: 1,
|
|
139
|
+
high: 2,
|
|
140
|
+
medium: 3,
|
|
141
|
+
low: 4,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** @param {string} s @param {Config} cfg */
|
|
145
|
+
function resolveState(s, cfg) {
|
|
146
|
+
if (isUuid(s)) return s;
|
|
147
|
+
const display = STATE_ALIASES[s.toLowerCase()] ?? s;
|
|
148
|
+
const id = cfg.states[display];
|
|
149
|
+
if (!id) fail(`unknown state "${s}" (and not a UUID). Run \`init\` to configure states.`);
|
|
150
|
+
return id;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {string} s @param {Config} cfg */
|
|
154
|
+
function resolveMember(s, cfg) {
|
|
155
|
+
if (isUuid(s)) return s;
|
|
156
|
+
const key = s.toLowerCase();
|
|
157
|
+
if (key === "frontend" || key === "fe") {
|
|
158
|
+
if (!cfg.members.frontend) fail("frontend lead is not configured. Run `init`.");
|
|
159
|
+
return cfg.members.frontend;
|
|
160
|
+
}
|
|
161
|
+
if (key === "backend" || key === "be") {
|
|
162
|
+
if (!cfg.members.backend) fail("backend lead is not configured. Run `init`.");
|
|
163
|
+
return cfg.members.backend;
|
|
164
|
+
}
|
|
165
|
+
fail(`unknown member role "${s}" (expected frontend|backend or a UUID).`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param {string} name @param {Config} cfg */
|
|
169
|
+
function resolveLabel(name, cfg) {
|
|
170
|
+
if (isUuid(name)) return name;
|
|
171
|
+
if (cfg.labels[name]) return cfg.labels[name];
|
|
172
|
+
const norm = (x) => x.toLowerCase().replace(/-/g, " ").trim();
|
|
173
|
+
const target = norm(name);
|
|
174
|
+
for (const [k, id] of Object.entries(cfg.labels)) {
|
|
175
|
+
if (norm(k) === target) return id;
|
|
176
|
+
}
|
|
177
|
+
fail(`unknown label "${name}" (and not a UUID). Run \`init\` to configure labels.`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {string} csv @param {Config} cfg */
|
|
181
|
+
function resolveLabels(csv, cfg) {
|
|
182
|
+
return csv
|
|
183
|
+
.split(",")
|
|
184
|
+
.map((s) => s.trim())
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.map((s) => resolveLabel(s, cfg));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string} p */
|
|
190
|
+
function resolvePriority(p) {
|
|
191
|
+
const key = p.toLowerCase();
|
|
192
|
+
if (key in PRIORITY) return PRIORITY[key];
|
|
193
|
+
const n = Number.parseInt(p, 10);
|
|
194
|
+
if (!Number.isNaN(n) && n >= 0 && n <= 4) return n;
|
|
195
|
+
fail(`invalid priority "${p}" (expected 0-4 or none|urgent|high|medium|low).`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {string} s @param {Config} cfg */
|
|
199
|
+
async function resolveCycle(s, cfg) {
|
|
200
|
+
if (s === "current" || s === "active") return await currentCycleId(cfg);
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Core API call ---------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} query
|
|
208
|
+
* @param {Record<string, unknown>} [variables]
|
|
209
|
+
* @returns {Promise<any>}
|
|
210
|
+
*/
|
|
211
|
+
async function gql(query, variables = {}) {
|
|
212
|
+
if (!API_KEY) {
|
|
213
|
+
fail(
|
|
214
|
+
"LINEAR_API_KEY is not set.\n" +
|
|
215
|
+
" Add to ~/.zshenv (or ~/.bashrc) and open a new shell:\n" +
|
|
216
|
+
" export LINEAR_API_KEY=lin_api_xxx\n" +
|
|
217
|
+
" Get a key from https://linear.app/settings/account/security",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const res = await fetch(API_URL, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
// Linear personal API keys go in Authorization raw (no "Bearer" prefix).
|
|
225
|
+
Authorization: /** @type {string} */ (API_KEY),
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({ query, variables }),
|
|
228
|
+
});
|
|
229
|
+
return res.json();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Print a mutation/query result as JSON; exit non-zero if it carried errors. */
|
|
233
|
+
function emit(data) {
|
|
234
|
+
printJson(data);
|
|
235
|
+
if (data?.errors?.length) process.exitCode = 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- Pretty printers -------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
const PRI_LABEL = { 0: "-", 1: "Urgent", 2: "High", 3: "Medium", 4: "Low" };
|
|
241
|
+
|
|
242
|
+
/** @param {string} s @param {number} n */
|
|
243
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
244
|
+
/** @param {string|number} s @param {number} n */
|
|
245
|
+
const padL = (s, n) => String(s).padStart(n);
|
|
246
|
+
|
|
247
|
+
function prettyIssues(data) {
|
|
248
|
+
const issues = data?.data?.team?.issues?.nodes ?? [];
|
|
249
|
+
if (!issues.length) return console.log("No issues found.");
|
|
250
|
+
console.log(
|
|
251
|
+
`${pad("ID", 10)} ${pad("Priority", 8)} ${pad("Status", 14)} ${pad("Assignee", 12)} ${padL("Est", 3)} Title`,
|
|
252
|
+
);
|
|
253
|
+
console.log("-".repeat(80));
|
|
254
|
+
for (const i of issues) {
|
|
255
|
+
const pid = PRI_LABEL[i.priority] ?? "-";
|
|
256
|
+
const assignee = i.assignee?.name?.split(" ")[0] ?? "-";
|
|
257
|
+
const est = i.estimate ?? "-";
|
|
258
|
+
const labels = (i.labels?.nodes ?? []).map((l) => l.name).join(", ");
|
|
259
|
+
const title = labels ? `${i.title} [${labels}]` : i.title;
|
|
260
|
+
console.log(
|
|
261
|
+
`${pad(i.identifier, 10)} ${pad(pid, 8)} ${pad(i.state.name, 14)} ${pad(assignee, 12)} ${padL(est, 3)} ${title}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function prettyProjects(data) {
|
|
267
|
+
const projects = data?.data?.team?.projects?.nodes ?? [];
|
|
268
|
+
if (!projects.length) return console.log("No projects found.");
|
|
269
|
+
console.log(
|
|
270
|
+
`${pad("Initiative", 15)} ${pad("Project", 35)} ${pad("State", 10)} ${padL("Progress", 8)} Target Date`,
|
|
271
|
+
);
|
|
272
|
+
console.log("-".repeat(90));
|
|
273
|
+
for (const p of projects) {
|
|
274
|
+
const inits = p.initiatives?.nodes ?? [];
|
|
275
|
+
const initiative = inits[0]?.name ?? "-";
|
|
276
|
+
const progress = p.progress != null ? `${Math.round(p.progress * 100)}%` : "-";
|
|
277
|
+
const target = p.targetDate ?? "-";
|
|
278
|
+
console.log(
|
|
279
|
+
`${pad(initiative, 15)} ${pad(p.name, 35)} ${pad(p.state, 10)} ${padL(progress, 8)} ${target}`,
|
|
280
|
+
);
|
|
281
|
+
console.log(` ID: ${p.id}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function prettyMilestones(data) {
|
|
286
|
+
const project = data?.data?.project;
|
|
287
|
+
if (!project) return console.log("Project not found.");
|
|
288
|
+
const milestones = project.projectMilestones?.nodes ?? [];
|
|
289
|
+
console.log(`Project: ${project.name}`);
|
|
290
|
+
if (!milestones.length) return console.log("No milestones found.");
|
|
291
|
+
console.log(`${pad("Milestone", 40)} ${pad("Target Date", 15)}`);
|
|
292
|
+
console.log("-".repeat(55));
|
|
293
|
+
for (const m of milestones) {
|
|
294
|
+
console.log(`${pad(m.name, 40)} ${pad(m.targetDate ?? "-", 15)}`);
|
|
295
|
+
console.log(` ID: ${m.id}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function prettyProjectIssues(data) {
|
|
300
|
+
const project = data?.data?.project;
|
|
301
|
+
if (!project) return console.log("Project not found.");
|
|
302
|
+
const issues = project.issues?.nodes ?? [];
|
|
303
|
+
console.log(`Project: ${project.name} (${issues.length} issues)`);
|
|
304
|
+
if (!issues.length) return console.log("No issues found.");
|
|
305
|
+
/** @type {Record<string, any[]>} */
|
|
306
|
+
const grouped = {};
|
|
307
|
+
for (const i of issues) {
|
|
308
|
+
const ms = i.projectMilestone?.name ?? "(No milestone)";
|
|
309
|
+
(grouped[ms] ??= []).push(i);
|
|
310
|
+
}
|
|
311
|
+
for (const [msName, msIssues] of Object.entries(grouped)) {
|
|
312
|
+
console.log(`\n## ${msName}`);
|
|
313
|
+
console.log(
|
|
314
|
+
`${pad("ID", 10)} ${pad("Priority", 8)} ${pad("Status", 14)} ${pad("Assignee", 12)} Title`,
|
|
315
|
+
);
|
|
316
|
+
console.log("-".repeat(70));
|
|
317
|
+
for (const i of msIssues) {
|
|
318
|
+
const pid = PRI_LABEL[i.priority] ?? "-";
|
|
319
|
+
const assignee = i.assignee?.name?.split(" ")[0] ?? "-";
|
|
320
|
+
console.log(
|
|
321
|
+
`${pad(i.identifier, 10)} ${pad(pid, 8)} ${pad(i.state.name, 14)} ${pad(assignee, 12)} ${i.title}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function prettyInitiatives(data) {
|
|
328
|
+
const nodes = data?.data?.initiatives?.nodes ?? [];
|
|
329
|
+
if (!nodes.length) return console.log("No initiatives found.");
|
|
330
|
+
console.log(`${pad("Name", 20)} ${pad("Status", 12)} ${pad("Description", 45)} URL`);
|
|
331
|
+
console.log("-".repeat(110));
|
|
332
|
+
for (const i of nodes) {
|
|
333
|
+
let desc = (i.description ?? "-").slice(0, 42);
|
|
334
|
+
if ((i.description ?? "").length > 42) desc += "...";
|
|
335
|
+
console.log(
|
|
336
|
+
`${pad(i.name, 20)} ${pad(i.status ?? "-", 12)} ${pad(desc, 45)} ${i.url ?? "-"}`,
|
|
337
|
+
);
|
|
338
|
+
console.log(` ID: ${i.id}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** @param {any[]} cycles */
|
|
343
|
+
function prettyRebalance(cycles) {
|
|
344
|
+
cycles = cycles
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.sort((a, b) => (a.startsAt ?? "").localeCompare(b.startsAt ?? ""));
|
|
347
|
+
if (!cycles.length) return console.log("No active/upcoming cycles found.");
|
|
348
|
+
for (const c of cycles) {
|
|
349
|
+
const issues = c.issues?.nodes ?? [];
|
|
350
|
+
const totalEst = issues.reduce((s, i) => s + (i.estimate ?? 0), 0);
|
|
351
|
+
const active = c.isActive ? " [ACTIVE]" : "";
|
|
352
|
+
const progressPct = Math.round((c.progress ?? 0) * 100);
|
|
353
|
+
console.log(`\n${"=".repeat(90)}`);
|
|
354
|
+
console.log(
|
|
355
|
+
`Cycle ${c.number}${active} (${c.startsAt?.slice(0, 10)} to ${c.endsAt?.slice(0, 10)}) Progress: ${progressPct}% Issues: ${issues.length} Est: ${totalEst}`,
|
|
356
|
+
);
|
|
357
|
+
console.log("=".repeat(90));
|
|
358
|
+
/** @type {Record<string, any[]>} */
|
|
359
|
+
const byProject = {};
|
|
360
|
+
for (const i of issues) {
|
|
361
|
+
const proj = i.project?.name ?? "(No project)";
|
|
362
|
+
(byProject[proj] ??= []).push(i);
|
|
363
|
+
}
|
|
364
|
+
for (const proj of Object.keys(byProject).sort()) {
|
|
365
|
+
const pi = byProject[proj];
|
|
366
|
+
const pe = pi.reduce((s, i) => s + (i.estimate ?? 0), 0);
|
|
367
|
+
console.log(`\n ${proj} (${pi.length} issues, est: ${pe})`);
|
|
368
|
+
console.log(` ${"-".repeat(70)}`);
|
|
369
|
+
for (const i of pi) {
|
|
370
|
+
const pid = PRI_LABEL[i.priority] ?? "-";
|
|
371
|
+
const assignee = i.assignee?.name?.split(" ")[0] ?? "-";
|
|
372
|
+
const est = i.estimate ?? "-";
|
|
373
|
+
const ms = i.projectMilestone ? ` [${i.projectMilestone.name}]` : "";
|
|
374
|
+
console.log(
|
|
375
|
+
` ${pad(i.identifier, 10)} ${pad(pid, 8)} ${pad(i.state.name, 14)} ${pad(assignee, 12)} ${padL(est, 3)} ${i.title}${ms}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
console.log();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// --- Reusable query fragments ----------------------------------------------
|
|
384
|
+
|
|
385
|
+
const ISSUE_FIELDS = `id identifier title priority estimate state { name } assignee { name } labels { nodes { name } }`;
|
|
386
|
+
|
|
387
|
+
// --- Command handlers ------------------------------------------------------
|
|
388
|
+
// Each handler: (args: string[], v: Record<string, any>, cfg: Config) => Promise<void>
|
|
389
|
+
|
|
390
|
+
async function cmdCreateIssue(args, v, cfg) {
|
|
391
|
+
if (!v.title) fail("create-issue requires --title");
|
|
392
|
+
/** @type {Record<string, unknown>} */
|
|
393
|
+
const inp = { title: v.title, teamId: cfg.teamId };
|
|
394
|
+
const desc = readMaybeFile(v, "description");
|
|
395
|
+
if (desc) inp.description = desc;
|
|
396
|
+
if (v.priority != null) inp.priority = resolvePriority(v.priority);
|
|
397
|
+
inp.stateId = v.state ? resolveState(v.state, cfg) : cfg.states["Backlog"];
|
|
398
|
+
if (!inp.stateId) delete inp.stateId;
|
|
399
|
+
if (v.assignee) inp.assigneeId = resolveMember(v.assignee, cfg);
|
|
400
|
+
if (v.labels) inp.labelIds = resolveLabels(v.labels, cfg);
|
|
401
|
+
if (v.estimate != null) inp.estimate = toInt(v.estimate);
|
|
402
|
+
if (v.project) inp.projectId = v.project;
|
|
403
|
+
if (v.milestone) inp.projectMilestoneId = v.milestone;
|
|
404
|
+
if (v.cycle) inp.cycleId = await resolveCycle(v.cycle, cfg);
|
|
405
|
+
if (v.raw) Object.assign(inp, JSON.parse(v.raw));
|
|
406
|
+
|
|
407
|
+
emit(
|
|
408
|
+
await gql(
|
|
409
|
+
`mutation IssueCreate($input: IssueCreateInput!) {
|
|
410
|
+
issueCreate(input: $input) {
|
|
411
|
+
success
|
|
412
|
+
issue { id identifier title url priority state { name } assignee { name } labels { nodes { name } } }
|
|
413
|
+
}
|
|
414
|
+
}`,
|
|
415
|
+
{ input: inp },
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function cmdListIssues(args, v, cfg) {
|
|
421
|
+
const stateType = args[0]; // backlog|unstarted|started|completed|canceled
|
|
422
|
+
const limit = toInt(v.limit) ?? 50;
|
|
423
|
+
const filter = stateType ? `filter: { state: { type: { eq: "${stateType}" } } },` : "";
|
|
424
|
+
const data = await gql(
|
|
425
|
+
`{ team(id: "${cfg.teamId}") {
|
|
426
|
+
issues(${filter} first: ${limit}, orderBy: updatedAt) {
|
|
427
|
+
nodes { ${ISSUE_FIELDS} cycle { number } }
|
|
428
|
+
}
|
|
429
|
+
} }`,
|
|
430
|
+
);
|
|
431
|
+
v.json ? emit(data) : prettyIssues(data);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function cmdListCycleIssues(args, v, cfg) {
|
|
435
|
+
emit(
|
|
436
|
+
await gql(
|
|
437
|
+
`{ team(id: "${cfg.teamId}") {
|
|
438
|
+
cycles(filter: { isActive: { eq: true } }) {
|
|
439
|
+
nodes { number startsAt endsAt issues { nodes { ${ISSUE_FIELDS} } } }
|
|
440
|
+
}
|
|
441
|
+
} }`,
|
|
442
|
+
),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function cmdUpdateIssue(args, v, cfg) {
|
|
447
|
+
const issueId = args[0];
|
|
448
|
+
if (!issueId) fail("update-issue requires an issue id");
|
|
449
|
+
/** @type {Record<string, unknown>} */
|
|
450
|
+
const inp = {};
|
|
451
|
+
if (v.title) inp.title = v.title;
|
|
452
|
+
const desc = readMaybeFile(v, "description");
|
|
453
|
+
if (desc) inp.description = desc;
|
|
454
|
+
if (v.priority != null) inp.priority = resolvePriority(v.priority);
|
|
455
|
+
if (v.state) inp.stateId = resolveState(v.state, cfg);
|
|
456
|
+
if (v.assignee) inp.assigneeId = resolveMember(v.assignee, cfg);
|
|
457
|
+
if (v.labels) inp.labelIds = resolveLabels(v.labels, cfg);
|
|
458
|
+
if (v.estimate != null) inp.estimate = toInt(v.estimate);
|
|
459
|
+
if (v.project) inp.projectId = v.project;
|
|
460
|
+
if (v.milestone) inp.projectMilestoneId = v.milestone;
|
|
461
|
+
if (v.cycle) inp.cycleId = await resolveCycle(v.cycle, cfg);
|
|
462
|
+
if (v.raw) Object.assign(inp, JSON.parse(v.raw));
|
|
463
|
+
if (!Object.keys(inp).length) fail("update-issue: nothing to update (pass flags or --raw)");
|
|
464
|
+
emit(await issueUpdate(issueId, inp));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** @param {string} issueId @param {Record<string, unknown>} input */
|
|
468
|
+
function issueUpdate(issueId, input) {
|
|
469
|
+
return gql(
|
|
470
|
+
`mutation IssueUpdate($issueId: String!, $input: IssueUpdateInput!) {
|
|
471
|
+
issueUpdate(id: $issueId, input: $input) {
|
|
472
|
+
success
|
|
473
|
+
issue { id identifier title state { name } priority assignee { name } }
|
|
474
|
+
}
|
|
475
|
+
}`,
|
|
476
|
+
{ issueId, input },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function cmdMoveIssue(args, v, cfg) {
|
|
481
|
+
const [issueId, ...statusParts] = args;
|
|
482
|
+
const status = statusParts.join(" ");
|
|
483
|
+
if (!issueId || !status) fail('move-issue requires: <issue-id> "<status>"');
|
|
484
|
+
emit(await issueUpdate(issueId, { stateId: resolveState(status, cfg) }));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function cmdAssignIssue(args, v, cfg) {
|
|
488
|
+
const [issueId, member] = args;
|
|
489
|
+
if (!issueId || !member) fail("assign-issue requires: <issue-id> <frontend|backend>");
|
|
490
|
+
emit(await issueUpdate(issueId, { assigneeId: resolveMember(member, cfg) }));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function cmdSearchIssues(args, v, cfg) {
|
|
494
|
+
const term = args[0];
|
|
495
|
+
if (!term) fail("search-issues requires a search term");
|
|
496
|
+
emit(
|
|
497
|
+
await gql(
|
|
498
|
+
`query SearchIssues($term: String!) {
|
|
499
|
+
searchIssues(term: $term, first: 20) {
|
|
500
|
+
nodes { id identifier title state { name } assignee { name } priority }
|
|
501
|
+
}
|
|
502
|
+
}`,
|
|
503
|
+
{ term },
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function cmdCreateProject(args, v, cfg) {
|
|
509
|
+
const name = v.name ?? args[0];
|
|
510
|
+
if (!name) fail("create-project requires --name (or a positional name)");
|
|
511
|
+
/** @type {Record<string, unknown>} */
|
|
512
|
+
const inp = { name, teamIds: [cfg.teamId] };
|
|
513
|
+
const desc = readMaybeFile(v, "description");
|
|
514
|
+
if (desc) inp.description = desc;
|
|
515
|
+
const result = await gql(
|
|
516
|
+
`mutation CreateProject($input: ProjectCreateInput!) {
|
|
517
|
+
projectCreate(input: $input) { success project { id name state url } }
|
|
518
|
+
}`,
|
|
519
|
+
{ input: inp },
|
|
520
|
+
);
|
|
521
|
+
// Optionally link to an initiative.
|
|
522
|
+
const initiativeId = v.initiative;
|
|
523
|
+
const projectId = result?.data?.projectCreate?.project?.id;
|
|
524
|
+
if (initiativeId && projectId) {
|
|
525
|
+
await gql(
|
|
526
|
+
`mutation LinkInitiativeProject($input: InitiativeToProjectCreateInput!) {
|
|
527
|
+
initiativeToProjectCreate(input: $input) { success }
|
|
528
|
+
}`,
|
|
529
|
+
{ input: { initiativeId, projectId } },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
emit(result);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function cmdListProjects(args, v, cfg) {
|
|
536
|
+
const data = await gql(
|
|
537
|
+
`{ team(id: "${cfg.teamId}") {
|
|
538
|
+
projects(first: 50, orderBy: updatedAt) {
|
|
539
|
+
nodes { id name state progress startDate targetDate initiatives { nodes { name } } }
|
|
540
|
+
}
|
|
541
|
+
} }`,
|
|
542
|
+
);
|
|
543
|
+
v.json ? emit(data) : prettyProjects(data);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function cmdListMilestones(args, v, cfg) {
|
|
547
|
+
const projectId = args[0];
|
|
548
|
+
if (!projectId) fail("list-milestones requires a project id");
|
|
549
|
+
const data = await gql(
|
|
550
|
+
`{ project(id: "${projectId}") {
|
|
551
|
+
name
|
|
552
|
+
projectMilestones(first: 50) { nodes { id name targetDate sortOrder } }
|
|
553
|
+
} }`,
|
|
554
|
+
);
|
|
555
|
+
v.json ? emit(data) : prettyMilestones(data);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function cmdListProjectIssues(args, v, cfg) {
|
|
559
|
+
const projectId = args[0];
|
|
560
|
+
if (!projectId) fail("list-project-issues requires a project id");
|
|
561
|
+
const limit = toInt(v.limit) ?? (v.json ? 200 : 50);
|
|
562
|
+
const data = await gql(
|
|
563
|
+
`{ project(id: "${projectId}") {
|
|
564
|
+
name
|
|
565
|
+
issues(first: ${limit}, orderBy: updatedAt) {
|
|
566
|
+
nodes { ${ISSUE_FIELDS.replace("state { name }", "state { name type }")} projectMilestone { id name } }
|
|
567
|
+
}
|
|
568
|
+
} }`,
|
|
569
|
+
);
|
|
570
|
+
v.json ? emit(data) : prettyProjectIssues(data);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cmdCreateMilestone(args, v, cfg) {
|
|
574
|
+
const [projectId, ...nameParts] = args;
|
|
575
|
+
const name = v.name ?? nameParts.join(" ");
|
|
576
|
+
if (!projectId || !name) fail('create-milestone requires: <project-id> "<name>"');
|
|
577
|
+
/** @type {Record<string, unknown>} */
|
|
578
|
+
const inp = { projectId, name };
|
|
579
|
+
if (v["target-date"]) inp.targetDate = v["target-date"];
|
|
580
|
+
emit(
|
|
581
|
+
await gql(
|
|
582
|
+
`mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) {
|
|
583
|
+
projectMilestoneCreate(input: $input) {
|
|
584
|
+
success projectMilestone { id name targetDate sortOrder }
|
|
585
|
+
}
|
|
586
|
+
}`,
|
|
587
|
+
{ input: inp },
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function cmdUpdateMilestone(args, v, cfg) {
|
|
593
|
+
const milestoneId = args[0];
|
|
594
|
+
if (!milestoneId) fail("update-milestone requires a milestone id");
|
|
595
|
+
/** @type {Record<string, unknown>} */
|
|
596
|
+
const inp = {};
|
|
597
|
+
if (v.name) inp.name = v.name;
|
|
598
|
+
if (v["target-date"]) inp.targetDate = v["target-date"];
|
|
599
|
+
if (v["sort-order"] != null) inp.sortOrder = toInt(v["sort-order"]);
|
|
600
|
+
if (v.raw) Object.assign(inp, JSON.parse(v.raw));
|
|
601
|
+
if (!Object.keys(inp).length) fail("update-milestone: nothing to update");
|
|
602
|
+
emit(
|
|
603
|
+
await gql(
|
|
604
|
+
`mutation UpdateProjectMilestone($milestoneId: String!, $input: ProjectMilestoneUpdateInput!) {
|
|
605
|
+
projectMilestoneUpdate(id: $milestoneId, input: $input) {
|
|
606
|
+
success projectMilestone { id name targetDate sortOrder }
|
|
607
|
+
}
|
|
608
|
+
}`,
|
|
609
|
+
{ milestoneId, input: inp },
|
|
610
|
+
),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function cmdDeleteMilestone(args, v, cfg) {
|
|
615
|
+
const milestoneId = args[0];
|
|
616
|
+
if (!milestoneId) fail("delete-milestone requires a milestone id");
|
|
617
|
+
emit(
|
|
618
|
+
await gql(
|
|
619
|
+
`mutation DeleteProjectMilestone($milestoneId: String!) {
|
|
620
|
+
projectMilestoneDelete(id: $milestoneId) { success }
|
|
621
|
+
}`,
|
|
622
|
+
{ milestoneId },
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function cmdSetIssueMilestone(args, v, cfg) {
|
|
628
|
+
const [issueId, milestoneId] = args;
|
|
629
|
+
if (!issueId) fail('set-issue-milestone requires: <issue-id> <milestone-id|"">');
|
|
630
|
+
// Empty string / "none" unsets the milestone.
|
|
631
|
+
const ms = milestoneId && milestoneId !== "none" ? milestoneId : null;
|
|
632
|
+
emit(
|
|
633
|
+
await gql(
|
|
634
|
+
`mutation IssueUpdate($issueId: String!, $input: IssueUpdateInput!) {
|
|
635
|
+
issueUpdate(id: $issueId, input: $input) {
|
|
636
|
+
success issue { id identifier title projectMilestone { name } }
|
|
637
|
+
}
|
|
638
|
+
}`,
|
|
639
|
+
{ issueId, input: { projectMilestoneId: ms } },
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function cmdBatchMoveToMilestone(args, v, cfg) {
|
|
645
|
+
const [milestoneId, ...issueIds] = args;
|
|
646
|
+
if (!milestoneId || !issueIds.length)
|
|
647
|
+
fail("batch-move-to-milestone requires: <milestone-id> <issue-id>...");
|
|
648
|
+
let count = 0;
|
|
649
|
+
for (const id of issueIds) {
|
|
650
|
+
count++;
|
|
651
|
+
console.error(`Moving issue ${count}/${issueIds.length} (${id}) to milestone...`);
|
|
652
|
+
await issueUpdate(id, { projectMilestoneId: milestoneId });
|
|
653
|
+
await sleep(500);
|
|
654
|
+
}
|
|
655
|
+
console.error(`Done. Moved ${issueIds.length} issues.`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function cmdAddDependency(args, v, cfg) {
|
|
659
|
+
const [blockerId, blockedId] = args;
|
|
660
|
+
if (!blockerId || !blockedId)
|
|
661
|
+
fail("add-dependency requires: <blocker-issue-id> <blocked-issue-id>");
|
|
662
|
+
emit(
|
|
663
|
+
await gql(
|
|
664
|
+
`mutation CreateIssueRelation($input: IssueRelationCreateInput!) {
|
|
665
|
+
issueRelationCreate(input: $input) {
|
|
666
|
+
success
|
|
667
|
+
issueRelation { id type issue { identifier title } relatedIssue { identifier title } }
|
|
668
|
+
}
|
|
669
|
+
}`,
|
|
670
|
+
{ input: { issueId: blockerId, relatedIssueId: blockedId, type: "blocks" } },
|
|
671
|
+
),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function cmdListDependencies(args, v, cfg) {
|
|
676
|
+
const issueId = args[0];
|
|
677
|
+
if (!issueId) fail("list-dependencies requires an issue id");
|
|
678
|
+
emit(
|
|
679
|
+
await gql(
|
|
680
|
+
`{ issue(id: "${issueId}") {
|
|
681
|
+
identifier title
|
|
682
|
+
relations { nodes { id type relatedIssue { identifier title state { name } } } }
|
|
683
|
+
inverseRelations { nodes { id type issue { identifier title state { name } } } }
|
|
684
|
+
} }`,
|
|
685
|
+
),
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function cmdRemoveDependency(args, v, cfg) {
|
|
690
|
+
const relationId = args[0];
|
|
691
|
+
if (!relationId) fail("remove-dependency requires a relation id");
|
|
692
|
+
emit(
|
|
693
|
+
await gql(
|
|
694
|
+
`mutation DeleteIssueRelation($relationId: String!) {
|
|
695
|
+
issueRelationDelete(id: $relationId) { success }
|
|
696
|
+
}`,
|
|
697
|
+
{ relationId },
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function cmdAddComment(args, v, cfg) {
|
|
703
|
+
const issueId = args[0];
|
|
704
|
+
const body = readMaybeFile(v, "body") ?? args[1];
|
|
705
|
+
if (!issueId || !body) fail("add-comment requires: <issue-id> --body <text> (or a positional body)");
|
|
706
|
+
emit(
|
|
707
|
+
await gql(
|
|
708
|
+
`mutation CreateComment($input: CommentCreateInput!) {
|
|
709
|
+
commentCreate(input: $input) { success comment { id body user { name } } }
|
|
710
|
+
}`,
|
|
711
|
+
{ input: { issueId, body } },
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function cmdCreateInitiative(args, v, cfg) {
|
|
717
|
+
const name = v.name ?? args[0];
|
|
718
|
+
if (!name) fail("create-initiative requires --name (or a positional name)");
|
|
719
|
+
/** @type {Record<string, unknown>} */
|
|
720
|
+
const inp = { name };
|
|
721
|
+
const desc = readMaybeFile(v, "description");
|
|
722
|
+
if (desc) inp.description = desc;
|
|
723
|
+
emit(
|
|
724
|
+
await gql(
|
|
725
|
+
`mutation CreateInitiative($input: InitiativeCreateInput!) {
|
|
726
|
+
initiativeCreate(input: $input) {
|
|
727
|
+
success initiative { id name description status url }
|
|
728
|
+
}
|
|
729
|
+
}`,
|
|
730
|
+
{ input: inp },
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const INITIATIVE_LIST_QUERY = `{ initiatives(first: 50) { nodes { id name description status url } } }`;
|
|
736
|
+
|
|
737
|
+
async function cmdListInitiatives(args, v, cfg) {
|
|
738
|
+
const data = await gql(INITIATIVE_LIST_QUERY);
|
|
739
|
+
v.json ? emit(data) : prettyInitiatives(data);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function cmdGetInitiative(args, v, cfg) {
|
|
743
|
+
const id = args[0];
|
|
744
|
+
if (!id) fail("get-initiative requires an initiative id");
|
|
745
|
+
emit(await getInitiativeById(id));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** @param {string} id */
|
|
749
|
+
function getInitiativeById(id) {
|
|
750
|
+
return gql(
|
|
751
|
+
`query GetInitiative($id: String!) {
|
|
752
|
+
initiative(id: $id) {
|
|
753
|
+
id name description content status url
|
|
754
|
+
projects { nodes { id name state progress startDate targetDate } }
|
|
755
|
+
}
|
|
756
|
+
}`,
|
|
757
|
+
{ id },
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function cmdGetInitiativeByName(args, v, cfg) {
|
|
762
|
+
const search = (args[0] ?? "").toLowerCase();
|
|
763
|
+
if (!search) fail("get-initiative-by-name requires a name");
|
|
764
|
+
const all = await gql(INITIATIVE_LIST_QUERY);
|
|
765
|
+
const match = (all?.data?.initiatives?.nodes ?? []).find((i) =>
|
|
766
|
+
i.name.toLowerCase().includes(search),
|
|
767
|
+
);
|
|
768
|
+
if (!match) fail(`No initiative found matching "${args[0]}"`);
|
|
769
|
+
emit(await getInitiativeById(match.id));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function cmdUpdateInitiative(args, v, cfg) {
|
|
773
|
+
const id = args[0];
|
|
774
|
+
if (!id) fail("update-initiative requires an initiative id");
|
|
775
|
+
/** @type {Record<string, unknown>} */
|
|
776
|
+
const inp = {};
|
|
777
|
+
if (v.name) inp.name = v.name;
|
|
778
|
+
const desc = readMaybeFile(v, "description");
|
|
779
|
+
if (desc) inp.description = desc;
|
|
780
|
+
const content = readMaybeFile(v, "content");
|
|
781
|
+
if (content) inp.content = content;
|
|
782
|
+
if (v.raw) Object.assign(inp, JSON.parse(v.raw));
|
|
783
|
+
if (!Object.keys(inp).length) fail("update-initiative: nothing to update");
|
|
784
|
+
emit(
|
|
785
|
+
await gql(
|
|
786
|
+
`mutation UpdateInitiative($initiativeId: String!, $input: InitiativeUpdateInput!) {
|
|
787
|
+
initiativeUpdate(id: $initiativeId, input: $input) {
|
|
788
|
+
success initiative { id name description status url }
|
|
789
|
+
}
|
|
790
|
+
}`,
|
|
791
|
+
{ initiativeId: id, input: inp },
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function cmdAddInitiativeLink(args, v, cfg) {
|
|
797
|
+
const [id, url, ...labelParts] = args;
|
|
798
|
+
const label = v.label ?? labelParts.join(" ");
|
|
799
|
+
if (!id || !url || !label)
|
|
800
|
+
fail('add-initiative-link requires: <initiative-id> <url> "<label>"');
|
|
801
|
+
emit(
|
|
802
|
+
await gql(
|
|
803
|
+
`mutation CreateExternalLink($input: EntityExternalLinkCreateInput!) {
|
|
804
|
+
entityExternalLinkCreate(input: $input) {
|
|
805
|
+
success entityExternalLink { id url label }
|
|
806
|
+
}
|
|
807
|
+
}`,
|
|
808
|
+
{ input: { initiativeId: id, url, label } },
|
|
809
|
+
),
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function cmdListStates(args, v, cfg) {
|
|
814
|
+
emit(await gql(`{ team(id: "${cfg.teamId}") { states { nodes { id name type position } } } }`));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function cmdListMembers(args, v, cfg) {
|
|
818
|
+
emit(await gql(`{ team(id: "${cfg.teamId}") { members { nodes { id name email } } } }`));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function cmdListLabels(args, v, cfg) {
|
|
822
|
+
emit(await gql(`{ team(id: "${cfg.teamId}") { labels { nodes { id name } } } }`));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async function cmdListCycles(args, v, cfg) {
|
|
826
|
+
emit(
|
|
827
|
+
await gql(
|
|
828
|
+
`{ team(id: "${cfg.teamId}") { cycles { nodes { id number startsAt endsAt isActive progress } } } }`,
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** @param {Config} cfg @returns {Promise<string>} */
|
|
834
|
+
async function currentCycleId(cfg) {
|
|
835
|
+
const d = await gql(
|
|
836
|
+
`{ team(id: "${cfg.teamId}") { cycles(filter: { isActive: { eq: true } }) { nodes { id } } } }`,
|
|
837
|
+
);
|
|
838
|
+
return d?.data?.team?.cycles?.nodes?.[0]?.id ?? "";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function cmdCurrentCycleId(args, v, cfg) {
|
|
842
|
+
console.log(await currentCycleId(cfg));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** @param {string} cycleId */
|
|
846
|
+
function cycleIssuesById(cycleId) {
|
|
847
|
+
return gql(
|
|
848
|
+
`query CycleIssues($cycleId: String!) {
|
|
849
|
+
cycle(id: $cycleId) {
|
|
850
|
+
id number startsAt endsAt isActive progress
|
|
851
|
+
issues(filter: { state: { type: { nin: ["completed", "canceled"] } } }, first: 100) {
|
|
852
|
+
nodes {
|
|
853
|
+
${ISSUE_FIELDS.replace("state { name }", "state { name type }")}
|
|
854
|
+
project { id name } projectMilestone { id name }
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}`,
|
|
859
|
+
{ cycleId },
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function cmdListCycleIssuesById(args, v, cfg) {
|
|
864
|
+
const cycleId = args[0];
|
|
865
|
+
if (!cycleId) fail("list-cycle-issues-by-id requires a cycle id");
|
|
866
|
+
emit(await cycleIssuesById(cycleId));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** @param {Config} cfg @returns {Promise<any[]>} */
|
|
870
|
+
async function activeAndFutureCycles(cfg) {
|
|
871
|
+
const list = await gql(
|
|
872
|
+
`{ team(id: "${cfg.teamId}") { cycles { nodes { id startsAt endsAt isActive } } } }`,
|
|
873
|
+
);
|
|
874
|
+
const now = new Date().toISOString();
|
|
875
|
+
const cycles = (list?.data?.team?.cycles?.nodes ?? [])
|
|
876
|
+
.filter((c) => c.isActive || c.endsAt > now)
|
|
877
|
+
.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
|
|
878
|
+
const results = [];
|
|
879
|
+
for (const c of cycles) {
|
|
880
|
+
const d = await cycleIssuesById(c.id);
|
|
881
|
+
results.push(d?.data?.cycle ?? {});
|
|
882
|
+
}
|
|
883
|
+
return results;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function cmdListCycleIssuesAll(args, v, cfg) {
|
|
887
|
+
emit(await activeAndFutureCycles(cfg));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function cmdRebalancePretty(args, v, cfg) {
|
|
891
|
+
const cycles = await activeAndFutureCycles(cfg);
|
|
892
|
+
v.json ? emit(cycles) : prettyRebalance(cycles);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function cmdMoveIssueToCycle(args, v, cfg) {
|
|
896
|
+
const [issueId, cycleId] = args;
|
|
897
|
+
if (!issueId || !cycleId) fail("move-issue-to-cycle requires: <issue-id> <cycle-id>");
|
|
898
|
+
emit(await issueUpdate(issueId, { cycleId: await resolveCycle(cycleId, cfg) }));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function cmdBatchMoveToCycle(args, v, cfg) {
|
|
902
|
+
const [cycleRef, ...issueIds] = args;
|
|
903
|
+
if (!cycleRef || !issueIds.length)
|
|
904
|
+
fail("batch-move-to-cycle requires: <cycle-id> <issue-id>...");
|
|
905
|
+
const cycleId = await resolveCycle(cycleRef, cfg);
|
|
906
|
+
let success = 0;
|
|
907
|
+
let failCount = 0;
|
|
908
|
+
for (const id of issueIds) {
|
|
909
|
+
const result = await issueUpdate(id, { cycleId });
|
|
910
|
+
if (result?.data?.issueUpdate?.success) {
|
|
911
|
+
success++;
|
|
912
|
+
} else {
|
|
913
|
+
failCount++;
|
|
914
|
+
const msg = result?.errors?.[0]?.message ?? JSON.stringify(result);
|
|
915
|
+
console.error(`FAIL (${id}): ${msg}`);
|
|
916
|
+
}
|
|
917
|
+
await sleep(500);
|
|
918
|
+
}
|
|
919
|
+
console.log(`Moved ${success} issues to cycle (${failCount} failed).`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function cmdCycleCapacity(args, v, cfg) {
|
|
923
|
+
const pastData = await gql(
|
|
924
|
+
`query { team(id: "${cfg.teamId}") { cycles(filter: { isPast: { eq: true } }, first: 3) { nodes { number completedScopeHistory } } } }`,
|
|
925
|
+
);
|
|
926
|
+
const currentData = await gql(
|
|
927
|
+
`query { team(id: "${cfg.teamId}") { cycles(filter: { isPast: { eq: false } }) { nodes { id number startsAt endsAt isActive currentProgress } } } }`,
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
const pastCycles = pastData?.data?.team?.cycles?.nodes ?? [];
|
|
931
|
+
if (!pastCycles.length) return console.log("No past cycles found — cannot calculate velocity.");
|
|
932
|
+
|
|
933
|
+
const completedScopes = [];
|
|
934
|
+
for (const c of pastCycles) {
|
|
935
|
+
const h = c.completedScopeHistory ?? [];
|
|
936
|
+
if (h.length) completedScopes.push(h[h.length - 1]);
|
|
937
|
+
}
|
|
938
|
+
if (!completedScopes.length) return console.log("No completed scope data found in past cycles.");
|
|
939
|
+
|
|
940
|
+
const velocity = completedScopes.reduce((a, b) => a + b, 0) / completedScopes.length;
|
|
941
|
+
console.log(
|
|
942
|
+
`Velocity (avg completed pts from last ${completedScopes.length} cycles): ${velocity.toFixed(0)} pts`,
|
|
943
|
+
);
|
|
944
|
+
const details = pastCycles
|
|
945
|
+
.filter((c) => (c.completedScopeHistory ?? []).length)
|
|
946
|
+
.map((c) => `C${c.number}=${c.completedScopeHistory[c.completedScopeHistory.length - 1]}`)
|
|
947
|
+
.join(", ");
|
|
948
|
+
console.log(` (${details})\n`);
|
|
949
|
+
|
|
950
|
+
const cycles = (currentData?.data?.team?.cycles?.nodes ?? []).sort((a, b) =>
|
|
951
|
+
a.startsAt.localeCompare(b.startsAt),
|
|
952
|
+
);
|
|
953
|
+
for (const c of cycles) {
|
|
954
|
+
const scopeEst = c.currentProgress?.scopeEstimate ?? 0;
|
|
955
|
+
const capPct = velocity > 0 ? (scopeEst / velocity) * 100 : 0;
|
|
956
|
+
const label = c.isActive ? " [ACTIVE]" : "";
|
|
957
|
+
console.log(`Cycle ${c.number}${label} (${c.startsAt.slice(0, 10)} to ${c.endsAt.slice(0, 10)})`);
|
|
958
|
+
console.log(
|
|
959
|
+
` Estimate pts: ${scopeEst} | Capacity: ${capPct.toFixed(0)}% | Velocity: ${velocity.toFixed(0)}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function cmdApi(args, v, cfg) {
|
|
965
|
+
const query = args[0];
|
|
966
|
+
if (!query) fail('api requires a GraphQL query string (and optional JSON variables)');
|
|
967
|
+
const variables = args[1] ? JSON.parse(args[1]) : {};
|
|
968
|
+
emit(await gql(query, variables));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// --- init (interactive workspace bootstrap) --------------------------------
|
|
972
|
+
|
|
973
|
+
async function cmdInit() {
|
|
974
|
+
if (!API_KEY) fail("LINEAR_API_KEY is not set. Set it first, then re-run `init`.");
|
|
975
|
+
|
|
976
|
+
console.error("Fetching workspace data from Linear...");
|
|
977
|
+
const bootstrap = await gql(
|
|
978
|
+
`{ teams(first: 50) { nodes { id key name parent { id } } }
|
|
979
|
+
users(first: 250) { nodes { id name email displayName } } }`,
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
const teams = bootstrap?.data?.teams?.nodes ?? [];
|
|
983
|
+
if (!teams.length) {
|
|
984
|
+
console.error("Error: no teams returned. Check LINEAR_API_KEY.");
|
|
985
|
+
console.error("Raw response:");
|
|
986
|
+
console.error(JSON.stringify(bootstrap, null, 2));
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Prefer a parent team (no parent of its own) so states/labels come from the
|
|
991
|
+
// umbrella team rather than a sub-team.
|
|
992
|
+
const parents = teams.filter((t) => !t.parent);
|
|
993
|
+
const chosenTeam = parents[0] ?? teams[0];
|
|
994
|
+
|
|
995
|
+
const teamExtra = await gql(
|
|
996
|
+
`query TeamExtra($id: String!) {
|
|
997
|
+
team(id: $id) { states { nodes { id name type } } labels { nodes { id name } } }
|
|
998
|
+
}`,
|
|
999
|
+
{ id: chosenTeam.id },
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
// Filter out Linear's integration/bot users.
|
|
1003
|
+
const users = (bootstrap?.data?.users?.nodes ?? []).filter(
|
|
1004
|
+
(u) => !(u.email ?? "").endsWith("@linear.linear.app"),
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
console.error("\nMembers in your Linear workspace:");
|
|
1008
|
+
users.forEach((u, i) => {
|
|
1009
|
+
const name = u.name || u.displayName || "(unnamed)";
|
|
1010
|
+
console.error(` ${i + 1}. ${name} <${u.email || "-"}>`);
|
|
1011
|
+
});
|
|
1012
|
+
console.error("");
|
|
1013
|
+
|
|
1014
|
+
const rl = createInterface({ input, output });
|
|
1015
|
+
const frontendIdx = await rl.question("Which member is the Frontend/PM lead? Enter number: ");
|
|
1016
|
+
const backendIdx = await rl.question("Which member is the Backend lead? Enter number: ");
|
|
1017
|
+
rl.close();
|
|
1018
|
+
|
|
1019
|
+
/** @param {string} idxStr */
|
|
1020
|
+
const pick = (idxStr) => {
|
|
1021
|
+
const i = Number.parseInt(idxStr, 10);
|
|
1022
|
+
if (i >= 1 && i <= users.length) {
|
|
1023
|
+
const u = users[i - 1];
|
|
1024
|
+
return { id: u.id, name: u.name || u.displayName || "", email: u.email || "" };
|
|
1025
|
+
}
|
|
1026
|
+
fail(`Invalid selection: ${JSON.stringify(idxStr)}`);
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const frontend = pick(frontendIdx);
|
|
1030
|
+
const backend = pick(backendIdx);
|
|
1031
|
+
|
|
1032
|
+
const WANTED_STATES = ["Backlog", "Todo", "In Progress", "In Review", "Done", "Canceled"];
|
|
1033
|
+
const WANTED_LABELS = ["discovery", "tech-debt", "backend", "frontend", "db", "bug", "feature", "improvement"];
|
|
1034
|
+
|
|
1035
|
+
const stateNodes = teamExtra?.data?.team?.states?.nodes ?? [];
|
|
1036
|
+
const labelNodes = teamExtra?.data?.team?.labels?.nodes ?? [];
|
|
1037
|
+
|
|
1038
|
+
/** @type {Record<string, string>} */
|
|
1039
|
+
const states = {};
|
|
1040
|
+
const stateLookup = new Map(stateNodes.map((s) => [s.name.toLowerCase(), s]));
|
|
1041
|
+
for (const name of WANTED_STATES) {
|
|
1042
|
+
const s = stateLookup.get(name.toLowerCase());
|
|
1043
|
+
if (s) states[name] = s.id;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const norm = (s) => s.toLowerCase().replace(/-/g, " ").trim();
|
|
1047
|
+
/** @type {Record<string, string>} */
|
|
1048
|
+
const labels = {};
|
|
1049
|
+
const labelLookup = new Map(labelNodes.map((l) => [norm(l.name), l]));
|
|
1050
|
+
for (const name of WANTED_LABELS) {
|
|
1051
|
+
const l = labelLookup.get(norm(name));
|
|
1052
|
+
if (l) labels[name] = l.id;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Parent teams first so teams[0] is the umbrella team.
|
|
1056
|
+
const teamsSorted = [...teams].sort((a, b) => (a.parent ? 1 : 0) - (b.parent ? 1 : 0));
|
|
1057
|
+
|
|
1058
|
+
const cfg = {
|
|
1059
|
+
teams: teamsSorted.map((t) => ({ id: t.id, key: t.key, name: t.name })),
|
|
1060
|
+
roles: { frontend_lead: frontend, backend_lead: backend },
|
|
1061
|
+
states,
|
|
1062
|
+
labels,
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
mkdirSync(dirname(WORKSPACE_FILE), { recursive: true });
|
|
1066
|
+
writeFileSync(WORKSPACE_FILE, JSON.stringify(cfg, null, 2));
|
|
1067
|
+
console.error(`\nWrote ${WORKSPACE_FILE}.`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// --- Dispatch --------------------------------------------------------------
|
|
1071
|
+
|
|
1072
|
+
/** Commands that don't need a workspace config loaded. */
|
|
1073
|
+
const NO_CONFIG = new Set(["init", "api", "help"]);
|
|
1074
|
+
|
|
1075
|
+
/** @type {Record<string, (args: string[], v: Record<string, any>, cfg: Config) => Promise<void>>} */
|
|
1076
|
+
const COMMANDS = {
|
|
1077
|
+
"create-issue": cmdCreateIssue,
|
|
1078
|
+
"list-issues": cmdListIssues,
|
|
1079
|
+
"list-cycle-issues": cmdListCycleIssues,
|
|
1080
|
+
"update-issue": cmdUpdateIssue,
|
|
1081
|
+
"move-issue": cmdMoveIssue,
|
|
1082
|
+
"assign-issue": cmdAssignIssue,
|
|
1083
|
+
"search-issues": cmdSearchIssues,
|
|
1084
|
+
"create-project": cmdCreateProject,
|
|
1085
|
+
"list-projects": cmdListProjects,
|
|
1086
|
+
"list-milestones": cmdListMilestones,
|
|
1087
|
+
"list-project-issues": cmdListProjectIssues,
|
|
1088
|
+
"create-milestone": cmdCreateMilestone,
|
|
1089
|
+
"update-milestone": cmdUpdateMilestone,
|
|
1090
|
+
"delete-milestone": cmdDeleteMilestone,
|
|
1091
|
+
"set-issue-milestone": cmdSetIssueMilestone,
|
|
1092
|
+
"batch-move-to-milestone": cmdBatchMoveToMilestone,
|
|
1093
|
+
"add-dependency": cmdAddDependency,
|
|
1094
|
+
"list-dependencies": cmdListDependencies,
|
|
1095
|
+
"remove-dependency": cmdRemoveDependency,
|
|
1096
|
+
"add-comment": cmdAddComment,
|
|
1097
|
+
"create-initiative": cmdCreateInitiative,
|
|
1098
|
+
"list-initiatives": cmdListInitiatives,
|
|
1099
|
+
"get-initiative": cmdGetInitiative,
|
|
1100
|
+
"get-initiative-by-name": cmdGetInitiativeByName,
|
|
1101
|
+
"update-initiative": cmdUpdateInitiative,
|
|
1102
|
+
"add-initiative-link": cmdAddInitiativeLink,
|
|
1103
|
+
"list-states": cmdListStates,
|
|
1104
|
+
"list-members": cmdListMembers,
|
|
1105
|
+
"list-labels": cmdListLabels,
|
|
1106
|
+
"list-cycles": cmdListCycles,
|
|
1107
|
+
"list-cycle-issues-by-id": cmdListCycleIssuesById,
|
|
1108
|
+
"list-cycle-issues-all": cmdListCycleIssuesAll,
|
|
1109
|
+
"rebalance": cmdRebalancePretty,
|
|
1110
|
+
"current-cycle-id": cmdCurrentCycleId,
|
|
1111
|
+
"move-issue-to-cycle": cmdMoveIssueToCycle,
|
|
1112
|
+
"batch-move-to-cycle": cmdBatchMoveToCycle,
|
|
1113
|
+
"cycle-capacity": cmdCycleCapacity,
|
|
1114
|
+
"api": cmdApi,
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
const USAGE = `Linear CLI — node linear.mjs <command> [args] [--flags]
|
|
1118
|
+
|
|
1119
|
+
Setup:
|
|
1120
|
+
init Interactive: fetch workspace, write ~/.config/linctl/workspace.json
|
|
1121
|
+
|
|
1122
|
+
Issues:
|
|
1123
|
+
create-issue --title T [--description D|--description-file F] [--priority P]
|
|
1124
|
+
[--state S] [--assignee frontend|backend] [--labels a,b]
|
|
1125
|
+
[--estimate N] [--project ID] [--milestone ID] [--cycle ID|current] [--raw JSON]
|
|
1126
|
+
list-issues [backlog|unstarted|started|completed|canceled] [--limit N] [--json]
|
|
1127
|
+
list-cycle-issues
|
|
1128
|
+
update-issue <id> [--state S] [--priority P] [--assignee R] [--labels a,b]
|
|
1129
|
+
[--project ID] [--milestone ID] [--cycle ID] [--title T] [--raw JSON]
|
|
1130
|
+
move-issue <id> "<status>" e.g. move-issue <id> "In Progress"
|
|
1131
|
+
assign-issue <id> <frontend|backend>
|
|
1132
|
+
search-issues "<term>"
|
|
1133
|
+
|
|
1134
|
+
Projects & milestones:
|
|
1135
|
+
create-project --name N [--initiative ID] [--description D]
|
|
1136
|
+
list-projects [--json]
|
|
1137
|
+
list-milestones <project-id> [--json]
|
|
1138
|
+
list-project-issues <project-id> [--limit N] [--json]
|
|
1139
|
+
create-milestone <project-id> "<name>" [--target-date YYYY-MM-DD]
|
|
1140
|
+
update-milestone <id> [--name N] [--target-date D] [--sort-order N] [--raw JSON]
|
|
1141
|
+
delete-milestone <id>
|
|
1142
|
+
set-issue-milestone <issue-id> <milestone-id|none>
|
|
1143
|
+
batch-move-to-milestone <milestone-id> <issue-id>...
|
|
1144
|
+
|
|
1145
|
+
Dependencies & comments:
|
|
1146
|
+
add-dependency <blocker-id> <blocked-id>
|
|
1147
|
+
list-dependencies <issue-id>
|
|
1148
|
+
remove-dependency <relation-id>
|
|
1149
|
+
add-comment <issue-id> --body "<text>" | --body-file F
|
|
1150
|
+
|
|
1151
|
+
Initiatives:
|
|
1152
|
+
create-initiative --name N [--description D]
|
|
1153
|
+
list-initiatives [--json]
|
|
1154
|
+
get-initiative <id>
|
|
1155
|
+
get-initiative-by-name "<name>"
|
|
1156
|
+
update-initiative <id> [--content C|--content-file F] [--description D] [--name N] [--raw JSON]
|
|
1157
|
+
add-initiative-link <id> <url> "<label>"
|
|
1158
|
+
|
|
1159
|
+
Cycles:
|
|
1160
|
+
list-cycles
|
|
1161
|
+
current-cycle-id
|
|
1162
|
+
cycle-capacity
|
|
1163
|
+
rebalance [--json] Active/upcoming cycles, grouped by project
|
|
1164
|
+
list-cycle-issues-by-id <cycle-id>
|
|
1165
|
+
list-cycle-issues-all
|
|
1166
|
+
move-issue-to-cycle <issue-id> <cycle-id|current>
|
|
1167
|
+
batch-move-to-cycle <cycle-id|current> <issue-id>... (0.5s delay between calls)
|
|
1168
|
+
|
|
1169
|
+
Info:
|
|
1170
|
+
list-states | list-members | list-labels
|
|
1171
|
+
|
|
1172
|
+
Escape hatch:
|
|
1173
|
+
api '<graphql>' ['<json-variables>'] Raw GraphQL request
|
|
1174
|
+
|
|
1175
|
+
Symbolic names: --state accepts todo|backlog|"in progress"|"in review"|done|canceled or a UUID;
|
|
1176
|
+
--assignee accepts frontend|backend or a UUID; --labels accepts label names or UUIDs;
|
|
1177
|
+
--priority accepts 0-4 or none|urgent|high|medium|low; --cycle accepts "current" or a UUID.`;
|
|
1178
|
+
|
|
1179
|
+
async function main() {
|
|
1180
|
+
const { values, positionals } = parseArgs({
|
|
1181
|
+
args: process.argv.slice(2),
|
|
1182
|
+
allowPositionals: true,
|
|
1183
|
+
options: {
|
|
1184
|
+
title: { type: "string" },
|
|
1185
|
+
description: { type: "string" },
|
|
1186
|
+
"description-file": { type: "string" },
|
|
1187
|
+
priority: { type: "string" },
|
|
1188
|
+
state: { type: "string" },
|
|
1189
|
+
assignee: { type: "string" },
|
|
1190
|
+
labels: { type: "string" },
|
|
1191
|
+
estimate: { type: "string" },
|
|
1192
|
+
project: { type: "string" },
|
|
1193
|
+
milestone: { type: "string" },
|
|
1194
|
+
cycle: { type: "string" },
|
|
1195
|
+
initiative: { type: "string" },
|
|
1196
|
+
"target-date": { type: "string" },
|
|
1197
|
+
name: { type: "string" },
|
|
1198
|
+
content: { type: "string" },
|
|
1199
|
+
"content-file": { type: "string" },
|
|
1200
|
+
body: { type: "string" },
|
|
1201
|
+
"body-file": { type: "string" },
|
|
1202
|
+
url: { type: "string" },
|
|
1203
|
+
label: { type: "string" },
|
|
1204
|
+
"sort-order": { type: "string" },
|
|
1205
|
+
limit: { type: "string" },
|
|
1206
|
+
raw: { type: "string" },
|
|
1207
|
+
json: { type: "boolean" },
|
|
1208
|
+
help: { type: "boolean", short: "h" },
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
const [command, ...args] = positionals;
|
|
1213
|
+
|
|
1214
|
+
if (!command || command === "help" || values.help) {
|
|
1215
|
+
console.log(USAGE);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (command === "init") return cmdInit();
|
|
1220
|
+
|
|
1221
|
+
const handler = COMMANDS[command];
|
|
1222
|
+
if (!handler) fail(`unknown command "${command}". Run \`node linear.mjs help\`.`);
|
|
1223
|
+
|
|
1224
|
+
const cfg = NO_CONFIG.has(command) ? /** @type {Config} */ ({}) : requireConfig();
|
|
1225
|
+
await handler(args, values, cfg);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
main().catch((err) => fail(err?.stack ?? String(err)));
|