@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,484 @@
1
+ import { basename } from "path";
2
+ import { execSync } from "child_process";
3
+ import chalk from "chalk";
4
+ import boxen from "boxen";
5
+ import enquirer from "enquirer";
6
+ import { input, confirm, select, search } from "@inquirer/prompts";
7
+ import { Listr } from "listr2";
8
+
9
+ import { getCurrentBranch } from "../git/core.js";
10
+ import { getBranchChoices } from "../git/branches.js";
11
+ import { getRemoteUrl, isGitLabRemote, getDefaultBranch } from "../gitlab/helpers.js";
12
+ import { execFileAsync, extractMsg } from "../utils/exec.js";
13
+ import { loadConfig, saveConfig } from "../config/index.js";
14
+ import { p, THEME } from "../ui/theme.js";
15
+ import { colorBranch } from "../ui/colors.js";
16
+
17
+ export async function cmdMr(repos, opts = {}) {
18
+ const config = loadConfig();
19
+ const mrConfig = config.mr ?? {};
20
+
21
+ // Destructure CLI opts (all optional — absence falls through to interactive prompts)
22
+ const {
23
+ target: cliTarget,
24
+ mrRepos: cliMrRepos,
25
+ title: cliTitle,
26
+ description: cliDescription,
27
+ labels: cliLabels,
28
+ draft: cliDraft,
29
+ noPush: cliNoPush,
30
+ yes: autoConfirm,
31
+ } = opts;
32
+
33
+ try {
34
+ execSync("glab version", { encoding: "utf8", stdio: "pipe" });
35
+ } catch {
36
+ console.log(
37
+ boxen(
38
+ chalk.bold(p.red("glab not found")) + "\n\n" +
39
+ p.muted("The GitLab CLI is required for this feature.\n") +
40
+ p.muted("Install: ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation"),
41
+ {
42
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
43
+ borderStyle: "round",
44
+ borderColor: "#f87171",
45
+ title: p.red(" missing dependency "),
46
+ titleAlignment: "center",
47
+ },
48
+ ),
49
+ );
50
+ return 1;
51
+ }
52
+
53
+ const repoInfo = await Promise.all(repos.map(async (repo) => {
54
+ const name = basename(repo);
55
+ const branch = await getCurrentBranch(repo);
56
+ const remote = getRemoteUrl(repo);
57
+ const isGitlab = isGitLabRemote(remote);
58
+ const defBranch = getDefaultBranch(repo);
59
+ const remoteShort = remote.replace(/^https?:\/\//, "").replace(/\.git$/, "").slice(0, 55);
60
+ return { repo, name, branch, remote, remoteShort, isGitlab, defBranch };
61
+ }));
62
+
63
+ const repoChoice = (repo) => ({
64
+ value: repo,
65
+ name: chalk.bold(p.white(repo.name)),
66
+ description:
67
+ colorBranch(repo.branch) + " " +
68
+ (repo.isGitlab ? p.muted(repo.remoteShort) : p.dim(repo.remoteShort || "no remote")),
69
+ });
70
+
71
+ const printSelectedRepos = (selected) => {
72
+ if (selected.length === 1) {
73
+ console.log(
74
+ " " + chalk.bold(p.white(selected[0].name)) +
75
+ " " + colorBranch(selected[0].branch) + "\n",
76
+ );
77
+ return;
78
+ }
79
+ const maxN = Math.max(...selected.map((r) => r.name.length));
80
+ console.log(" " + p.muted(`${selected.length} repos selected:`));
81
+ console.log(" " + p.dim("─".repeat(maxN + 20)));
82
+ for (const r of selected) {
83
+ console.log(
84
+ " " + p.muted("◉") + " " +
85
+ chalk.bold(p.white(r.name.padEnd(maxN))) +
86
+ " " + colorBranch(r.branch),
87
+ );
88
+ }
89
+ console.log();
90
+ };
91
+
92
+ const promptRepoSearch = (message, excludedNames = new Set()) =>
93
+ search({
94
+ message,
95
+ source: (input) => {
96
+ const term = (input ?? "").toLowerCase().trim();
97
+ return [
98
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to previous step") },
99
+ ...repoInfo
100
+ .filter((r) => !excludedNames.has(r.name))
101
+ .filter((r) => !term || r.name.toLowerCase().includes(term))
102
+ .map(repoChoice),
103
+ ];
104
+ },
105
+ theme: THEME,
106
+ });
107
+
108
+ const promptMultiRepoSearch = async () => {
109
+ const choices = repoInfo.map((repo) => ({
110
+ name: repo.name,
111
+ value: repo.name,
112
+ repo,
113
+ message: chalk.bold(p.white(repo.name)),
114
+ hint:
115
+ colorBranch(repo.branch) + " " +
116
+ (repo.isGitlab ? p.muted(repo.remoteShort) : p.dim(repo.remoteShort || "no remote")),
117
+ }));
118
+
119
+ const selectedNames = await enquirer.autocomplete({
120
+ name: "repos",
121
+ message: "Select repositories:",
122
+ multiple: true,
123
+ initial: 0,
124
+ limit: 12,
125
+ choices,
126
+ symbols: { indicator: { on: "◉", off: "◯" } },
127
+ footer() {
128
+ return p.muted("space to toggle, type to filter, enter to confirm, esc to go back");
129
+ },
130
+ suggest(input = "", allChoices = []) {
131
+ const term = input.toLowerCase().trim();
132
+ const selectedChoices = allChoices.filter((c) => c.enabled);
133
+ const unselectedChoices = allChoices.filter((c) => !c.enabled);
134
+ const filteredUnselected = term
135
+ ? unselectedChoices.filter((c) =>
136
+ c.repo.name.toLowerCase().includes(term) ||
137
+ c.repo.branch.toLowerCase().includes(term) ||
138
+ c.repo.remoteShort.toLowerCase().includes(term),
139
+ )
140
+ : unselectedChoices;
141
+ return [...selectedChoices, ...filteredUnselected];
142
+ },
143
+ }).catch(() => "__back__");
144
+
145
+ if (selectedNames === "__back__") return "__back__";
146
+
147
+ return selectedNames
148
+ .map((name) => repoInfo.find((r) => r.name === name))
149
+ .filter(Boolean);
150
+ };
151
+
152
+ const promptTargetBranch = async (selectedRepos) => {
153
+ const branchChoices = getBranchChoices(selectedRepos);
154
+ const defaultTarget = mrConfig.targetBranch || selectedRepos[0].defBranch || "main";
155
+
156
+ if (branchChoices.length > 0) {
157
+ return search({
158
+ message: p.white("Target branch:"),
159
+ source: (input) => {
160
+ const term = (input ?? "").toLowerCase().trim();
161
+ return [
162
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to repository selection") },
163
+ ...branchChoices
164
+ .filter((b) => !term || b.name.toLowerCase().includes(term))
165
+ .map((b) => {
166
+ const repoLabel = selectedRepos.length === 1
167
+ ? p.muted("latest local/origin ref")
168
+ : p.muted(`seen in ${b.repos.length}/${selectedRepos.length} repos`);
169
+ return {
170
+ value: b.name,
171
+ name: colorBranch(b.name),
172
+ description: b.name === defaultTarget
173
+ ? p.teal("default target") + " " + repoLabel
174
+ : repoLabel,
175
+ };
176
+ }),
177
+ ];
178
+ },
179
+ theme: THEME,
180
+ });
181
+ }
182
+
183
+ return input({
184
+ message: p.white("Target branch") + p.muted(" (merge into):"),
185
+ default: defaultTarget,
186
+ theme: THEME,
187
+ validate: (v) => v.trim() !== "" || "Target branch cannot be empty.",
188
+ });
189
+ };
190
+
191
+ let selected;
192
+ let targetBranch;
193
+
194
+ // ── Non-interactive path — both --target and (optionally) --repo supplied ────
195
+ if (cliTarget) {
196
+ targetBranch = cliTarget;
197
+
198
+ if (cliMrRepos) {
199
+ // Filter by requested repo names
200
+ selected = repoInfo.filter((r) => cliMrRepos.includes(r.name));
201
+ if (selected.length === 0) {
202
+ console.log(p.yellow(` No matching repos for --repo filter(s).\n`));
203
+ return 1;
204
+ }
205
+ } else {
206
+ // Use all repos in scope
207
+ selected = repoInfo;
208
+ }
209
+ } else {
210
+ // ── Interactive path ─────────────────────────────────────────────────────────
211
+ selectionFlow: while (true) {
212
+ const selMode = await select({
213
+ message: p.white("Scope:"),
214
+ choices: [
215
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to mode selection") },
216
+ { value: "single", name: p.cyan("Single repo") + p.muted(" search and pick one") },
217
+ { value: "multi", name: p.purple("Multiple repos") + p.muted(" manually build a selection") },
218
+ ],
219
+ default: mrConfig.scope === "multi" ? "multi" : "single",
220
+ theme: THEME,
221
+ });
222
+
223
+ if (selMode === "__back__") return "__back__";
224
+
225
+ console.log();
226
+
227
+ while (true) {
228
+ if (selMode === "single") {
229
+ const picked = await promptRepoSearch(p.white("Repository:"));
230
+ console.log();
231
+ if (picked === "__back__") continue selectionFlow;
232
+ selected = [picked];
233
+ } else {
234
+ selected = await promptMultiRepoSearch();
235
+ console.log();
236
+ if (selected === "__back__") continue selectionFlow;
237
+ }
238
+
239
+ if (!selected || selected.length === 0) {
240
+ console.log(p.muted(" Nothing selected. Exiting.\n"));
241
+ return 0;
242
+ }
243
+
244
+ printSelectedRepos(selected);
245
+
246
+ const target = await promptTargetBranch(selected);
247
+ console.log();
248
+ if (target === "__back__") continue;
249
+
250
+ targetBranch = target;
251
+ break selectionFlow;
252
+ }
253
+ }
254
+ } // end non-interactive / interactive split
255
+
256
+ const title = cliTitle !== null && cliTitle !== undefined
257
+ ? cliTitle
258
+ : await input({
259
+ message: p.white("Title") + p.muted(" (blank → use last commit message):"),
260
+ theme: THEME,
261
+ });
262
+
263
+ const description = cliDescription !== null && cliDescription !== undefined
264
+ ? cliDescription
265
+ : await input({
266
+ message: p.white("Description") + p.muted(" (blank → use commit body):"),
267
+ theme: THEME,
268
+ });
269
+
270
+ const labels = cliLabels !== null && cliLabels !== undefined
271
+ ? cliLabels
272
+ : await input({
273
+ message: p.white("Labels") + p.muted(" (comma separated, optional):"),
274
+ default: mrConfig.labels ?? "",
275
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
276
+ });
277
+
278
+ const isDraft = cliDraft
279
+ ? true
280
+ : await confirm({
281
+ message: p.white("Mark as") + p.muted(" Draft?"),
282
+ default: mrConfig.isDraft ?? false,
283
+ theme: THEME,
284
+ });
285
+
286
+ // --no-push takes priority; cliNoPush=true means skip push
287
+ const pushFirst = cliNoPush
288
+ ? false
289
+ : await confirm({
290
+ message: p.white("Push branch") + p.muted(" to remote before creating MR?"),
291
+ default: mrConfig.pushFirst ?? true,
292
+ theme: THEME,
293
+ });
294
+
295
+ console.log();
296
+
297
+ const repoLabel = selected.length === 1
298
+ ? chalk.bold(p.white(selected[0].name))
299
+ : p.white(`${selected.length} repos`) + " " +
300
+ p.muted(
301
+ selected.map((r) => r.name).join(", ").slice(0, 60) +
302
+ (selected.map((r) => r.name).join(", ").length > 60 ? "…" : ""),
303
+ );
304
+
305
+ const sourceLabel = selected.length === 1
306
+ ? colorBranch(selected[0].branch) + p.muted(" → ") + colorBranch(targetBranch)
307
+ : p.muted("each branch") + p.muted(" → ") + colorBranch(targetBranch);
308
+
309
+ const previewLines = [
310
+ p.muted(" repo ") + repoLabel,
311
+ p.muted(" source ") + sourceLabel,
312
+ p.muted(" title ") + (title.trim() ? chalk.bold(p.white(title)) : p.dim("← last commit message")),
313
+ p.muted(" description ") + (description.trim()
314
+ ? p.white(description.slice(0, 60) + (description.length > 60 ? "…" : ""))
315
+ : p.dim(title.trim() ? "—" : "← commit body")),
316
+ labels ? p.muted(" labels ") + p.purple(labels) : null,
317
+ isDraft ? p.muted(" flags ") + p.yellow("draft") : null,
318
+ pushFirst ? p.muted(" push first ") + p.teal("yes") : null,
319
+ ].filter(Boolean).join("\n");
320
+
321
+ console.log(
322
+ boxen(previewLines, {
323
+ padding: { top: 1, bottom: 1, left: 2, right: 2 },
324
+ borderStyle: "round",
325
+ borderColor: "#334155",
326
+ title: p.muted(" merge request preview "),
327
+ titleAlignment: "right",
328
+ }),
329
+ );
330
+ console.log();
331
+
332
+ const confirmed = autoConfirm
333
+ ? true
334
+ : await confirm({
335
+ message: p.white(
336
+ selected.length === 1 ? "Create merge request?" : `Create ${selected.length} merge requests?`,
337
+ ),
338
+ default: true,
339
+ theme: THEME,
340
+ });
341
+
342
+ if (!confirmed) {
343
+ console.log("\n" + p.muted(" Aborted.\n"));
344
+ return 0;
345
+ }
346
+
347
+ saveConfig({
348
+ ...config,
349
+ mr: {
350
+ ...mrConfig,
351
+ scope: selected.length > 1 ? "multi" : "single",
352
+ targetBranch,
353
+ labels,
354
+ isDraft,
355
+ pushFirst,
356
+ },
357
+ });
358
+
359
+ console.log();
360
+
361
+ const buildGlabArgs = () => {
362
+ const a = ["mr", "create", "--target-branch", targetBranch, "--yes"];
363
+ if (title.trim()) {
364
+ a.push("--title", title.trim());
365
+ } else {
366
+ a.push("--fill");
367
+ }
368
+ if (description.trim()) a.push("--description", description.trim());
369
+ if (isDraft) a.push("--draft");
370
+ if (labels) a.push("--label", labels);
371
+ if (pushFirst) a.push("--push");
372
+ return a;
373
+ };
374
+
375
+ const mrResults = [];
376
+ const numWidth = String(selected.length).length;
377
+
378
+ const tasks = new Listr(
379
+ selected.map((r, i) => {
380
+ const idx = p.muted(`[${String(i + 1).padStart(numWidth)}/${selected.length}]`);
381
+ return {
382
+ title:
383
+ idx + " " + chalk.bold(p.white(r.name)) + " " +
384
+ colorBranch(r.branch) + p.muted(" → ") + colorBranch(targetBranch) +
385
+ p.muted(" opening…"),
386
+ task: async (_, task) => {
387
+ const t0 = Date.now();
388
+ try {
389
+ const { stdout, stderr } = await execFileAsync("glab", buildGlabArgs(), { cwd: r.repo });
390
+ const combined = (stdout + "\n" + stderr).trim();
391
+ const url = combined.split("\n").find((l) => /https?:\/\//.test(l))?.trim() ?? "";
392
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
393
+
394
+ mrResults.push({ name: r.name, ok: true, url });
395
+ task.title =
396
+ idx + " " + chalk.bold(p.white(r.name)) +
397
+ " " + p.green("✔") + " " +
398
+ colorBranch(r.branch) + p.muted(" → ") + colorBranch(targetBranch) +
399
+ (url ? " " + p.cyan(url) : "") +
400
+ p.muted(" " + elapsed + "s");
401
+ } catch (e) {
402
+ const raw = (e.stdout ?? "") + (e.stderr ?? "");
403
+ const url = raw.split("\n").find((l) => /https?:\/\//.test(l))?.trim() ?? "";
404
+ const msg = extractMsg(e);
405
+
406
+ if (url) {
407
+ mrResults.push({ name: r.name, ok: true, url, existing: true });
408
+ task.title =
409
+ idx + " " + chalk.bold(p.white(r.name)) +
410
+ " " + p.teal("◉") +
411
+ " " + p.muted("MR already exists ") + p.cyan(url);
412
+ } else {
413
+ mrResults.push({ name: r.name, ok: false, msg });
414
+ task.title =
415
+ idx + " " + chalk.bold(p.white(r.name)) +
416
+ " " + p.red("✘") +
417
+ " " + p.muted(msg.slice(0, 70));
418
+ throw new Error(msg);
419
+ }
420
+ }
421
+ },
422
+ };
423
+ }),
424
+ { concurrent: false, exitOnError: false },
425
+ );
426
+
427
+ await tasks.run().catch(() => {});
428
+
429
+ console.log();
430
+
431
+ const opened = mrResults.filter((r) => r.ok && !r.existing);
432
+ const existing = mrResults.filter((r) => r.ok && r.existing);
433
+ const failed = mrResults.filter((r) => !r.ok);
434
+ const sep = p.slate(" · ");
435
+
436
+ const summaryParts = [];
437
+ if (opened.length) summaryParts.push(chalk.bold(p.green(`✔ ${opened.length} opened`)));
438
+ if (existing.length) summaryParts.push(p.teal(`◉ ${existing.length} already existed`));
439
+ if (failed.length) summaryParts.push(p.red(`✘ ${failed.length} failed`));
440
+
441
+ console.log(
442
+ boxen(
443
+ summaryParts.join(sep) +
444
+ "\n" + p.muted(`${selected.length} repo${selected.length !== 1 ? "s" : ""} processed`),
445
+ {
446
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
447
+ borderStyle: "round",
448
+ borderColor: failed.length > 0 ? "#f87171" : "#4ade80",
449
+ },
450
+ ),
451
+ );
452
+
453
+ const successful = mrResults.filter((r) => r.ok && r.url);
454
+ if (successful.length > 0) {
455
+ const maxN = Math.max(...successful.map((r) => r.name.length));
456
+ console.log();
457
+ console.log(" " + chalk.bold(p.white("Merge Requests")));
458
+ console.log(" " + p.dim("─".repeat(50)));
459
+ for (const r of successful) {
460
+ const badge = r.existing ? p.teal("◉") : p.green("✔");
461
+ console.log(
462
+ " " + badge + " " +
463
+ chalk.bold(p.white(r.name.padEnd(maxN))) +
464
+ " " + p.cyan(r.url),
465
+ );
466
+ }
467
+ }
468
+
469
+ if (failed.length > 0) {
470
+ console.log();
471
+ console.log(" " + chalk.bold(p.white("Errors")));
472
+ console.log(" " + p.dim("─".repeat(50)));
473
+ for (const r of failed) {
474
+ console.log(
475
+ " " + p.red("✘") + " " +
476
+ chalk.bold(p.white(r.name)) +
477
+ " " + p.muted(r.msg.slice(0, 90)),
478
+ );
479
+ }
480
+ }
481
+
482
+ console.log();
483
+ return failed.length > 0 ? 1 : 0;
484
+ }