@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,1711 @@
1
+ import { basename } from "path";
2
+ import { execSync } from "child_process";
3
+ import { execFile, spawn } from "child_process";
4
+ import { promisify } from "util";
5
+ import chalk from "chalk";
6
+ import { Listr } from "listr2";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ import boxen from "boxen";
10
+ import enquirer from "enquirer";
11
+ import { input, confirm, select, search } from "@inquirer/prompts";
12
+
13
+ import { getCurrentBranch } from "../git/core.js";
14
+ import { glabApi, glabGraphQL } from "../gitlab/api.js";
15
+ import {
16
+ getRemoteUrl,
17
+ isGitLabRemote,
18
+ detectGroupFromRepos,
19
+ getProjectPath,
20
+ slugify,
21
+ } from "../gitlab/helpers.js";
22
+ import { loadConfig, saveConfig } from "../config/index.js";
23
+ import { p, THEME } from "../ui/theme.js";
24
+ import { colorBranch, colorLabel } from "../ui/colors.js";
25
+ export { cmdPortalSettings } from "./settings.js";
26
+ import { cmdMr } from "./mr.js";
27
+
28
+ // ── GraphQL epic query ────────────────────────────────────────────────────────
29
+ // Fetches epics assigned to the given username (and optionally filtered by label)
30
+ // via the GitLab Work Items GraphQL API.
31
+ // Returns an array of { iid, title, due_date, web_url } objects.
32
+ async function fetchAssignedEpics(groupPath, username, epicLabelFilter = null) {
33
+ const labelArg = epicLabelFilter ? `, labelName: "${epicLabelFilter}"` : "";
34
+ const query = `{
35
+ group(fullPath: "${groupPath}") {
36
+ workItems(first: 100, types: [EPIC], assigneeUsernames: ["${username}"]${labelArg}, state: opened) {
37
+ nodes {
38
+ id iid title webUrl
39
+ widgets {
40
+ type
41
+ ... on WorkItemWidgetLabels {
42
+ labels { nodes { title } }
43
+ }
44
+ }
45
+ }
46
+ pageInfo { hasNextPage }
47
+ }
48
+ }
49
+ }`;
50
+
51
+ const data = await glabGraphQL(query);
52
+ const nodes = data?.group?.workItems?.nodes ?? [];
53
+
54
+ return nodes.map((e) => ({
55
+ iid: e.iid,
56
+ id: e.id, // gid://gitlab/Epic/12345 — needed for issue creation via REST
57
+ title: e.title,
58
+ web_url: e.webUrl,
59
+ labels: e.widgets?.find((w) => w.type === "LABELS")?.labels?.nodes?.map((l) => l.title) ?? [],
60
+ }));
61
+ }
62
+
63
+ // ── Group resolver ─────────────────────────────────────────────────────────────
64
+ // Probes group path candidates from top-level down (e.g. "a/b/c" → ["a","a/b","a/b/c"])
65
+ // and returns the highest-level group that has epics for the user.
66
+ // Falls back to the top-level group if no assigned epics are found at any level.
67
+ async function resolveEpicGroup(rawGroupPath, username, epicLabelFilter = null) {
68
+ const parts = rawGroupPath.split("/").filter(Boolean);
69
+ const candidates = parts.map((_, i) => parts.slice(0, i + 1).join("/"));
70
+
71
+ for (const candidate of candidates) {
72
+ try {
73
+ const epics = await fetchAssignedEpics(candidate, username, epicLabelFilter);
74
+ if (epics.length > 0) return candidate;
75
+ } catch {
76
+ // Group might not exist at this level — try next
77
+ }
78
+ }
79
+
80
+ return parts[0];
81
+ }
82
+
83
+ // ── Epic issues ────────────────────────────────────────────────────────────────
84
+
85
+ async function fetchEpicIssues(group, epicIid) {
86
+ try {
87
+ const enc = encodeURIComponent(group);
88
+ const issues = await glabApi(`groups/${enc}/epics/${epicIid}/issues?per_page=50`);
89
+ return Array.isArray(issues) ? issues : [];
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ function printEpicIssues(issues, primaryBranches = {}) {
96
+ if (issues.length === 0) {
97
+ console.log(" " + p.muted("No issues yet in this epic.\n"));
98
+ return;
99
+ }
100
+
101
+ const openCount = issues.filter((i) => i.state === "opened").length;
102
+ const closedCount = issues.length - openCount;
103
+
104
+ const rows = issues.map((issue) => {
105
+ const ref = issue.references?.full ?? "";
106
+ const refParts = ref.split("#");
107
+ const projectName = (refParts[0] ?? "").split("/").pop() ?? "";
108
+ const localIid = refParts[1] ?? String(issue.iid);
109
+ const statusDot = issue.state === "opened" ? p.green("●") : p.muted("○");
110
+ const keyLabel = issue.labels?.find((l) => l.startsWith("STA::")) ?? "";
111
+ const primary = primaryBranches[ref] ?? null;
112
+ return { statusDot, localIid, projectName, title: issue.title, keyLabel, primary };
113
+ });
114
+
115
+ const maxProject = Math.max(...rows.map((r) => r.projectName.length), 7);
116
+ const divider = " " + p.muted("─".repeat(maxProject + 75));
117
+
118
+ console.log(divider);
119
+ for (const r of rows) {
120
+ const iidPad = `#${r.localIid}`.padEnd(5);
121
+ const titleSlice = r.title.length > 55 ? r.title.slice(0, 54) + "…" : r.title.padEnd(55);
122
+ console.log(
123
+ " " + r.statusDot +
124
+ " " + p.muted(iidPad) +
125
+ " " + p.white(r.projectName.padEnd(maxProject)) +
126
+ " " + p.muted(titleSlice) +
127
+ (r.keyLabel ? " " + colorLabel(r.keyLabel) : "") +
128
+ (r.primary ? " " + p.teal("⎇") + " " + colorBranch(r.primary) : ""),
129
+ );
130
+ }
131
+ console.log(divider);
132
+ console.log(
133
+ " " +
134
+ p.muted(`${issues.length} issue${issues.length !== 1 ? "s" : ""}`) +
135
+ (openCount > 0 ? " " + p.green(`${openCount} open`) : "") +
136
+ (closedCount > 0 ? " " + p.muted(`${closedCount} closed`) : "") +
137
+ "\n",
138
+ );
139
+ }
140
+
141
+ // ── Browser helper ─────────────────────────────────────────────────────────────
142
+
143
+ function openUrl(url) {
144
+ const cmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
145
+ try { execSync(`${cmd} "${url}"`, { stdio: "ignore" }); } catch { }
146
+ }
147
+
148
+ // ── CodeRabbit CLI detection ───────────────────────────────────────────────────
149
+ // Returns true if the `cr` (or `coderabbit`) CLI is available on $PATH.
150
+ function isCrAvailable() {
151
+ try {
152
+ execSync("cr --version", { stdio: "pipe" });
153
+ return true;
154
+ } catch {
155
+ try {
156
+ execSync("coderabbit --version", { stdio: "pipe" });
157
+ return true;
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+ }
163
+
164
+ // ── Issue branches ─────────────────────────────────────────────────────────────
165
+
166
+ // Config key that uniquely identifies an issue across projects
167
+ function issueConfigKey(issue) {
168
+ const ref = issue.references?.full ?? "";
169
+ return ref || `unknown#${issue.iid}`;
170
+ }
171
+
172
+ // Fetch branches for a project that reference a given issue IID in their name.
173
+ // Uses GitLab's ?search= to pre-filter and paginates in case there are many results.
174
+ async function fetchIssueBranches(projectPath, issueIid) {
175
+ try {
176
+ const enc = encodeURIComponent(projectPath);
177
+ const pattern = new RegExp(`(^|[/._-])${issueIid}([^0-9]|$)`);
178
+ const results = [];
179
+ let page = 1;
180
+
181
+ while (true) {
182
+ const branches = await glabApi(
183
+ `projects/${enc}/repository/branches?search=${issueIid}&per_page=100&page=${page}`,
184
+ );
185
+ if (!Array.isArray(branches) || branches.length === 0) break;
186
+ results.push(...branches.filter((b) => pattern.test(b.name)));
187
+ if (branches.length < 100) break;
188
+ page++;
189
+ }
190
+
191
+ return results;
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ // Interactive issue detail view — shows branches and lets the user set a primary branch
198
+ async function cmdIssueView(issue, glabRepos = [], portalConfig = {}) {
199
+ const ref = issue.references?.full ?? "";
200
+ const [projectPath, localIid] = ref.split("#");
201
+ const projectName = (projectPath ?? "").split("/").pop() ?? "";
202
+ const configKey = issueConfigKey(issue);
203
+
204
+ // Issue info
205
+ const labelLine = issue.labels?.length > 0
206
+ ? "\n" + p.muted(" labels ") + issue.labels.map((l) => colorLabel(l)).join(p.muted(" "))
207
+ : "";
208
+ console.log(
209
+ boxen(
210
+ p.muted(" project ") + p.white(projectName) + "\n" +
211
+ p.muted(" title ") + chalk.bold(p.white(issue.title)) + "\n" +
212
+ p.muted(" state ") + (issue.state === "opened" ? p.green("open") : p.muted("closed")) +
213
+ labelLine + "\n" +
214
+ p.muted(" url ") + p.muted(issue.web_url ?? ""),
215
+ {
216
+ padding: { top: 0, bottom: 0, left: 1, right: 2 },
217
+ borderStyle: "round",
218
+ borderColor: "#334155",
219
+ title: p.muted(` issue #${localIid} `),
220
+ titleAlignment: "left",
221
+ },
222
+ ),
223
+ );
224
+ console.log();
225
+
226
+ // Fetch branches
227
+ process.stdout.write(" " + p.muted("Loading branches…\r"));
228
+ const branches = await fetchIssueBranches(projectPath, localIid);
229
+ process.stdout.write(" ".repeat(40) + "\r");
230
+
231
+ const cfg = loadConfig();
232
+ const primaryBranches = cfg.portal?.primaryBranches ?? {};
233
+ const currentPrimary = primaryBranches[configKey] ?? null;
234
+
235
+ // Show branch list
236
+ if (branches.length > 0) {
237
+ for (const b of branches) {
238
+ const isPrimary = b.name === currentPrimary;
239
+ const date = (b.commit?.committed_date ?? "").slice(0, 10);
240
+ console.log(
241
+ " " + (isPrimary ? p.teal("★") : p.muted("·")) +
242
+ " " + colorBranch(b.name) +
243
+ (isPrimary ? " " + p.teal("primary") : "") +
244
+ (date ? " " + p.muted(date) : ""),
245
+ );
246
+ }
247
+ console.log();
248
+ } else {
249
+ console.log(" " + p.muted("No branches found for this issue.\n"));
250
+ }
251
+
252
+ // Action menu
253
+ const issueAction = await select({
254
+ message: p.white("Action:"),
255
+ choices: [
256
+ ...(branches.length > 0 ? [{
257
+ value: "primary",
258
+ name: p.cyan("⎇ Set primary branch"),
259
+ description: currentPrimary ? p.muted("current: ") + colorBranch(currentPrimary) : p.muted("none set"),
260
+ }] : []),
261
+ ...(currentPrimary ? [{
262
+ value: "mr",
263
+ name: p.purple("⊞ Create merge request"),
264
+ description: p.muted("MR from ") + colorBranch(currentPrimary) + p.muted(" → default base branch"),
265
+ }] : []),
266
+ {
267
+ value: "open",
268
+ name: p.teal("⊕ View in GitLab"),
269
+ description: p.muted(issue.web_url ?? ""),
270
+ },
271
+ { value: "back", name: p.yellow("← Back") },
272
+ ],
273
+ theme: THEME,
274
+ });
275
+ console.log();
276
+
277
+ if (issueAction === "open") {
278
+ openUrl(issue.web_url);
279
+ return;
280
+ }
281
+ if (issueAction === "back") return;
282
+
283
+ // Create MR from primary branch
284
+ if (issueAction === "mr") {
285
+ await cmdIssueMr(issue, glabRepos, portalConfig);
286
+ return;
287
+ }
288
+
289
+ // Set primary branch
290
+ const primaryChoice = await search({
291
+ message: p.white("Set primary branch:"),
292
+ source: (val) => {
293
+ const term = (val ?? "").toLowerCase().trim();
294
+ return [
295
+ { value: "__cancel__", name: p.yellow("← Cancel"), description: p.muted("return to action menu without changing") },
296
+ { value: null, name: p.muted("— none —"), description: p.muted("clear primary branch") },
297
+ ...branches
298
+ .filter((b) => !term || b.name.toLowerCase().includes(term))
299
+ .map((b) => ({
300
+ value: b.name,
301
+ name: colorBranch(b.name),
302
+ description: b.name === currentPrimary ? p.teal("★ current primary") : "",
303
+ })),
304
+ ];
305
+ },
306
+ theme: THEME,
307
+ });
308
+ console.log();
309
+
310
+ if (primaryChoice === "__cancel__") return;
311
+
312
+ // Persist
313
+ const fresh = loadConfig();
314
+ const updated = { ...(fresh.portal?.primaryBranches ?? {}), [configKey]: primaryChoice };
315
+ if (primaryChoice === null) delete updated[configKey];
316
+ saveConfig({ ...fresh, portal: { ...fresh.portal, primaryBranches: updated } });
317
+
318
+ if (primaryChoice) {
319
+ console.log(" " + p.green("✔") + " " + p.muted("Primary →") + " " + colorBranch(primaryChoice) + "\n");
320
+ } else {
321
+ console.log(" " + p.muted("Primary branch cleared.\n"));
322
+ }
323
+ }
324
+
325
+ // ── Issue MR creation ────────────────────────────────────────────────────────
326
+ // Creates an MR via the GitLab REST API directly (avoids glab mr create CLI
327
+ // argument parsing issues with special characters in titles).
328
+ async function createMrViaApi(projectPath, { sourceBranch, targetBranch, title, description, labels, isDraft }) {
329
+ const enc = encodeURIComponent(projectPath);
330
+ const fields = {
331
+ source_branch: sourceBranch,
332
+ target_branch: targetBranch,
333
+ title: title.trim(),
334
+ };
335
+ if (description?.trim()) fields.description = description.trim();
336
+ if (labels?.trim()) fields.labels = labels.trim();
337
+ if (isDraft) fields.draft = true;
338
+ return glabApi(`projects/${enc}/merge_requests`, { method: "POST", fields });
339
+ }
340
+
341
+ // Single-issue MR — called from cmdIssueView.
342
+ async function cmdIssueMr(issue, glabRepos, portalConfig) {
343
+ const ref = issue.references?.full ?? "";
344
+ const [projectPath, localIid] = ref.split("#");
345
+ const configKey = issueConfigKey(issue);
346
+ const primary = (loadConfig().portal?.primaryBranches ?? {})[configKey] ?? null;
347
+
348
+ if (!primary) {
349
+ console.log(" " + p.yellow("No primary branch set.") + p.muted(" Use \"Set primary branch\" first.\n"));
350
+ return;
351
+ }
352
+ const localRepo = findLocalRepo(glabRepos, projectPath);
353
+ if (!localRepo) {
354
+ console.log(" " + p.yellow("No local repo found for this issue.") + p.muted(" Is it within the current search scope?\n"));
355
+ return;
356
+ }
357
+
358
+ const targetBranch = await input({
359
+ message: p.white("Target branch") + p.muted(" (merge into):"),
360
+ default: portalConfig.defaultBaseBranch ?? "develop",
361
+ theme: THEME,
362
+ validate: (v) => v.trim() !== "" || "Required",
363
+ });
364
+ const title = await input({
365
+ message: p.white("Title:"),
366
+ default: issue.title,
367
+ theme: THEME,
368
+ validate: (v) => v.trim() !== "" || "Required",
369
+ });
370
+ const description = await input({ message: p.white("Description:"), default: "", theme: THEME });
371
+ const labels = await input({
372
+ message: p.white("Labels") + p.muted(" (optional):"),
373
+ default: portalConfig.defaultLabels ?? "",
374
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
375
+ });
376
+ const isDraft = await confirm({ message: p.white("Mark as") + p.muted(" Draft?"), default: false, theme: THEME });
377
+ const pushFirst = await confirm({ message: p.white("Push branch") + p.muted(" first?"), default: true, theme: THEME });
378
+ console.log();
379
+
380
+ console.log(
381
+ boxen(
382
+ p.muted(" source ") + colorBranch(primary) + p.muted(" → ") + colorBranch(targetBranch) + "\n" +
383
+ p.muted(" title ") + chalk.bold(p.white(title)) + "\n" +
384
+ p.muted(" project ") + p.white(localRepo.name) +
385
+ (labels?.trim() ? "\n" + p.muted(" labels ") + p.purple(labels) : "") +
386
+ (isDraft ? "\n" + p.muted(" flags ") + p.yellow("draft") : ""),
387
+ {
388
+ padding: { top: 1, bottom: 1, left: 2, right: 2 }, borderStyle: "round", borderColor: "#334155",
389
+ title: p.muted(" merge request preview "), titleAlignment: "right"
390
+ },
391
+ ),
392
+ );
393
+ console.log();
394
+
395
+ const ok = await confirm({ message: p.white("Create merge request?"), default: true, theme: THEME });
396
+ console.log();
397
+ if (!ok) return;
398
+
399
+ process.stdout.write(" " + p.muted("Creating MR…\r"));
400
+ try {
401
+ if (pushFirst) {
402
+ process.stdout.write(" " + p.muted(`Pushing ${primary}…\r`));
403
+ await execFileAsync("git", ["push", "-u", "origin", primary], { cwd: localRepo.repo }).catch(() => { });
404
+ }
405
+ const mr = await createMrViaApi(projectPath, { sourceBranch: primary, targetBranch, title, description, labels, isDraft });
406
+ process.stdout.write(" ".repeat(40) + "\r");
407
+ const url = mr.web_url ?? "";
408
+ console.log(
409
+ boxen(
410
+ chalk.bold(p.green("✔ Merge request created")) + (url ? "\n " + p.cyan(url) : ""),
411
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#4ade80" },
412
+ ),
413
+ );
414
+ console.log();
415
+ if (url) {
416
+ const openIt = await confirm({ message: p.white("Open in browser?"), default: false, theme: THEME });
417
+ console.log();
418
+ if (openIt) openUrl(url);
419
+ }
420
+ } catch (e) {
421
+ process.stdout.write(" ".repeat(40) + "\r");
422
+ const msg = (e.message ?? "").toLowerCase();
423
+ const isExisting = msg.includes("already exists") || msg.includes("open merge request");
424
+ console.log(
425
+ boxen(
426
+ isExisting
427
+ ? p.teal("◉ An MR for this branch already exists")
428
+ : chalk.bold(p.red("Failed to create MR")) + "\n\n" + p.muted((e.message ?? "").slice(0, 120)),
429
+ {
430
+ padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round",
431
+ borderColor: isExisting ? "#4ecdc4" : "#f87171"
432
+ },
433
+ ),
434
+ );
435
+ console.log();
436
+ }
437
+ }
438
+
439
+ // ── Epic bulk MR creation ─────────────────────────────────────────────────────
440
+ // Creates MRs in parallel for all issues that have a primary branch + local repo.
441
+ async function cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm = false } = {}) {
442
+ const primaryBranches = loadConfig().portal?.primaryBranches ?? {};
443
+
444
+ const candidates = epicIssues.map((issue) => {
445
+ const ref = issue.references?.full ?? "";
446
+ const [projectPath, localIid] = ref.split("#");
447
+ const primary = primaryBranches[issueConfigKey(issue)] ?? null;
448
+ const localRepo = findLocalRepo(glabRepos, projectPath);
449
+ return { issue, localIid, projectPath, primary, localRepo };
450
+ });
451
+
452
+ const ready = candidates.filter((c) => c.primary && c.localRepo);
453
+ const skipped = candidates.filter((c) => !c.primary || !c.localRepo);
454
+
455
+ if (ready.length === 0) {
456
+ console.log(" " + p.muted("No issues ready. Set primary branches first.\n"));
457
+ return;
458
+ }
459
+
460
+ // Show skipped repos as info before the picker
461
+ if (skipped.length > 0) {
462
+ const maxSkip = Math.max(...skipped.map((c) => (c.localRepo?.name ?? c.projectPath.split("/").pop()).length), 4);
463
+ for (const c of skipped) {
464
+ const name = (c.localRepo?.name ?? c.projectPath.split("/").pop()).padEnd(maxSkip);
465
+ const reason = !c.primary ? "no primary set" : "no local repo";
466
+ console.log(" " + p.muted("○") + " " + p.muted(name + " " + reason));
467
+ }
468
+ console.log();
469
+ }
470
+
471
+ let selectedReady;
472
+
473
+ if (autoConfirm) {
474
+ // Non-interactive: use all ready repos without showing the picker
475
+ selectedReady = ready;
476
+ if (skipped.length > 0) {
477
+ console.log();
478
+ }
479
+ } else {
480
+ // Multi-select: all ready repos pre-selected, space to toggle, Esc to go back
481
+ const choices = ready.map((c) => ({
482
+ name: c.localRepo.name,
483
+ value: c.localRepo.name,
484
+ message: chalk.bold(p.white(c.localRepo.name)) + " " + colorBranch(c.primary),
485
+ hint: "",
486
+ enabled: true, // pre-select all
487
+ }));
488
+
489
+ const selectedNames = await enquirer.autocomplete({
490
+ name: "repos",
491
+ message: "Select repos to create MRs for:",
492
+ multiple: true,
493
+ initial: 0,
494
+ limit: 12,
495
+ choices,
496
+ symbols: { indicator: { on: "◉", off: "◯" } },
497
+ footer() { return p.muted("space to toggle · type to filter · enter to confirm · esc to go back"); },
498
+ suggest(input = "", allChoices = []) {
499
+ const term = (input ?? "").toLowerCase().trim();
500
+ const selected = allChoices.filter((c) => c.enabled);
501
+ const unselected = allChoices.filter((c) => !c.enabled);
502
+ const filtered = term ? unselected.filter((c) => c.value.toLowerCase().includes(term)) : unselected;
503
+ return [...selected, ...filtered];
504
+ },
505
+ }).catch(() => "__back__");
506
+
507
+ console.log();
508
+ if (selectedNames === "__back__" || !Array.isArray(selectedNames) || selectedNames.length === 0) {
509
+ if (selectedNames !== "__back__") console.log(" " + p.muted("Nothing selected.\n"));
510
+ return;
511
+ }
512
+
513
+ selectedReady = selectedNames
514
+ .map((name) => ready.find((c) => c.localRepo.name === name))
515
+ .filter(Boolean);
516
+ } // end autoConfirm / interactive split
517
+
518
+ // Shared options — prompt once for all (skip prompts when auto-confirming)
519
+ const targetBranch = autoConfirm
520
+ ? (portalConfig.defaultBaseBranch ?? "develop")
521
+ : (await enquirer.prompt({
522
+ type: "input",
523
+ name: "targetBranch",
524
+ message: p.white("Target branch") + p.muted(" (for all):"),
525
+ initial: portalConfig.defaultBaseBranch ?? "develop",
526
+ validate: (v) => v.trim() !== "" || "Required",
527
+ })).targetBranch;
528
+
529
+ const labels = autoConfirm
530
+ ? (portalConfig.defaultLabels ?? "")
531
+ : await input({
532
+ message: p.white("Labels") + p.muted(" (optional, applied to all):"),
533
+ default: portalConfig.defaultLabels ?? "",
534
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
535
+ });
536
+
537
+ const isDraft = autoConfirm
538
+ ? false
539
+ : await confirm({ message: p.white("Mark all as") + p.muted(" Draft?"), default: false, theme: THEME });
540
+
541
+ const pushFirst = autoConfirm
542
+ ? true
543
+ : await confirm({ message: p.white("Push branches") + p.muted(" first?"), default: true, theme: THEME });
544
+
545
+ console.log();
546
+
547
+ const confirmed = autoConfirm
548
+ ? true
549
+ : await confirm({
550
+ message: p.white("Create ") + p.cyan(String(selectedReady.length)) + p.white(` MR${selectedReady.length !== 1 ? "s" : ""}?`),
551
+ default: true, theme: THEME,
552
+ });
553
+ console.log();
554
+ if (!confirmed) return;
555
+
556
+ // Parallel execution via Listr
557
+ const results = [];
558
+ const numWidth = String(selectedReady.length).length;
559
+
560
+
561
+ const listTasks = new Listr(
562
+ selectedReady.map(({ issue, localIid, localRepo, primary }, i) => {
563
+ const idx = p.muted(`[${String(i + 1).padStart(numWidth)}/${selectedReady.length}]`);
564
+ return {
565
+ title: idx + " " + chalk.bold(p.white(localRepo.name)) +
566
+ " " + colorBranch(primary) + p.muted(" → ") + colorBranch(targetBranch),
567
+ task: async (_, task) => {
568
+ try {
569
+ if (pushFirst) {
570
+ await execFileAsync("git", ["push", "-u", "origin", primary], { cwd: localRepo.repo }).catch(() => { });
571
+ }
572
+ const mr = await createMrViaApi(issue.references?.full?.split("#")[0] ?? "", {
573
+ sourceBranch: primary, targetBranch, title: issue.title, description: "", labels, isDraft,
574
+ });
575
+ const url = mr.web_url ?? "";
576
+ results.push({ name: localRepo.name, ok: true, url, existing: false });
577
+ task.title = idx + " " + chalk.bold(p.white(localRepo.name)) + " " + p.green("✔") + (url ? " " + p.cyan(url) : "");
578
+ } catch (e) {
579
+ const msg = (e.message ?? "").toLowerCase();
580
+ if (msg.includes("already exists") || msg.includes("open merge request")) {
581
+ results.push({ name: localRepo.name, ok: true, url: "", existing: true });
582
+ task.title = idx + " " + chalk.bold(p.white(localRepo.name)) + " " + p.teal("◉ already exists");
583
+ } else {
584
+ const clean = (e.message ?? "").replace(/\n/g, " ").trim();
585
+ results.push({ name: localRepo.name, ok: false, url: "", existing: false, msg: clean });
586
+ task.title = idx + " " + chalk.bold(p.white(localRepo.name)) + " " + p.red("✘ ") + p.muted(clean.slice(0, 65));
587
+ throw new Error(clean);
588
+ }
589
+ }
590
+ },
591
+ };
592
+ }),
593
+ { concurrent: true, exitOnError: false },
594
+ );
595
+
596
+ await listTasks.run().catch(() => { });
597
+ console.log();
598
+
599
+ const opened = results.filter((r) => r.ok && !r.existing);
600
+ const existing = results.filter((r) => r.ok && r.existing);
601
+ const failed = results.filter((r) => !r.ok);
602
+ const sep = p.slate(" · ");
603
+ const parts = [];
604
+ if (opened.length) parts.push(chalk.bold(p.green(`✔ ${opened.length} opened`)));
605
+ if (existing.length) parts.push(p.teal(`◉ ${existing.length} already existed`));
606
+ if (failed.length) parts.push(p.red(`✘ ${failed.length} failed`));
607
+ console.log(
608
+ boxen(
609
+ parts.join(sep) + "\n" + p.muted(`${selectedReady.length} repo${selectedReady.length !== 1 ? "s" : ""} processed`),
610
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: failed.length > 0 ? "#f87171" : "#4ade80" },
611
+ ),
612
+ );
613
+
614
+ const successful = results.filter((r) => r.ok && r.url);
615
+ if (successful.length > 0) {
616
+ const maxN = Math.max(...successful.map((r) => r.name.length));
617
+ console.log();
618
+ console.log(" " + chalk.bold(p.white("Merge Requests")));
619
+ console.log(" " + p.dim("─".repeat(50)));
620
+ for (const r of successful) {
621
+ console.log(
622
+ " " + (r.existing ? p.teal("◉") : p.green("✔")) +
623
+ " " + chalk.bold(p.white(r.name.padEnd(maxN))) + " " + p.cyan(r.url),
624
+ );
625
+ }
626
+ }
627
+ console.log();
628
+ }
629
+
630
+ // ── Repo matcher ──────────────────────────────────────────────────────────────
631
+ // Finds a local glabRepo entry for a given GitLab issue projectPath.
632
+ // Tier 1: exact full path match (fast, precise).
633
+ // Tier 2: fallback by folder name — handles cases where the local remote URL
634
+ // has a different slug than the GitLab project name (e.g. renamed repos).
635
+ function findLocalRepo(glabRepos, projectPath) {
636
+ const exact = glabRepos.find(
637
+ (r) => r.projectPath.toLowerCase() === (projectPath ?? "").toLowerCase(),
638
+ );
639
+ if (exact) return exact;
640
+
641
+ const nameSegment = (projectPath ?? "").split("/").pop().toLowerCase();
642
+ return glabRepos.find((r) => basename(r.repo).toLowerCase() === nameSegment) ?? null;
643
+ }
644
+
645
+ // ── Epic checkout ──────────────────────────────────────────────────────────────
646
+ // Checks out the primary branch for each issue's local repo.
647
+ // If a primary branch is not yet set, asks the user to pick one first.
648
+
649
+ async function cmdEpicCheckout(epicIssues, glabRepos, { autoConfirm = false } = {}) {
650
+ // Build tasks: match each issue to a local repo by project path
651
+ const seen = new Map(); // projectPath → task (deduplicate; last issue wins)
652
+ const tasks = [];
653
+
654
+ for (const issue of epicIssues) {
655
+ const ref = issue.references?.full ?? "";
656
+ if (!ref) continue;
657
+ const [projectPath, localIid] = ref.split("#");
658
+
659
+ const localRepo = findLocalRepo(glabRepos, projectPath);
660
+
661
+ if (!localRepo) continue;
662
+
663
+ const entry = { issue, localRepo, localIid, projectPath, configKey: issueConfigKey(issue) };
664
+ if (!seen.has(projectPath)) tasks.push(entry);
665
+ seen.set(projectPath, entry);
666
+ }
667
+
668
+ if (tasks.length === 0) {
669
+ console.log(" " + p.muted("No local repos matched the issues in this epic.\n"));
670
+ return;
671
+ }
672
+
673
+ // Phase 1 — resolve primaries; ask for any that are missing
674
+ const checkouts = []; // { localRepo, branchName }
675
+
676
+ for (const { issue, localRepo, localIid, projectPath, configKey } of tasks) {
677
+ let primary = (loadConfig().portal?.primaryBranches ?? {})[configKey] ?? null;
678
+
679
+ if (!primary) {
680
+ // No primary set — fetch branches and ask
681
+ console.log(
682
+ " " + p.yellow("○") + " " + p.white(localRepo.name) +
683
+ p.muted(" #" + localIid + " — no primary branch set"),
684
+ );
685
+ console.log();
686
+
687
+ process.stdout.write(" " + p.muted("Loading branches…\r"));
688
+ const branches = await fetchIssueBranches(projectPath, localIid);
689
+ process.stdout.write(" ".repeat(40) + "\r");
690
+
691
+ if (branches.length === 0) {
692
+ console.log(" " + p.muted(" No branches found for #" + localIid + " — skipping.\n"));
693
+ continue;
694
+ }
695
+
696
+ const picked = await search({
697
+ message:
698
+ p.white("Primary branch") + p.muted(" for ") +
699
+ p.white(localRepo.name) + p.muted(" #" + localIid + ":"),
700
+ source: (val) => {
701
+ const term = (val ?? "").toLowerCase().trim();
702
+ return [
703
+ { value: null, name: p.muted("— skip —"), description: p.muted("don't checkout this repo") },
704
+ ...branches
705
+ .filter((b) => !term || b.name.toLowerCase().includes(term))
706
+ .map((b) => ({
707
+ value: b.name,
708
+ name: colorBranch(b.name),
709
+ description: (b.commit?.committed_date ?? "").slice(0, 10),
710
+ })),
711
+ ];
712
+ },
713
+ theme: THEME,
714
+ });
715
+ console.log();
716
+
717
+ if (!picked) continue;
718
+
719
+ // Save as primary
720
+ primary = picked;
721
+ const fresh = loadConfig();
722
+ const pb = { ...(fresh.portal?.primaryBranches ?? {}), [configKey]: primary };
723
+ saveConfig({ ...fresh, portal: { ...fresh.portal, primaryBranches: pb } });
724
+ }
725
+
726
+ checkouts.push({ localRepo, branchName: primary });
727
+ }
728
+
729
+ if (checkouts.length === 0) {
730
+ console.log(" " + p.muted("Nothing to checkout.\n"));
731
+ return;
732
+ }
733
+
734
+ // Phase 2 — preview & confirm
735
+ console.log(
736
+ boxen(
737
+ checkouts
738
+ .map(({ localRepo, branchName }) =>
739
+ " " + p.white(localRepo.name.padEnd(24)) + colorBranch(branchName),
740
+ )
741
+ .join("\n"),
742
+ {
743
+ padding: { top: 1, bottom: 1, left: 1, right: 2 },
744
+ borderStyle: "round",
745
+ borderColor: "#334155",
746
+ title: p.muted(" checkout preview "),
747
+ titleAlignment: "right",
748
+ },
749
+ ),
750
+ );
751
+ console.log();
752
+
753
+ const confirmed = autoConfirm
754
+ ? true
755
+ : await confirm({
756
+ message: p.white("Checkout ") + p.cyan(String(checkouts.length)) + p.white(" repo" + (checkouts.length !== 1 ? "s" : "") + "?"),
757
+ default: true,
758
+ theme: THEME,
759
+ });
760
+ console.log();
761
+ if (!confirmed) return;
762
+
763
+ // Phase 3 — execute git switch
764
+ for (const { localRepo, branchName } of checkouts) {
765
+ try {
766
+ await execFileAsync("git", ["switch", branchName], { cwd: localRepo.repo });
767
+ console.log(
768
+ " " + p.green("✔") + " " + p.white(localRepo.name.padEnd(24)) + colorBranch(branchName),
769
+ );
770
+ } catch (e) {
771
+ const msg = ((e.stderr || e.message || "").toString().split("\n")[0] ?? "").trim();
772
+ console.log(
773
+ " " + p.red("✖") + " " + p.white(localRepo.name.padEnd(24)) + p.muted(msg.slice(0, 55)),
774
+ );
775
+ }
776
+ }
777
+ console.log();
778
+ }
779
+
780
+ // ── Epic CR review ────────────────────────────────────────────────────────────
781
+ // Runs `cr --base <primaryBranch>` sequentially in each issue's local repo.
782
+ // Uses spawn with stdio:inherit so cr's rich output flows directly to terminal.
783
+ async function cmdEpicCrReview(epicIssues, glabRepos, portalConfig = {}) {
784
+ const baseBranch = portalConfig.defaultBaseBranch ?? "develop";
785
+ const primaryBranches = loadConfig().portal?.primaryBranches ?? {};
786
+
787
+ const candidates = epicIssues.map((issue) => {
788
+ const ref = issue.references?.full ?? "";
789
+ const [projectPath] = ref.split("#");
790
+ const primary = primaryBranches[issueConfigKey(issue)] ?? null;
791
+ const localRepo = findLocalRepo(glabRepos, projectPath);
792
+ return { issue, projectPath, primary, localRepo };
793
+ });
794
+
795
+ const ready = candidates.filter((c) => c.primary && c.localRepo);
796
+ const skipped = candidates.filter((c) => !c.primary || !c.localRepo);
797
+
798
+ if (ready.length === 0) {
799
+ console.log(" " + p.muted("No issues ready for review. Set primary branches first.\n"));
800
+ return;
801
+ }
802
+
803
+ if (skipped.length > 0) {
804
+ const maxSkip = Math.max(...skipped.map((c) => (c.localRepo?.name ?? c.projectPath.split("/").pop()).length), 4);
805
+ for (const c of skipped) {
806
+ const name = (c.localRepo?.name ?? c.projectPath.split("/").pop()).padEnd(maxSkip);
807
+ const reason = !c.primary ? "no primary set" : "no local repo";
808
+ console.log(" " + p.muted("○") + " " + p.muted(name + " " + reason));
809
+ }
810
+ console.log();
811
+ }
812
+
813
+ console.log(
814
+ boxen(
815
+ ready
816
+ .map(({ localRepo, primary }) =>
817
+ " " + p.white(localRepo.name.padEnd(24)) + colorBranch(primary),
818
+ )
819
+ .join("\n"),
820
+ {
821
+ padding: { top: 1, bottom: 1, left: 1, right: 2 },
822
+ borderStyle: "round",
823
+ borderColor: "#334155",
824
+ title: p.muted(" cr review preview "),
825
+ titleAlignment: "right",
826
+ },
827
+ ),
828
+ );
829
+ console.log();
830
+
831
+ const confirmed = await confirm({
832
+ message: p.white("Run ") + p.yellow("cr") + p.white(" review on ") + p.cyan(String(ready.length)) + p.white(` repo${ready.length !== 1 ? "s" : ""}?`),
833
+ default: true,
834
+ theme: THEME,
835
+ });
836
+ console.log();
837
+ if (!confirmed) return;
838
+
839
+ // Detect which binary to use (cr alias or full coderabbit name)
840
+ const crBin = (() => { try { execSync("cr --version", { stdio: "pipe" }); return "cr"; } catch { return "coderabbit"; } })();
841
+
842
+ const numWidth = String(ready.length).length;
843
+ const results = [];
844
+
845
+ for (let i = 0; i < ready.length; i++) {
846
+ const { localRepo, primary } = ready[i];
847
+ const idx = `[${String(i + 1).padStart(numWidth)}/${ready.length}]`;
848
+
849
+ // Print a header so the user knows which repo is being reviewed
850
+ console.log(
851
+ boxen(
852
+ p.muted(idx) + " " + chalk.bold(p.white(localRepo.name)) + " " + colorBranch(primary),
853
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#334155" },
854
+ ),
855
+ );
856
+ console.log();
857
+
858
+ // 1. Switch to the primary (feature) branch
859
+ try {
860
+ execSync(`git switch ${primary}`, { cwd: localRepo.repo, stdio: "pipe" });
861
+ } catch {
862
+ // already on branch or switch failed — continue anyway
863
+ }
864
+
865
+ // 2. Run cr --base <defaultBaseBranch> --prompt-only
866
+ // This reviews all commits on primaryBranch that aren't on baseBranch
867
+ const exitCode = await new Promise((resolve) => {
868
+ const child = spawn(crBin, ["--base", baseBranch, "--prompt-only"], {
869
+ cwd: localRepo.repo,
870
+ stdio: "inherit",
871
+ shell: false,
872
+ });
873
+ child.on("close", (code) => resolve(code ?? 0));
874
+ child.on("error", () => resolve(1));
875
+ });
876
+
877
+ const ok = exitCode === 0;
878
+ results.push({ name: localRepo.name, ok });
879
+ console.log(
880
+ " " + (ok ? p.green("✔") : p.red("✘")) +
881
+ " " + chalk.bold(p.white(localRepo.name)) +
882
+ " " + (ok ? p.green("review complete") : p.red("review failed")) + "\n",
883
+ );
884
+ }
885
+
886
+ // Final summary
887
+ const passed = results.filter((r) => r.ok).length;
888
+ const failed = results.filter((r) => !r.ok).length;
889
+ const summaryParts = [];
890
+ if (passed) summaryParts.push(chalk.bold(p.green(`✔ ${passed} complete`)));
891
+ if (failed) summaryParts.push(p.red(`✘ ${failed} failed`));
892
+ console.log(
893
+ boxen(
894
+ summaryParts.join(p.slate(" · ")) + "\n" + p.muted(`${ready.length} repo${ready.length !== 1 ? "s" : ""} reviewed`),
895
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: failed > 0 ? "#f87171" : "#4ade80" },
896
+ ),
897
+ );
898
+ console.log();
899
+ }
900
+
901
+ // ── Main portal command ────────────────────────────────────────────────────────
902
+
903
+ export async function cmdPortal(repos, {
904
+ settings = false,
905
+ // ── non-interactive opts ────────────────────────────────────────────────────────────
906
+ epic: cliEpic = null, // --epic <iid>
907
+ checkout: cliCheckout = false, // --checkout
908
+ createMr: cliCreateMr = false, // --create-mr
909
+ createIssue: cliCreateIssue = false, // --create-issue
910
+ review: cliReview = false, // --review
911
+ // shared
912
+ target: cliTarget = null, // --target
913
+ title: cliTitle = null, // --title (MR)
914
+ description: cliDescription = null,
915
+ labels: cliLabels = null,
916
+ draft: cliDraft = false,
917
+ noPush: cliNoPush = false,
918
+ // issue creation
919
+ issueProject: cliIssueProject = null,
920
+ issueTitle: cliIssueTitle = null,
921
+ issueDescription: cliIssueDescription = null,
922
+ issueLabels: cliIssueLabels = null,
923
+ branchName: cliBranchName = null,
924
+ baseBranch: cliBaseBranch = null,
925
+ // auto-confirm
926
+ yes: autoConfirm = false,
927
+ } = {}) {
928
+ // Detect CodeRabbit CLI once up front
929
+ const crAvailable = isCrAvailable();
930
+ const config = loadConfig();
931
+ const portalConfig = config.portal ?? {};
932
+
933
+ try {
934
+ execSync("glab version", { encoding: "utf8", stdio: "pipe" });
935
+ } catch {
936
+ const isMac = process.platform === "darwin";
937
+ const isWin = process.platform === "win32";
938
+ const isLinux = process.platform === "linux";
939
+
940
+ const platform = isMac ? "macOS" : isWin ? "Windows" : isLinux ? "Linux" : null;
941
+
942
+ const installLines = isMac
943
+ ? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
944
+ p.muted(" MacPorts ") + p.cyan("sudo port install glab") + "\n" +
945
+ p.muted(" asdf ") + p.cyan("asdf plugin add glab && asdf install glab latest")
946
+ : isWin
947
+ ? p.muted(" winget ") + p.cyan("winget install glab.glab") + "\n" +
948
+ p.muted(" choco ") + p.cyan("choco install glab") + "\n" +
949
+ p.muted(" scoop ") + p.cyan("scoop install glab") + "\n" +
950
+ p.muted(" brew ") + p.cyan("brew install glab") + p.muted(" (via WSL)")
951
+ : isLinux
952
+ ? p.muted(" brew ") + p.cyan("brew install glab") + "\n" +
953
+ p.muted(" snap ") + p.cyan("sudo snap install glab && sudo snap connect glab:ssh-keys") + "\n" +
954
+ p.muted(" apt ") + p.cyan("sudo apt install glab") + p.muted(" (WakeMeOps repo)") + "\n" +
955
+ p.muted(" pacman ") + p.cyan("pacman -S glab") + "\n" +
956
+ p.muted(" dnf ") + p.cyan("dnf install glab")
957
+ : p.muted(" ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation");
958
+
959
+ console.log(
960
+ boxen(
961
+ chalk.bold(p.red("glab not found")) + "\n\n" +
962
+ p.white((platform ? `gitmux requires the GitLab CLI. Install it on ${platform}:\n\n` : "gitmux requires the GitLab CLI:\n\n")) +
963
+ installLines + "\n\n" +
964
+ p.white("Then authenticate:\n\n") +
965
+ p.muted(" ") + p.cyan("glab auth login") + p.muted(" (follow the prompts to connect your GitLab account)") + "\n" +
966
+ p.muted(" docs ") + p.cyan("https://gitlab.com/gitlab-org/cli#installation"),
967
+ {
968
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
969
+ borderStyle: "round",
970
+ borderColor: "#f87171",
971
+ title: p.red(" missing dependency: glab "),
972
+ titleAlignment: "center",
973
+ },
974
+ ),
975
+ );
976
+ return 1;
977
+ }
978
+
979
+ process.stdout.write(" " + p.muted("Authenticating…\r"));
980
+ let user;
981
+ try {
982
+ user = await glabApi("/user");
983
+ process.stdout.write(" ".repeat(40) + "\r");
984
+ } catch (e) {
985
+ process.stdout.write(" ".repeat(40) + "\r");
986
+ console.log(
987
+ boxen(
988
+ chalk.bold(p.red("Authentication failed")) + "\n\n" +
989
+ p.muted("Run ") + p.cyan("glab auth login") + p.muted(" to authenticate.\n") +
990
+ p.muted(e.message.slice(0, 80)),
991
+ {
992
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
993
+ borderStyle: "round",
994
+ borderColor: "#f87171",
995
+ },
996
+ ),
997
+ );
998
+ return 1;
999
+ }
1000
+
1001
+ let group = portalConfig.group;
1002
+ if (!group) {
1003
+ const rawGroup = detectGroupFromRepos(repos);
1004
+ if (rawGroup) {
1005
+ process.stdout.write(" " + p.muted("Detecting group…\r"));
1006
+ group = await resolveEpicGroup(rawGroup, user.username, portalConfig.epicLabelFilter);
1007
+ process.stdout.write(" ".repeat(40) + "\r");
1008
+ saveConfig({ ...config, portal: { ...portalConfig, group } });
1009
+ console.log(" " + p.muted("Group auto-detected: ") + p.white(group) + "\n");
1010
+ }
1011
+ }
1012
+
1013
+ if (settings) {
1014
+ return await cmdPortalSettings(loadConfig(), group);
1015
+ }
1016
+
1017
+ if (!group) {
1018
+ console.log(
1019
+ " " + p.yellow("No GitLab group configured.") + p.muted(" Let's set it up.\n"),
1020
+ );
1021
+ return await cmdPortalSettings(loadConfig(), null);
1022
+ }
1023
+
1024
+ // ── Fetch epics assigned to user via GraphQL Work Items API ───────────────────
1025
+ process.stdout.write(" " + p.muted("Loading epics…\r"));
1026
+ let epics = [];
1027
+
1028
+ try {
1029
+ epics = await fetchAssignedEpics(group, user.username, portalConfig.epicLabelFilter);
1030
+ process.stdout.write(" ".repeat(40) + "\r");
1031
+ } catch (e) {
1032
+ process.stdout.write(" ".repeat(40) + "\r");
1033
+ console.log(
1034
+ boxen(
1035
+ chalk.bold(p.red("Failed to load epics")) + "\n\n" + p.muted(e.message.slice(0, 100)),
1036
+ {
1037
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1038
+ borderStyle: "round",
1039
+ borderColor: "#f87171",
1040
+ },
1041
+ ),
1042
+ );
1043
+ return 1;
1044
+ }
1045
+
1046
+ // No epics in configured group — probe ancestor groups (handles stale config)
1047
+ if (epics.length === 0 && group.includes("/")) {
1048
+ process.stdout.write(" " + p.muted("Checking parent groups for epics…\r"));
1049
+ const betterGroup = await resolveEpicGroup(group, user.username, portalConfig.epicLabelFilter);
1050
+ process.stdout.write(" ".repeat(60) + "\r");
1051
+
1052
+ if (betterGroup !== group) {
1053
+ group = betterGroup;
1054
+ saveConfig({ ...loadConfig(), portal: { ...(loadConfig().portal ?? {}), group } });
1055
+ try {
1056
+ epics = await fetchAssignedEpics(group, user.username, portalConfig.epicLabelFilter);
1057
+ } catch { }
1058
+ }
1059
+ }
1060
+
1061
+ // Portal header — rendered after group is fully resolved
1062
+ console.log(
1063
+ boxen(
1064
+ p.muted("user") + " " + chalk.bold(p.white(user.username)) +
1065
+ " " + p.muted("group") + " " + chalk.bold(p.cyan(group)),
1066
+ {
1067
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1068
+ borderStyle: "round",
1069
+ borderColor: "#334155",
1070
+ title: p.muted(" GitLab Development Portal "),
1071
+ titleAlignment: "center",
1072
+ },
1073
+ ),
1074
+ );
1075
+ console.log();
1076
+
1077
+ const glabRepos = (
1078
+ await Promise.all(
1079
+ repos.map(async (repo) => {
1080
+ const remote = getRemoteUrl(repo);
1081
+ if (!isGitLabRemote(remote)) return null;
1082
+ const branch = await getCurrentBranch(repo);
1083
+ const projectPath = getProjectPath(remote);
1084
+ return { repo, name: basename(repo), remote, projectPath, branch };
1085
+ }),
1086
+ )
1087
+ ).filter(Boolean);
1088
+
1089
+ // ── Non-interactive dispatch ─────────────────────────────────────────────────
1090
+ // When any action flag is set, resolve the epic (if --epic provided) and dispatch
1091
+ // directly without showing any menus.
1092
+ const hasCliAction = cliEpic || cliCheckout || cliCreateMr || cliCreateIssue || cliReview;
1093
+ if (hasCliAction) {
1094
+ let resolvedEpic = null;
1095
+
1096
+ if (cliEpic) {
1097
+ resolvedEpic = epics.find((e) => String(e.iid) === String(cliEpic));
1098
+ if (!resolvedEpic) {
1099
+ console.log(
1100
+ boxen(
1101
+ chalk.bold(p.red(`Epic #${cliEpic} not found`)) + "\n" +
1102
+ p.muted("Available IIDs: ") + epics.map((e) => `#${e.iid}`).join(", "),
1103
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#f87171" },
1104
+ ),
1105
+ );
1106
+ return 1;
1107
+ }
1108
+ }
1109
+
1110
+ // --create-mr via portal (non-interactive MR command)
1111
+ if (cliCreateMr && !cliEpic) {
1112
+ return await cmdMr(repos, {
1113
+ target: cliTarget,
1114
+ title: cliTitle,
1115
+ description: cliDescription,
1116
+ labels: cliLabels,
1117
+ draft: cliDraft,
1118
+ noPush: cliNoPush,
1119
+ yes: autoConfirm,
1120
+ });
1121
+ }
1122
+
1123
+ if (resolvedEpic) {
1124
+ // Fetch issues for the resolved epic
1125
+ process.stdout.write(" " + p.muted("Loading issues…\r"));
1126
+ const epicIssues = await fetchEpicIssues(group, resolvedEpic.iid);
1127
+ process.stdout.write(" ".repeat(40) + "\r");
1128
+
1129
+ if (cliCheckout) {
1130
+ return await cmdEpicCheckout(epicIssues, glabRepos, { autoConfirm });
1131
+ }
1132
+
1133
+ if (cliReview) {
1134
+ if (!crAvailable) {
1135
+ console.log(
1136
+ boxen(
1137
+ chalk.bold(p.yellow("CodeRabbit CLI not found")) + "\n\n" +
1138
+ p.muted("Install with: ") + p.cyan("curl -fsSL https://cli.coderabbit.ai/install.sh | sh") + "\n" +
1139
+ p.muted("Then authenticate: ") + p.cyan("cr auth login"),
1140
+ {
1141
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
1142
+ borderStyle: "round",
1143
+ borderColor: "#fbbf24",
1144
+ title: p.yellow(" missing dependency "),
1145
+ titleAlignment: "center",
1146
+ },
1147
+ ),
1148
+ );
1149
+ return 1;
1150
+ }
1151
+ return await cmdEpicCrReview(epicIssues, glabRepos, portalConfig);
1152
+ }
1153
+
1154
+ if (cliCreateMr) {
1155
+ // Bulk epic MR — override shared options in portalConfig for non-interactive
1156
+ const mergedPortalConfig = {
1157
+ ...portalConfig,
1158
+ ...(cliTarget ? { defaultBaseBranch: cliTarget } : {}),
1159
+ ...(cliLabels ? { defaultLabels: cliLabels } : {}),
1160
+ };
1161
+ return await cmdEpicMr(epicIssues, glabRepos, mergedPortalConfig, { autoConfirm });
1162
+ }
1163
+
1164
+ if (cliCreateIssue) {
1165
+ if (!cliIssueProject) {
1166
+ console.log(p.red(" --issue-project is required for non-interactive issue creation.\n"));
1167
+ return 1;
1168
+ }
1169
+ if (!cliIssueTitle) {
1170
+ console.log(p.red(" --issue-title is required for non-interactive issue creation.\n"));
1171
+ return 1;
1172
+ }
1173
+
1174
+ const projectChoice = glabRepos.find(
1175
+ (r) => r.name === cliIssueProject || r.projectPath === cliIssueProject,
1176
+ );
1177
+ if (!projectChoice) {
1178
+ console.log(p.red(` Project "${cliIssueProject}" not found in local GitLab repos.\n`));
1179
+ return 1;
1180
+ }
1181
+
1182
+ // Resolve epic ID
1183
+ let epicId = null;
1184
+ try {
1185
+ const epicRest = await glabApi(`groups/${encodeURIComponent(group)}/epics/${resolvedEpic.iid}`);
1186
+ epicId = epicRest.id ?? null;
1187
+ } catch { }
1188
+
1189
+ const issueFields = {
1190
+ title: cliIssueTitle.trim(),
1191
+ ...(epicId ? { epic_id: epicId } : { epic_iid: resolvedEpic.iid }),
1192
+ };
1193
+ if (cliIssueDescription?.trim()) issueFields.description = cliIssueDescription.trim();
1194
+ const effectiveLabels = cliIssueLabels ?? portalConfig.defaultLabels ?? null;
1195
+ if (effectiveLabels?.trim()) issueFields.labels = effectiveLabels.trim();
1196
+ if (portalConfig.defaultMilestone?.id) issueFields.milestone_id = portalConfig.defaultMilestone.id;
1197
+
1198
+ const enc = encodeURIComponent(projectChoice.projectPath);
1199
+ process.stdout.write(" " + p.muted("Creating issue…\r"));
1200
+ let issue;
1201
+ try {
1202
+ issue = await glabApi(`projects/${enc}/issues`, { method: "POST", fields: issueFields });
1203
+ process.stdout.write(" ".repeat(40) + "\r");
1204
+ } catch (e) {
1205
+ process.stdout.write(" ".repeat(40) + "\r");
1206
+ console.log(
1207
+ boxen(
1208
+ chalk.bold(p.red("Failed to create issue")) + "\n\n" + p.muted(e.message.slice(0, 100)),
1209
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#f87171" },
1210
+ ),
1211
+ );
1212
+ return 1;
1213
+ }
1214
+
1215
+ console.log(
1216
+ boxen(
1217
+ chalk.bold(p.green(`✔ Issue #${issue.iid} created`)) + "\n " + p.muted(issue.web_url ?? ""),
1218
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#4ade80" },
1219
+ ),
1220
+ );
1221
+ console.log();
1222
+
1223
+ // Non-interactive branch creation if --branch-name is provided
1224
+ if (cliBranchName) {
1225
+ const baseBr = cliBaseBranch ?? portalConfig.defaultBaseBranch ?? "develop";
1226
+ process.stdout.write(" " + p.muted("Creating branch…\r"));
1227
+ try {
1228
+ await glabApi(`projects/${enc}/repository/branches`, {
1229
+ method: "POST",
1230
+ fields: { branch: cliBranchName.trim(), ref: baseBr.trim() },
1231
+ });
1232
+ process.stdout.write(" ".repeat(40) + "\r");
1233
+ console.log(
1234
+ boxen(
1235
+ chalk.bold(p.green("✔ Branch created")) + " " + colorBranch(cliBranchName.trim()),
1236
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#4ade80" },
1237
+ ),
1238
+ );
1239
+ // Auto-set as primary
1240
+ const cfgNow = loadConfig();
1241
+ const pbNow = { ...(cfgNow.portal?.primaryBranches ?? {}), [`${projectChoice.projectPath}#${issue.iid}`]: cliBranchName.trim() };
1242
+ saveConfig({ ...cfgNow, portal: { ...cfgNow.portal, primaryBranches: pbNow } });
1243
+ } catch (e) {
1244
+ process.stdout.write(" ".repeat(40) + "\r");
1245
+ console.log(
1246
+ boxen(
1247
+ chalk.bold(p.red("Branch creation failed")) + "\n\n" + p.muted(e.message.slice(0, 80)),
1248
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: "round", borderColor: "#f87171" },
1249
+ ),
1250
+ );
1251
+ }
1252
+ console.log();
1253
+ }
1254
+
1255
+ return 0;
1256
+ }
1257
+ }
1258
+
1259
+ // Fallthrough: --epic was given but no action flag — just print the epic's issues
1260
+ if (resolvedEpic) {
1261
+ process.stdout.write(" " + p.muted("Loading issues…\r"));
1262
+ const epicIssues = await fetchEpicIssues(group, resolvedEpic.iid);
1263
+ process.stdout.write(" ".repeat(40) + "\r");
1264
+ const pb = loadConfig().portal?.primaryBranches ?? {};
1265
+ console.log(p.white(` Epic #${resolvedEpic.iid}: `) + chalk.bold(p.cyan(resolvedEpic.title)) + "\n");
1266
+ printEpicIssues(epicIssues, pb);
1267
+ return 0;
1268
+ }
1269
+ }
1270
+
1271
+ // ── Portal home menu ─────────────────────────────────────────────────────────
1272
+ portalHome: while (true) {
1273
+ const section = await select({
1274
+ message: p.white("GitLab Portal:"),
1275
+ choices: [
1276
+ {
1277
+ value: "epics",
1278
+ name: chalk.hex("#FC6D26")("◈ Epics") + p.muted(" browse assigned epics, create issues & branches"),
1279
+ description: p.muted("view epics, manage issues, checkout primary branches"),
1280
+ },
1281
+ {
1282
+ value: "mr",
1283
+ name: p.purple("⎇ Merge Requests") + p.muted(" create MRs for local branches"),
1284
+ description: p.muted("open merge requests via glab CLI"),
1285
+ },
1286
+ {
1287
+ value: "__back__",
1288
+ name: p.yellow("← Go back"),
1289
+ description: p.muted("return to mode selection"),
1290
+ },
1291
+ ],
1292
+ theme: THEME,
1293
+ });
1294
+ console.log();
1295
+
1296
+ if (section === "__back__") return "__back__";
1297
+
1298
+ if (section === "mr") {
1299
+ const r = await cmdMr(repos, {
1300
+ target: cliTarget,
1301
+ title: cliTitle,
1302
+ description: cliDescription,
1303
+ labels: cliLabels,
1304
+ draft: cliDraft,
1305
+ noPush: cliNoPush,
1306
+ yes: autoConfirm,
1307
+ });
1308
+ if (r !== "__back__") return r;
1309
+ continue portalHome;
1310
+ }
1311
+
1312
+ // ── Epics section ───────────────────────────────────────────────────────────
1313
+ if (epics.length === 0) {
1314
+ console.log(
1315
+ boxen(
1316
+ p.yellow("No open epics assigned to you") + p.muted(" in ") + p.white(group),
1317
+ {
1318
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1319
+ borderStyle: "round",
1320
+ borderColor: "#fbbf24",
1321
+ },
1322
+ ),
1323
+ );
1324
+ console.log();
1325
+ continue portalHome;
1326
+ }
1327
+
1328
+ if (glabRepos.length === 0) {
1329
+ console.log(p.yellow(" No local GitLab repositories found in scope.\n"));
1330
+ continue portalHome;
1331
+ }
1332
+
1333
+ portalFlow: while (true) {
1334
+ // 1. Select epic
1335
+ const epic = await search({
1336
+ message: p.white("Epic:"),
1337
+ source: (val) => {
1338
+ const term = (val ?? "").toLowerCase().trim();
1339
+ return [
1340
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to portal home") },
1341
+ ...epics
1342
+ .filter((e) => !term || e.title.toLowerCase().includes(term))
1343
+ .map((e) => ({
1344
+ value: e,
1345
+ name: chalk.bold(p.white(e.title)),
1346
+ description:
1347
+ p.muted(`#${e.iid}`) +
1348
+ (e.labels.length > 0 ? " " + e.labels.map((l) => colorLabel(l)).join(p.muted(" ")) : ""),
1349
+ })),
1350
+ ];
1351
+ },
1352
+ theme: THEME,
1353
+ });
1354
+ if (epic === "__back__") break portalFlow;
1355
+ console.log();
1356
+
1357
+ // 2. Issue loop — show existing issues then action menu
1358
+ issueLoop: while (true) {
1359
+ // Fetch and display existing issues under this epic
1360
+ process.stdout.write(" " + p.muted("Loading issues…\r"));
1361
+ const epicIssues = await fetchEpicIssues(group, epic.iid);
1362
+ process.stdout.write(" ".repeat(40) + "\r");
1363
+
1364
+ const primaryBranches = loadConfig().portal?.primaryBranches ?? {};
1365
+ printEpicIssues(epicIssues, primaryBranches);
1366
+
1367
+ // Action menu
1368
+ const hasIssues = epicIssues.length > 0;
1369
+ const hasLocalHit = epicIssues.some((i) => {
1370
+ const [pp] = (i.references?.full ?? "").split("#");
1371
+ return findLocalRepo(glabRepos, pp) !== null;
1372
+ });
1373
+
1374
+ const action = await select({
1375
+ message: p.white("Action:"),
1376
+ choices: [
1377
+ ...(hasIssues ? [{
1378
+ value: "view",
1379
+ name: p.cyan("↵ View issue"),
1380
+ description: p.muted("see branches & set primary branch"),
1381
+ }] : []),
1382
+ ...(hasIssues && hasLocalHit ? [{
1383
+ value: "checkout",
1384
+ name: p.teal("⎇ Checkout to primary branches"),
1385
+ description: p.muted("switch local repos to their primary branches"),
1386
+ }] : []),
1387
+ ...(hasIssues && hasLocalHit ? [{
1388
+ value: "epicMr",
1389
+ name: p.purple("⊞ Create MRs for epic"),
1390
+ description: p.muted("open a merge request per issue from each primary branch"),
1391
+ }] : []),
1392
+ ...(hasIssues && hasLocalHit && crAvailable ? [{
1393
+ value: "review",
1394
+ name: p.yellow("◎ Review with CodeRabbit"),
1395
+ description: p.muted("run cr --base <primary> on each issue's local repo"),
1396
+ }] : []),
1397
+ {
1398
+ value: "create",
1399
+ name: p.green("+ Create new issue"),
1400
+ description: p.muted("add a new issue linked to this epic"),
1401
+ },
1402
+ {
1403
+ value: "openEpic",
1404
+ name: p.teal("⊕ View epic in GitLab"),
1405
+ description: p.muted(epic.web_url ?? ""),
1406
+ },
1407
+ {
1408
+ value: "__back__",
1409
+ name: p.yellow("← Back to epics"),
1410
+ description: p.muted("select a different epic"),
1411
+ },
1412
+ ],
1413
+ theme: THEME,
1414
+ });
1415
+ console.log();
1416
+
1417
+ if (action === "__back__") continue portalFlow;
1418
+
1419
+ if (action === "openEpic") {
1420
+ openUrl(epic.web_url);
1421
+ continue issueLoop;
1422
+ }
1423
+
1424
+ // Checkout all repos to their primary branches
1425
+ if (action === "checkout") {
1426
+ await cmdEpicCheckout(epicIssues, glabRepos, { autoConfirm });
1427
+ continue issueLoop;
1428
+ }
1429
+
1430
+ // Review all issues in this epic with CodeRabbit
1431
+ if (action === "review") {
1432
+ await cmdEpicCrReview(epicIssues, glabRepos, portalConfig);
1433
+ continue issueLoop;
1434
+ }
1435
+
1436
+ // Create MRs for all issues in this epic
1437
+ if (action === "epicMr") {
1438
+ await cmdEpicMr(epicIssues, glabRepos, portalConfig, { autoConfirm });
1439
+ continue issueLoop;
1440
+ }
1441
+
1442
+ // View existing issue
1443
+ if (action === "view") {
1444
+ const issueChoice = await search({
1445
+ message: p.white("Select issue:"),
1446
+ source: (val) => {
1447
+ const term = (val ?? "").toLowerCase().trim();
1448
+ const pb = loadConfig().portal?.primaryBranches ?? {};
1449
+ return [
1450
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to action menu") },
1451
+ ...epicIssues
1452
+ .filter((i) => !term || i.title.toLowerCase().includes(term) || String(i.iid).includes(term))
1453
+ .map((i) => {
1454
+ const ref = i.references?.full ?? "";
1455
+ const projectName = ref.split("#")[0].split("/").pop() ?? "";
1456
+ const primary = pb[ref] ?? null;
1457
+ return {
1458
+ value: i,
1459
+ name:
1460
+ (i.state === "opened" ? p.green("● ") : p.muted("○ ")) +
1461
+ p.white(i.title.slice(0, 48)),
1462
+ description:
1463
+ p.muted(projectName) +
1464
+ (primary ? " " + p.teal("⎇") + " " + colorBranch(primary) : ""),
1465
+ };
1466
+ }),
1467
+ ];
1468
+ },
1469
+ theme: THEME,
1470
+ });
1471
+ console.log();
1472
+ if (issueChoice !== "__back__") await cmdIssueView(issueChoice, glabRepos, portalConfig);
1473
+ continue issueLoop;
1474
+ }
1475
+
1476
+ // 3. Select project
1477
+ const projectChoice = await search({
1478
+ message: p.white("Project:"),
1479
+ source: (val) => {
1480
+ const term = (val ?? "").toLowerCase().trim();
1481
+ return [
1482
+ { value: "__back__", name: p.yellow("← Go back"), description: p.muted("return to issue list") },
1483
+ ...glabRepos
1484
+ .filter((r) =>
1485
+ !term ||
1486
+ r.name.toLowerCase().includes(term) ||
1487
+ r.projectPath.toLowerCase().includes(term),
1488
+ )
1489
+ .map((r) => ({
1490
+ value: r,
1491
+ name: chalk.bold(p.white(r.name)),
1492
+ description: colorBranch(r.branch) + " " + p.muted(r.projectPath),
1493
+ })),
1494
+ ];
1495
+ },
1496
+ theme: THEME,
1497
+ });
1498
+ if (projectChoice === "__back__") { console.log(); continue issueLoop; }
1499
+ console.log();
1500
+
1501
+ // 4. Issue details
1502
+ const defaultTitle = `${epic.title} - ${projectChoice.name}`;
1503
+
1504
+ // 4. Issue details — use enquirer initial so backspace works char-by-char
1505
+ const { issueTitle } = await enquirer.prompt({
1506
+ type: "input",
1507
+ name: "issueTitle",
1508
+ message: p.white("Title:"),
1509
+ initial: defaultTitle,
1510
+ validate: (v) => v.trim() !== "" || "Title cannot be empty",
1511
+ });
1512
+
1513
+ const issueDesc = await input({
1514
+ message: p.white("Description") + p.muted(" (optional):"),
1515
+ theme: THEME,
1516
+ });
1517
+
1518
+ const issueLabels = await input({
1519
+ message: p.white("Labels") + p.muted(" (comma separated, optional):"),
1520
+ default: portalConfig.defaultLabels ?? "",
1521
+ theme: { ...THEME, style: { ...THEME.style, answer: (s) => p.purple(s) } },
1522
+ });
1523
+
1524
+ // 5. Preview
1525
+ const previewLines = [
1526
+ p.muted(" epic ") + chalk.bold(p.white(epic.title.slice(0, 55))),
1527
+ p.muted(" project ") + chalk.bold(p.white(projectChoice.name)),
1528
+ p.muted(" title ") + p.white(issueTitle.slice(0, 55) + (issueTitle.length > 55 ? "…" : "")),
1529
+ issueDesc.trim()
1530
+ ? p.muted(" description ") + p.white(issueDesc.slice(0, 55) + (issueDesc.length > 55 ? "…" : ""))
1531
+ : null,
1532
+ issueLabels.trim()
1533
+ ? p.muted(" labels ") + p.purple(issueLabels)
1534
+ : null,
1535
+ portalConfig.defaultMilestone?.title
1536
+ ? p.muted(" milestone ") + p.teal(portalConfig.defaultMilestone.title)
1537
+ : null,
1538
+ portalConfig.defaultIteration?.id
1539
+ ? p.muted(" iteration ") + p.cyan(
1540
+ portalConfig.defaultIteration.id === "__current__"
1541
+ ? "current iteration"
1542
+ : portalConfig.defaultIteration.title,
1543
+ )
1544
+ : null,
1545
+ ].filter(Boolean).join("\n");
1546
+
1547
+ console.log();
1548
+ console.log(
1549
+ boxen(previewLines, {
1550
+ padding: { top: 1, bottom: 1, left: 2, right: 2 },
1551
+ borderStyle: "round",
1552
+ borderColor: "#334155",
1553
+ title: p.muted(" issue preview "),
1554
+ titleAlignment: "right",
1555
+ }),
1556
+ );
1557
+ console.log();
1558
+
1559
+ const confirmed = await confirm({ message: p.white("Create issue?"), default: true, theme: THEME });
1560
+ if (!confirmed) { console.log(); continue issueLoop; }
1561
+
1562
+ // 6. Create issue — resolve __current__ iteration to a real sprint ID
1563
+ let resolvedIterationId = portalConfig.defaultIteration?.id ?? null;
1564
+ if (resolvedIterationId === "__current__") {
1565
+ try {
1566
+ const groupEnc = encodeURIComponent(group);
1567
+ const todayUtc = new Date().toISOString().slice(0, 10);
1568
+ const activeIters = await glabApi(`groups/${groupEnc}/iterations?state=current&per_page=1`);
1569
+ const iter = Array.isArray(activeIters) ? activeIters[0] : null;
1570
+ if (iter && iter.due_date > todayUtc) {
1571
+ // Iteration is still open past today — safe to use
1572
+ resolvedIterationId = iter.id;
1573
+ } else {
1574
+ // Ends today or none found — GitLab silently drops same-day assignments,
1575
+ // so grab the next upcoming iteration instead
1576
+ const nextIters = await glabApi(`groups/${groupEnc}/iterations?state=upcoming&per_page=1`);
1577
+ const next = Array.isArray(nextIters) ? nextIters[0] : null;
1578
+ resolvedIterationId = next?.id ?? null;
1579
+ }
1580
+ } catch {
1581
+ resolvedIterationId = null;
1582
+ }
1583
+ }
1584
+
1585
+
1586
+ // Resolve the real classic epic ID via REST — the GraphQL WorkItems API
1587
+ // returns gid://gitlab/WorkItem/NNNN which is NOT the epic's database ID.
1588
+ // GET /groups/:group/epics/:iid returns { id: <real numeric id>, ... }.
1589
+ let epicId = null;
1590
+ try {
1591
+ const epicRest = await glabApi(`groups/${encodeURIComponent(group)}/epics/${epic.iid}`);
1592
+ epicId = epicRest.id ?? null;
1593
+ } catch {
1594
+ // If REST fetch fails (e.g. group path mismatch), fall back to iid
1595
+ }
1596
+ const issueFields = {
1597
+ title: issueTitle.trim(),
1598
+ ...(epicId ? { epic_id: epicId } : { epic_iid: epic.iid }),
1599
+ };
1600
+
1601
+ if (issueDesc.trim()) issueFields.description = issueDesc.trim();
1602
+ if (issueLabels.trim()) issueFields.labels = issueLabels.trim();
1603
+ if (portalConfig.defaultMilestone?.id) issueFields.milestone_id = portalConfig.defaultMilestone.id;
1604
+ if (resolvedIterationId) issueFields.iteration_id = resolvedIterationId;
1605
+
1606
+ const enc = encodeURIComponent(projectChoice.projectPath);
1607
+ process.stdout.write(" " + p.muted("Creating issue…\r"));
1608
+ let issue;
1609
+ try {
1610
+ issue = await glabApi(`projects/${enc}/issues`, { method: "POST", fields: issueFields });
1611
+ process.stdout.write(" ".repeat(40) + "\r");
1612
+ } catch (e) {
1613
+ process.stdout.write(" ".repeat(40) + "\r");
1614
+ console.log(
1615
+ boxen(
1616
+ chalk.bold(p.red("Failed to create issue")) + "\n\n" + p.muted(e.message.slice(0, 100)),
1617
+ {
1618
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1619
+ borderStyle: "round",
1620
+ borderColor: "#f87171",
1621
+ },
1622
+ ),
1623
+ );
1624
+ console.log();
1625
+ continue issueLoop;
1626
+ }
1627
+
1628
+ console.log(
1629
+ boxen(
1630
+ chalk.bold(p.green(`✔ Issue #${issue.iid} created`)) + "\n " + p.muted(issue.web_url ?? ""),
1631
+ {
1632
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1633
+ borderStyle: "round",
1634
+ borderColor: "#4ade80",
1635
+ },
1636
+ ),
1637
+ );
1638
+ console.log();
1639
+
1640
+ // 7. Create branch
1641
+ const wantBranch = await confirm({
1642
+ message: p.white("Create branch") + p.muted(" for this issue?"),
1643
+ default: true,
1644
+ theme: THEME,
1645
+ });
1646
+
1647
+ if (wantBranch) {
1648
+ const defaultBranchName = `feature/${issue.iid}-${slugify(issueTitle)}`;
1649
+
1650
+ // Use enquirer's Input so `initial` goes into the edit buffer —
1651
+ // @inquirer/prompts `default` is a hint that backspace wipes instantly.
1652
+ const { branchName } = await enquirer.prompt({
1653
+ type: "input",
1654
+ name: "branchName",
1655
+ message: p.white("Branch name:"),
1656
+ initial: defaultBranchName,
1657
+ validate: (v) => v.trim() !== "" || "Branch name cannot be empty",
1658
+ });
1659
+
1660
+ const { baseBranchName } = await enquirer.prompt({
1661
+ type: "input",
1662
+ name: "baseBranchName",
1663
+ message: p.white("Base branch:"),
1664
+ initial: portalConfig.defaultBaseBranch ?? "develop",
1665
+ validate: (v) => v.trim() !== "" || "Base branch cannot be empty",
1666
+ });
1667
+
1668
+ process.stdout.write(" " + p.muted("Creating branch…\r"));
1669
+ try {
1670
+ await glabApi(`projects/${enc}/repository/branches`, {
1671
+ method: "POST",
1672
+ fields: { branch: branchName.trim(), ref: baseBranchName.trim() },
1673
+ });
1674
+ process.stdout.write(" ".repeat(40) + "\r");
1675
+ console.log(
1676
+ boxen(
1677
+ chalk.bold(p.green("✔ Branch created")) + " " + colorBranch(branchName.trim()),
1678
+ {
1679
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1680
+ borderStyle: "round",
1681
+ borderColor: "#4ade80",
1682
+ },
1683
+ ),
1684
+ );
1685
+ // Auto-set the new branch as primary for this issue
1686
+ const cfgNow = loadConfig();
1687
+ const pbNow = { ...(cfgNow.portal?.primaryBranches ?? {}), [`${projectChoice.projectPath}#${issue.iid}`]: branchName.trim() };
1688
+ saveConfig({ ...cfgNow, portal: { ...cfgNow.portal, primaryBranches: pbNow } });
1689
+ } catch (e) {
1690
+ process.stdout.write(" ".repeat(40) + "\r");
1691
+ console.log(
1692
+ boxen(
1693
+ chalk.bold(p.red("Branch creation failed")) + "\n\n" + p.muted(e.message.slice(0, 80)),
1694
+ {
1695
+ padding: { top: 0, bottom: 0, left: 2, right: 2 },
1696
+ borderStyle: "round",
1697
+ borderColor: "#f87171",
1698
+ },
1699
+ ),
1700
+ );
1701
+ }
1702
+ }
1703
+
1704
+ console.log();
1705
+ // Loop back to issueLoop → re-fetches issues and shows action menu
1706
+ }
1707
+ } // portalFlow — user went back from epic selection, continue portalHome
1708
+ } // portalHome
1709
+
1710
+ return 0;
1711
+ }