@errhythm/gitmux 1.6.2

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.
@@ -0,0 +1,490 @@
1
+ import chalk from "chalk";
2
+ import boxen from "boxen";
3
+ import { input, confirm, select, search } from "@inquirer/prompts";
4
+
5
+ import { glabApi } from "../gitlab/api.js";
6
+ import { detectGroupFromRepos } from "../gitlab/helpers.js";
7
+ import { loadConfig, saveConfig } from "../config/index.js";
8
+ import { expandBranchTemplate } from "../git/templates.js";
9
+ import { p, THEME } from "../ui/theme.js";
10
+
11
+ // ── Shared UI helpers ──────────────────────────────────────────────────────────
12
+
13
+ function printSectionHeader(title, subtitle) {
14
+ console.log(
15
+ boxen(
16
+ chalk.bold(p.white(title)) + (subtitle ? "\n" + p.muted(subtitle) : ""),
17
+ {
18
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
19
+ borderStyle: "round",
20
+ borderColor: "#334155",
21
+ },
22
+ ),
23
+ );
24
+ console.log();
25
+ }
26
+
27
+ function printSaved() {
28
+ console.log();
29
+ console.log(
30
+ boxen(chalk.bold(p.green("✔ Settings saved")), {
31
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
32
+ borderStyle: "round",
33
+ borderColor: "#4ade80",
34
+ }),
35
+ );
36
+ console.log();
37
+ }
38
+
39
+ // ── Portal settings ────────────────────────────────────────────────────────────
40
+
41
+ const ITER_CURRENT = "__current__";
42
+
43
+ function iterLabel(it) {
44
+ return (it.title && it.title.trim())
45
+ ? it.title.trim()
46
+ : `Sprint #${it.iid} ${it.start_date ?? ""}${it.due_date ? " → " + it.due_date : ""}`.trim();
47
+ }
48
+
49
+ export async function cmdPortalSettings(config, autoGroup) {
50
+ printSectionHeader("Portal Settings", "GitLab group, epic filter & issue creation defaults");
51
+
52
+ const W = 22; // label column width
53
+ const val = (v, color = p.cyan) => v ? color(v) : p.dim("none");
54
+
55
+ // Each field edits & saves independently — loop until Done
56
+ while (true) {
57
+ const pc = loadConfig().portal ?? {};
58
+
59
+ const iterVal = pc.defaultIteration?.id
60
+ ? (pc.defaultIteration.id === ITER_CURRENT ? p.teal("★ current sprint") : p.cyan(pc.defaultIteration.title ?? "set"))
61
+ : p.dim("none");
62
+
63
+ const field = await select({
64
+ message: p.white("Edit:"),
65
+ choices: [
66
+ {
67
+ value: "group",
68
+ name: p.muted("Group path".padEnd(W)) + val(pc.group),
69
+ },
70
+ {
71
+ value: "epicLabel",
72
+ name: p.muted("Epic filter label".padEnd(W)) + val(pc.epicLabelFilter, p.purple),
73
+ },
74
+ {
75
+ value: "milestone",
76
+ name: p.muted("Default milestone".padEnd(W)) + val(pc.defaultMilestone?.title, p.teal),
77
+ },
78
+ {
79
+ value: "iteration",
80
+ name: p.muted("Default iteration".padEnd(W)) + iterVal,
81
+ },
82
+ {
83
+ value: "labels",
84
+ name: p.muted("Issue labels".padEnd(W)) + val(pc.defaultLabels, p.purple),
85
+ },
86
+ {
87
+ value: "baseBranch",
88
+ name: p.muted("Base branch".padEnd(W)) + val(pc.defaultBaseBranch ?? "develop"),
89
+ },
90
+ { value: "__done__", name: p.yellow("← Done") },
91
+ ],
92
+ theme: THEME,
93
+ });
94
+ console.log();
95
+ if (field === "__done__") break;
96
+
97
+ // ── Group path ──────────────────────────────────────────────────────────────
98
+ if (field === "group") {
99
+ const val = await input({
100
+ message: p.white("GitLab group path:"),
101
+ default: pc.group ?? autoGroup ?? "",
102
+ theme: THEME,
103
+ validate: (v) => v.trim() !== "" || "Group path is required",
104
+ });
105
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, group: val.trim() } });
106
+ console.log();
107
+ }
108
+
109
+ // ── Epic filter label ───────────────────────────────────────────────────────
110
+ if (field === "epicLabel") {
111
+ const group = loadConfig().portal?.group;
112
+ if (!group) { console.log(" " + p.yellow("Set group path first.\n")); continue; }
113
+
114
+ process.stdout.write(" " + p.muted("Loading labels…\r"));
115
+ let labelNames = [];
116
+ try {
117
+ const ls = await glabApi(`groups/${encodeURIComponent(group)}/labels?per_page=100`);
118
+ labelNames = Array.isArray(ls) ? ls.map((l) => l.name).sort() : [];
119
+ process.stdout.write(" ".repeat(40) + "\r");
120
+ } catch { process.stdout.write(" ".repeat(40) + "\r"); }
121
+
122
+ let val;
123
+ if (labelNames.length > 0) {
124
+ val = await search({
125
+ message: p.white("Epic filter label:"),
126
+ source: (v) => {
127
+ const term = (v ?? "").toLowerCase().trim();
128
+ return [
129
+ { value: null, name: p.muted("— none —"), description: p.muted("assignee filter only") },
130
+ ...labelNames
131
+ .filter((l) => !term || l.toLowerCase().includes(term))
132
+ .map((l) => ({
133
+ value: l,
134
+ name: p.purple(l),
135
+ description: l === pc.epicLabelFilter ? p.teal("current") : "",
136
+ })),
137
+ ];
138
+ },
139
+ theme: THEME,
140
+ });
141
+ } else {
142
+ const raw = await input({
143
+ message: p.white("Epic filter label") + p.muted(" (e.g. TECH::RHYTHM):"),
144
+ default: pc.epicLabelFilter ?? "",
145
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
146
+ });
147
+ val = raw.trim() || null;
148
+ }
149
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, epicLabelFilter: val } });
150
+ console.log();
151
+ }
152
+
153
+ // ── Milestone ───────────────────────────────────────────────────────────────
154
+ if (field === "milestone") {
155
+ const group = loadConfig().portal?.group;
156
+ if (!group) { console.log(" " + p.yellow("Set group path first.\n")); continue; }
157
+
158
+ process.stdout.write(" " + p.muted("Loading milestones…\r"));
159
+ let milestones = [];
160
+ try {
161
+ const ms = await glabApi(`groups/${encodeURIComponent(group)}/milestones?state=active&per_page=50`);
162
+ milestones = Array.isArray(ms) ? ms : [];
163
+ process.stdout.write(" ".repeat(40) + "\r");
164
+ } catch { process.stdout.write(" ".repeat(40) + "\r"); }
165
+
166
+ if (milestones.length === 0) {
167
+ console.log(" " + p.muted("No active milestones found.\n"));
168
+ continue;
169
+ }
170
+ const val = await search({
171
+ message: p.white("Default milestone:"),
172
+ source: (v) => {
173
+ const term = (v ?? "").toLowerCase().trim();
174
+ return [
175
+ { value: null, name: p.muted("— none —"), description: p.muted("no default") },
176
+ ...milestones
177
+ .filter((m) => !term || m.title.toLowerCase().includes(term))
178
+ .map((m) => ({
179
+ value: { id: m.id, title: m.title },
180
+ name: p.white(m.title),
181
+ description: m.due_date ? p.muted("due " + m.due_date) : "",
182
+ })),
183
+ ];
184
+ },
185
+ theme: THEME,
186
+ });
187
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, defaultMilestone: val } });
188
+ console.log();
189
+ }
190
+
191
+ // ── Iteration ───────────────────────────────────────────────────────────────
192
+ if (field === "iteration") {
193
+ const group = loadConfig().portal?.group;
194
+ if (!group) { console.log(" " + p.yellow("Set group path first.\n")); continue; }
195
+
196
+ process.stdout.write(" " + p.muted("Loading iterations…\r"));
197
+ let iterations = [];
198
+ try {
199
+ const it = await glabApi(`groups/${encodeURIComponent(group)}/iterations?state=current&per_page=50`);
200
+ iterations = Array.isArray(it) ? it : [];
201
+ process.stdout.write(" ".repeat(40) + "\r");
202
+ } catch { process.stdout.write(" ".repeat(40) + "\r"); }
203
+
204
+ const val = await search({
205
+ message: p.white("Default iteration:"),
206
+ source: (v) => {
207
+ const term = (v ?? "").toLowerCase().trim();
208
+ return [
209
+ { value: null, name: p.muted("— none —"), description: p.muted("no default") },
210
+ {
211
+ value: { id: ITER_CURRENT, title: "Current iteration" },
212
+ name: p.teal("★ Current iteration"),
213
+ description: p.muted("always resolves to the active sprint"),
214
+ },
215
+ ...iterations
216
+ .filter((it) => !term || iterLabel(it).toLowerCase().includes(term))
217
+ .map((it) => {
218
+ const label = iterLabel(it);
219
+ return {
220
+ value: { id: it.id, title: label },
221
+ name: p.white(label),
222
+ description: it.start_date && it.due_date ? p.muted(`${it.start_date} → ${it.due_date}`) : "",
223
+ };
224
+ }),
225
+ ];
226
+ },
227
+ theme: THEME,
228
+ });
229
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, defaultIteration: val } });
230
+ console.log();
231
+ }
232
+
233
+ // ── Default issue labels ────────────────────────────────────────────────────
234
+ if (field === "labels") {
235
+ const val = await input({
236
+ message: p.white("Default issue labels") + p.muted(" (comma separated):"),
237
+ default: pc.defaultLabels ?? "",
238
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
239
+ });
240
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, defaultLabels: val.trim() } });
241
+ console.log();
242
+ }
243
+
244
+ // ── Default base branch ─────────────────────────────────────────────────────
245
+ if (field === "baseBranch") {
246
+ const val = await input({
247
+ message: p.white("Default base branch:"),
248
+ default: pc.defaultBaseBranch ?? "develop",
249
+ theme: THEME,
250
+ validate: (v) => v.trim() !== "" || "Cannot be empty",
251
+ });
252
+ saveConfig({ ...loadConfig(), portal: { ...loadConfig().portal, defaultBaseBranch: val.trim() } });
253
+ console.log();
254
+ }
255
+ }
256
+
257
+ return 0;
258
+ }
259
+
260
+ // ── Switch settings ────────────────────────────────────────────────────────────
261
+
262
+ async function cmdSwitchSettings(config) {
263
+ const switchConfig = config.switch ?? {};
264
+ const templates = [...(switchConfig.branchSuggestions ?? [])];
265
+
266
+ printSectionHeader("Switch Settings", "Branch suggestion templates");
267
+
268
+ const showTemplates = () => {
269
+ if (templates.length === 0) {
270
+ console.log(" " + p.muted("No templates configured.\n"));
271
+ return;
272
+ }
273
+ const now = new Date();
274
+ console.log(" " + p.slate("Templates:"));
275
+ templates.forEach((t, i) => {
276
+ console.log(
277
+ " " + p.muted(`[${i + 1}]`) + " " + p.purple(t) +
278
+ p.muted(" → ") + p.white(expandBranchTemplate(t, now)),
279
+ );
280
+ });
281
+ console.log();
282
+ };
283
+
284
+ showTemplates();
285
+
286
+ while (true) {
287
+ const action = await select({
288
+ message: p.white("Branch suggestions:"),
289
+ choices: [
290
+ {
291
+ value: "add",
292
+ name: p.cyan("+ Add template"),
293
+ description: p.muted("e.g. sprint/{yyyy}-{mm}-W{w} · tokens: {yyyy} {yy} {mm} {m} {dd} {d} {w} {ww} {q}"),
294
+ },
295
+ {
296
+ value: "remove",
297
+ name: p.red("✕ Remove template"),
298
+ description: p.muted("pick one to delete"),
299
+ disabled: templates.length === 0 ? "(none to remove)" : false,
300
+ },
301
+ { value: "done", name: p.green("✔ Save & close") },
302
+ ],
303
+ theme: THEME,
304
+ });
305
+
306
+ if (action === "done") break;
307
+
308
+ if (action === "add") {
309
+ const tpl = await input({
310
+ message: p.white("Template:"),
311
+ theme: THEME,
312
+ validate: (v) => v.trim() !== "" || "Template cannot be empty",
313
+ });
314
+ const preview = expandBranchTemplate(tpl.trim());
315
+ console.log(" " + p.muted("Preview today → ") + p.white(preview) + "\n");
316
+ templates.push(tpl.trim());
317
+ showTemplates();
318
+ }
319
+
320
+ if (action === "remove" && templates.length > 0) {
321
+ const toRemove = await select({
322
+ message: p.white("Remove:"),
323
+ choices: templates.map((t) => ({
324
+ value: t,
325
+ name: p.purple(t) + p.muted(" → ") + p.white(expandBranchTemplate(t)),
326
+ })),
327
+ theme: THEME,
328
+ });
329
+ templates.splice(templates.indexOf(toRemove), 1);
330
+ console.log(" " + p.muted("Removed ") + p.purple(toRemove) + "\n");
331
+ showTemplates();
332
+ }
333
+ }
334
+
335
+ saveConfig({ ...config, switch: { ...switchConfig, branchSuggestions: templates } });
336
+ printSaved();
337
+ return 0;
338
+ }
339
+
340
+ // ── MR settings ────────────────────────────────────────────────────────────────
341
+
342
+ async function cmdMrSettings(config) {
343
+ printSectionHeader("Merge Request Settings", "Defaults applied when creating MRs");
344
+
345
+ const W = 28;
346
+
347
+ while (true) {
348
+ const mr = loadConfig().mr ?? {};
349
+
350
+ const field = await select({
351
+ message: p.white("Edit:"),
352
+ choices: [
353
+ {
354
+ value: "labels",
355
+ name: p.muted("Default labels".padEnd(W)) + (mr.labels ? p.purple(mr.labels) : p.dim("none")),
356
+ },
357
+ {
358
+ value: "draft",
359
+ name: p.muted("Mark as draft by default".padEnd(W)) + (mr.isDraft ? p.yellow("yes") : p.muted("no")),
360
+ },
361
+ {
362
+ value: "push",
363
+ name: p.muted("Push branch before MR".padEnd(W)) + (mr.pushFirst !== false ? p.green("yes") : p.muted("no")),
364
+ },
365
+ { value: "__done__", name: p.yellow("← Done") },
366
+ ],
367
+ theme: THEME,
368
+ });
369
+ console.log();
370
+ if (field === "__done__") break;
371
+
372
+ if (field === "labels") {
373
+ const val = await input({
374
+ message: p.white("Default labels") + p.muted(" (comma separated):"),
375
+ default: mr.labels ?? "",
376
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
377
+ });
378
+ saveConfig({ ...loadConfig(), mr: { ...loadConfig().mr, labels: val.trim() } });
379
+ console.log();
380
+ }
381
+
382
+ if (field === "draft") {
383
+ const val = await confirm({
384
+ message: p.white("Mark MRs as draft by default?"),
385
+ default: mr.isDraft ?? false,
386
+ theme: THEME,
387
+ });
388
+ saveConfig({ ...loadConfig(), mr: { ...loadConfig().mr, isDraft: val } });
389
+ console.log();
390
+ }
391
+
392
+ if (field === "push") {
393
+ const val = await confirm({
394
+ message: p.white("Push branch before creating MR by default?"),
395
+ default: mr.pushFirst !== false,
396
+ theme: THEME,
397
+ });
398
+ saveConfig({ ...loadConfig(), mr: { ...loadConfig().mr, pushFirst: val } });
399
+ console.log();
400
+ }
401
+ }
402
+
403
+ return 0;
404
+ }
405
+
406
+ // ── Top-level settings command ─────────────────────────────────────────────────
407
+
408
+ export async function cmdSettings(repos) {
409
+ console.log(
410
+ boxen(
411
+ chalk.bold(p.white("gitmux Settings")) + "\n" +
412
+ p.muted("Configure portal, switch & merge request defaults"),
413
+ {
414
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
415
+ borderStyle: "round",
416
+ borderColor: "#334155",
417
+ title: p.muted(" settings "),
418
+ titleAlignment: "right",
419
+ },
420
+ ),
421
+ );
422
+ console.log();
423
+
424
+ while (true) {
425
+ const config = loadConfig();
426
+ const portalCfg = config.portal ?? {};
427
+ const switchCfg = config.switch ?? {};
428
+ const mrCfg = config.mr ?? {};
429
+
430
+ const portalSummary = [
431
+ portalCfg.group && p.muted("group ") + p.cyan(portalCfg.group),
432
+ portalCfg.epicLabelFilter && p.muted("label ") + p.purple(portalCfg.epicLabelFilter),
433
+ portalCfg.defaultMilestone?.title && p.muted("milestone ") + p.teal(portalCfg.defaultMilestone.title),
434
+ portalCfg.defaultIteration?.id && p.muted("iteration ") + (
435
+ portalCfg.defaultIteration.id === "__current__" ? p.teal("★ current") : p.cyan(portalCfg.defaultIteration.title ?? "set")
436
+ ),
437
+ portalCfg.defaultLabels && p.muted("labels ") + p.purple(portalCfg.defaultLabels),
438
+ portalCfg.defaultBaseBranch && p.muted("base ") + p.white(portalCfg.defaultBaseBranch),
439
+ ].filter(Boolean).join(p.dim(" · "));
440
+
441
+ const switchSummary = (switchCfg.branchSuggestions?.length ?? 0) > 0
442
+ ? p.muted(switchCfg.branchSuggestions.length + " template(s) ") +
443
+ p.purple(switchCfg.branchSuggestions.slice(0, 2).join(" ") + (switchCfg.branchSuggestions.length > 2 ? " …" : ""))
444
+ : p.dim("no templates");
445
+
446
+ const mrSummary = [
447
+ mrCfg.labels && p.muted("labels ") + p.purple(mrCfg.labels),
448
+ mrCfg.isDraft && p.yellow("draft"),
449
+ mrCfg.pushFirst !== false && p.green("push first"),
450
+ ].filter(Boolean).join(p.dim(" · ")) || p.dim("no defaults");
451
+
452
+ const section = await select({
453
+ message: p.white("Configure:"),
454
+ choices: [
455
+ {
456
+ value: "portal",
457
+ name: chalk.hex("#FC6D26")("◈ Portal"),
458
+ description: portalSummary || p.dim("not configured"),
459
+ },
460
+ {
461
+ value: "switch",
462
+ name: p.purple("⇌ Switch"),
463
+ description: switchSummary,
464
+ },
465
+ {
466
+ value: "mr",
467
+ name: p.purple("⎇ Merge Requests"),
468
+ description: mrSummary,
469
+ },
470
+ {
471
+ value: "__done__",
472
+ name: p.yellow("← Done"),
473
+ },
474
+ ],
475
+ theme: THEME,
476
+ });
477
+
478
+ console.log();
479
+ if (section === "__done__") break;
480
+
481
+ const freshConfig = loadConfig();
482
+ const autoGroup = detectGroupFromRepos(repos);
483
+
484
+ if (section === "portal") await cmdPortalSettings(freshConfig, autoGroup);
485
+ if (section === "switch") await cmdSwitchSettings(freshConfig);
486
+ if (section === "mr") await cmdMrSettings(freshConfig);
487
+ }
488
+
489
+ return 0;
490
+ }
@@ -0,0 +1,86 @@
1
+ import { basename } from "path";
2
+ import chalk from "chalk";
3
+ import boxen from "boxen";
4
+
5
+ import { getCurrentBranch, getRepoStatus, getAheadBehind } from "../git/core.js";
6
+ import { p } from "../ui/theme.js";
7
+ import { colorBranch } from "../ui/colors.js";
8
+
9
+ export async function cmdStatus(repos) {
10
+ console.log(p.muted(` Scanning ${repos.length} repositories…\n`));
11
+
12
+ const rows = await Promise.all(
13
+ repos.map(async (repo) => {
14
+ const name = basename(repo);
15
+ const [branch, { dirty, count }, { ahead, behind }] = await Promise.all([
16
+ getCurrentBranch(repo),
17
+ getRepoStatus(repo),
18
+ getAheadBehind(repo),
19
+ ]);
20
+ return { name, branch, dirty, count, ahead, behind };
21
+ }),
22
+ );
23
+
24
+ rows.sort((a, b) => {
25
+ if (a.dirty && !b.dirty) return -1;
26
+ if (!a.dirty && b.dirty) return 1;
27
+ return a.name.localeCompare(b.name);
28
+ });
29
+
30
+ const maxName = Math.max(...rows.map((r) => r.name.length), 4);
31
+ const maxBranch = Math.max(...rows.map((r) => r.branch.length), 6);
32
+
33
+ const divider = p.dim("─".repeat(maxName + maxBranch + 30));
34
+
35
+ console.log(
36
+ " " + p.slate("REPO".padEnd(maxName)) +
37
+ " " + p.slate("BRANCH".padEnd(maxBranch)) +
38
+ " " + p.slate("STATUS".padEnd(16)) +
39
+ " " + p.slate("SYNC"),
40
+ );
41
+ console.log(" " + divider);
42
+
43
+ for (const r of rows) {
44
+ const namePad = r.name.padEnd(maxName);
45
+ const branchPad = r.branch.padEnd(maxBranch);
46
+
47
+ const statusIcon = r.dirty ? p.yellow("✎") : p.green("✔");
48
+ const statusText = r.dirty
49
+ ? p.yellow(`${r.count} file${r.count !== 1 ? "s" : ""} changed`).padEnd(24)
50
+ : p.green("clean").padEnd(24);
51
+
52
+ const syncParts = [];
53
+ if (r.ahead > 0) syncParts.push(p.cyan(`↑${r.ahead}`));
54
+ if (r.behind > 0) syncParts.push(p.red(`↓${r.behind}`));
55
+ const syncStr = syncParts.length ? syncParts.join(" ") : p.dim("—");
56
+
57
+ console.log(
58
+ " " + chalk.bold(p.white(namePad)) +
59
+ " " + colorBranch(branchPad) +
60
+ " " + statusIcon + " " + statusText +
61
+ " " + syncStr,
62
+ );
63
+ }
64
+
65
+ console.log(" " + divider);
66
+ console.log();
67
+
68
+ const dirtyCount = rows.filter((r) => r.dirty).length;
69
+ const totalFiles = rows.reduce((s, r) => s + r.count, 0);
70
+ const cleanCount = rows.length - dirtyCount;
71
+
72
+ const sep = p.slate(" · ");
73
+ const parts = [p.white(`${rows.length} repos`), p.green(`${cleanCount} clean`)];
74
+ if (dirtyCount > 0) {
75
+ parts.push(p.yellow(`${dirtyCount} dirty · ${totalFiles} file${totalFiles !== 1 ? "s" : ""}`));
76
+ }
77
+
78
+ console.log(
79
+ boxen(parts.join(sep), {
80
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
81
+ borderStyle: "round",
82
+ borderColor: dirtyCount > 0 ? "#fbbf24" : "#4ade80",
83
+ }),
84
+ );
85
+ console.log();
86
+ }