@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.
- package/README.md +219 -0
- package/bin/gitmux.js +10 -0
- package/package.json +43 -0
- package/src/commands/fetch.js +76 -0
- package/src/commands/mr.js +484 -0
- package/src/commands/portal.js +1711 -0
- package/src/commands/settings.js +490 -0
- package/src/commands/status.js +86 -0
- package/src/commands/switch.js +296 -0
- package/src/config/index.js +22 -0
- package/src/constants.js +15 -0
- package/src/git/branches.js +68 -0
- package/src/git/core.js +67 -0
- package/src/git/templates.js +51 -0
- package/src/gitlab/api.js +46 -0
- package/src/gitlab/helpers.js +73 -0
- package/src/main.js +418 -0
- package/src/ui/colors.js +54 -0
- package/src/ui/print.js +177 -0
- package/src/ui/theme.js +26 -0
- package/src/utils/exec.js +12 -0
|
@@ -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
|
+
}
|