pan-stack-cli 0.1.5 → 0.2.1
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 +572 -24
- package/bin/pan.js +204 -21
- package/lib/ai-skills.js +829 -0
- package/lib/create.js +22 -4
- package/lib/discover.js +59 -1
- package/lib/git/git-checkout.js +395 -0
- package/lib/git/git-clone.js +162 -27
- package/lib/git/git-fetch-all.js +100 -33
- package/lib/git/git-grv.js +68 -37
- package/lib/git/git-gst.js +97 -26
- package/lib/git/git-mr.js +546 -0
- package/lib/git/git-pull.js +104 -43
- package/lib/git/git-push.js +150 -41
- package/lib/git/git-sync-main.js +42 -28
- package/lib/git/helpers/bulk-report.js +202 -0
- package/lib/git/helpers/cli.js +40 -5
- package/lib/git/helpers/manifest.js +93 -0
- package/lib/git/helpers/repo-select.js +328 -9
- package/lib/git/helpers/repo-summary.js +161 -0
- package/lib/i18n.js +61 -0
- package/lib/inquirer-theme.js +20 -0
- package/lib/language.js +121 -0
- package/lib/languages.js +14 -0
- package/lib/manifest.js +115 -14
- package/lib/menu-prompt.js +201 -0
- package/lib/menu.js +134 -0
- package/lib/messages.js +647 -0
- package/lib/run-command.js +173 -52
- package/lib/select.js +0 -0
- package/lib/serve.js +75 -0
- package/lib/skill-registry-client.js +173 -0
- package/lib/stats/aggregate.js +51 -0
- package/lib/stats/identity.js +20 -0
- package/lib/stats/index.js +189 -0
- package/lib/stats/parse.js +46 -0
- package/lib/stats/range.js +38 -0
- package/lib/stats/report.js +264 -0
- package/lib/stats/review.js +157 -0
- package/lib/stats/scan.js +92 -0
- package/lib/stats/serve.js +25 -0
- package/lib/ui/api.js +202 -0
- package/lib/ui/clone.js +115 -0
- package/lib/ui/config.js +182 -0
- package/lib/ui/diff.js +128 -0
- package/lib/ui/export-gate.js +79 -0
- package/lib/ui/gen-react-mobile-code.js +6 -0
- package/lib/ui/gen-react-web-code.js +75 -0
- package/lib/ui/index.js +89 -0
- package/lib/ui/init.js +252 -0
- package/lib/ui/login.js +128 -0
- package/lib/ui/logout.js +22 -0
- package/lib/ui/palettes.js +103 -0
- package/lib/ui/published-html.js +24 -0
- package/lib/ui/sync.js +137 -0
- package/lib/ui/workspace.js +213 -0
- package/package.json +10 -3
- package/templates/workspace/.claude/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.codex/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.cursor/skills/writing-guidelines/SKILL.md +0 -48
- package/templates/workspace/.pan.yaml +0 -25
- package/templates/workspace/.project.yaml +0 -15
- package/templates/workspace/README.md +0 -35
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Create GitLab Merge Requests for PAN Stack workspace + child repos.
|
|
4
|
+
*
|
|
5
|
+
* Reads repos from .project.yaml (same source as other pan git commands).
|
|
6
|
+
* Interactive multi-select picker when running in a TTY without an explicit
|
|
7
|
+
* selection. Non-interactive selection: pass "all", a single repo name, or a
|
|
8
|
+
* comma/space-separated list of repo names.
|
|
9
|
+
*
|
|
10
|
+
* Does NOT auto-push — run `pan push` first if HEAD is not on the remote yet.
|
|
11
|
+
* Skips repos with no commits ahead of origin/<target>.
|
|
12
|
+
*
|
|
13
|
+
* Token resolution order:
|
|
14
|
+
* 1. --token flag
|
|
15
|
+
* 2. GITLAB_TOKEN env var
|
|
16
|
+
* 3. GITLAB_TOKEN in <workspace>/.pan-env-build.yaml
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require("fs");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const https = require("https");
|
|
22
|
+
const {
|
|
23
|
+
looksLikeManifest,
|
|
24
|
+
loadManifestRepos,
|
|
25
|
+
printCommonOptions,
|
|
26
|
+
} = require("./helpers/cli");
|
|
27
|
+
const colors = require("./helpers/colors");
|
|
28
|
+
const { resolveMultiTargets } = require("./helpers/repo-select");
|
|
29
|
+
const { runGitCapture, getCurrentBranch, refExists } = require("./helpers/git");
|
|
30
|
+
const { t } = require("../i18n");
|
|
31
|
+
|
|
32
|
+
const ICON = {
|
|
33
|
+
ok: "✓",
|
|
34
|
+
exists: "=",
|
|
35
|
+
warn: "!",
|
|
36
|
+
skip: "-",
|
|
37
|
+
err: "✗",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TARGET_BRANCH = "main";
|
|
41
|
+
|
|
42
|
+
function printHelp() {
|
|
43
|
+
console.log(`usage: pan mr [target] [repo|all|repo1,repo2] [options]
|
|
44
|
+
|
|
45
|
+
Tạo GitLab Merge Request cho workspace + các repo trong .project.yaml.
|
|
46
|
+
Interactive multi-select khi chạy trong TTY và không truyền lựa chọn.
|
|
47
|
+
|
|
48
|
+
positional:
|
|
49
|
+
target Branch đích (default: ${DEFAULT_TARGET_BRANCH}). Vd: pan mr develop
|
|
50
|
+
repo|all|<list> Repo tạo MR: tên repo trong .project.yaml, "all",
|
|
51
|
+
hoặc danh sách phân tách bởi phẩy/space.
|
|
52
|
+
|
|
53
|
+
${printCommonOptions()}
|
|
54
|
+
--no-workspace Bỏ qua workspace root (chỉ repo con)
|
|
55
|
+
--gitlab-host HOST Override GitLab host (default: derive từ origin URL)
|
|
56
|
+
--token TOKEN Override GitLab token (default: env → .pan-env-build.yaml)
|
|
57
|
+
|
|
58
|
+
examples:
|
|
59
|
+
pan mr # target=main, chọn tương tác
|
|
60
|
+
pan mr develop # target=develop, chọn tương tác
|
|
61
|
+
pan mr main all # target=main, tất cả repo
|
|
62
|
+
pan mr main csdl-cc-backend # target=main, một repo
|
|
63
|
+
pan mr main csdl-cc-backend,csdl-cc-b-frontend
|
|
64
|
+
|
|
65
|
+
env:
|
|
66
|
+
GITLAB_TOKEN Personal Access Token (scope: api).
|
|
67
|
+
Fallback: đọc từ <workspace>/.pan-env-build.yaml.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseMrArgs(argv) {
|
|
71
|
+
const args = {
|
|
72
|
+
help: false,
|
|
73
|
+
manifest: "",
|
|
74
|
+
baseDir: ".",
|
|
75
|
+
dryRun: false,
|
|
76
|
+
noWorkspace: false,
|
|
77
|
+
gitlabHost: "",
|
|
78
|
+
token: "",
|
|
79
|
+
target: "",
|
|
80
|
+
selection: "",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const positional = [];
|
|
84
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
85
|
+
const arg = argv[index];
|
|
86
|
+
if (arg === "-h" || arg === "--help") {
|
|
87
|
+
args.help = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (arg === "--base-dir") {
|
|
91
|
+
args.baseDir = argv[++index] || ".";
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (arg === "--dry-run") {
|
|
95
|
+
args.dryRun = true;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (arg === "--no-workspace") {
|
|
99
|
+
args.noWorkspace = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg === "--gitlab-host") {
|
|
103
|
+
args.gitlabHost = argv[++index] || "";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (arg === "--token") {
|
|
107
|
+
args.token = argv[++index] || "";
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (arg.startsWith("-")) {
|
|
111
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
112
|
+
}
|
|
113
|
+
if (looksLikeManifest(arg) && !args.manifest) {
|
|
114
|
+
args.manifest = arg;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
positional.push(arg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (positional[0]) {
|
|
121
|
+
args.target = positional[0];
|
|
122
|
+
}
|
|
123
|
+
if (positional.length > 1) {
|
|
124
|
+
args.selection = positional.slice(1).join(" ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return args;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Env & URL helpers ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function loadGitlabToken(workspaceRoot, override) {
|
|
133
|
+
if (override) {
|
|
134
|
+
return override;
|
|
135
|
+
}
|
|
136
|
+
if (process.env.GITLAB_TOKEN) {
|
|
137
|
+
return process.env.GITLAB_TOKEN;
|
|
138
|
+
}
|
|
139
|
+
const envFile = path.join(workspaceRoot, ".pan-env-build.yaml");
|
|
140
|
+
if (!fs.existsSync(envFile)) {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
const content = fs.readFileSync(envFile, "utf8");
|
|
144
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
145
|
+
let line = rawLine;
|
|
146
|
+
const commentIdx = line.indexOf("#");
|
|
147
|
+
if (commentIdx >= 0) {
|
|
148
|
+
line = line.slice(0, commentIdx);
|
|
149
|
+
}
|
|
150
|
+
line = line.trim();
|
|
151
|
+
if (!line) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const idx = line.indexOf(":");
|
|
155
|
+
if (idx < 0) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const key = line.slice(0, idx).trim();
|
|
159
|
+
let val = line.slice(idx + 1).trim();
|
|
160
|
+
if (
|
|
161
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
162
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
163
|
+
) {
|
|
164
|
+
val = val.slice(1, -1);
|
|
165
|
+
}
|
|
166
|
+
if (key === "GITLAB_TOKEN" && val) {
|
|
167
|
+
return val;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractProjectPath(originUrl) {
|
|
174
|
+
return String(originUrl || "")
|
|
175
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
176
|
+
.replace(/^git@[^:]+:/, "")
|
|
177
|
+
.replace(/\.git$/, "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractGitlabHost(originUrl) {
|
|
181
|
+
const httpsMatch = String(originUrl || "").match(/^https?:\/\/([^/]+)/);
|
|
182
|
+
if (httpsMatch) {
|
|
183
|
+
return httpsMatch[1];
|
|
184
|
+
}
|
|
185
|
+
const sshMatch = String(originUrl || "").match(/^git@([^:]+):/);
|
|
186
|
+
if (sshMatch) {
|
|
187
|
+
return sshMatch[1];
|
|
188
|
+
}
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getOriginUrl(directory) {
|
|
193
|
+
const result = runGitCapture(["remote", "get-url", "origin"], {
|
|
194
|
+
cwd: directory,
|
|
195
|
+
});
|
|
196
|
+
if (result.status !== 0) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
return result.stdout;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function apiRequest(host, method, urlPath, token, body) {
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const payload = body != null ? Buffer.from(body, "utf8") : null;
|
|
205
|
+
const options = {
|
|
206
|
+
hostname: host,
|
|
207
|
+
port: 443,
|
|
208
|
+
path: urlPath,
|
|
209
|
+
method,
|
|
210
|
+
headers: {
|
|
211
|
+
"PRIVATE-TOKEN": token,
|
|
212
|
+
"User-Agent": "pan-stack-cli",
|
|
213
|
+
Accept: "application/json",
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
if (payload) {
|
|
217
|
+
options.headers["Content-Type"] = "application/json";
|
|
218
|
+
options.headers["Content-Length"] = payload.length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const req = https.request(options, (res) => {
|
|
222
|
+
const chunks = [];
|
|
223
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
224
|
+
res.on("end", () => {
|
|
225
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
226
|
+
resolve({ status: res.statusCode || 0, body: text });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
req.on("error", (err) => reject(err));
|
|
230
|
+
if (payload) {
|
|
231
|
+
req.write(payload);
|
|
232
|
+
}
|
|
233
|
+
req.end();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Per-repo processing ────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function safeJsonParse(text) {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(text);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function truncate(text, max) {
|
|
248
|
+
const clean = String(text || "").replace(/\s+/g, " ").trim();
|
|
249
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function processRepo({
|
|
253
|
+
name,
|
|
254
|
+
destination,
|
|
255
|
+
target,
|
|
256
|
+
token,
|
|
257
|
+
gitlabHostOverride,
|
|
258
|
+
dryRun,
|
|
259
|
+
}) {
|
|
260
|
+
const record = { name, status: "", message: "", url: "" };
|
|
261
|
+
|
|
262
|
+
if (!fs.existsSync(path.join(destination, ".git"))) {
|
|
263
|
+
return { ...record, status: "skip", message: t("MR_MSG_NOT_CLONED") };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const originUrl = getOriginUrl(destination);
|
|
267
|
+
if (!originUrl) {
|
|
268
|
+
return { ...record, status: "skip", message: t("MR_MSG_NO_ORIGIN") };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const projPath = extractProjectPath(originUrl);
|
|
272
|
+
const host = gitlabHostOverride || extractGitlabHost(originUrl);
|
|
273
|
+
if (!host) {
|
|
274
|
+
return {
|
|
275
|
+
...record,
|
|
276
|
+
status: "err",
|
|
277
|
+
message: t("MR_MSG_HOST_PARSE__URL", { url: originUrl }),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const branch = getCurrentBranch(destination);
|
|
282
|
+
if (!branch) {
|
|
283
|
+
return { ...record, status: "skip", message: t("MR_MSG_HEAD_DETACHED") };
|
|
284
|
+
}
|
|
285
|
+
if (branch === target) {
|
|
286
|
+
return {
|
|
287
|
+
...record,
|
|
288
|
+
status: "skip",
|
|
289
|
+
message: t("MR_MSG_ON_TARGET__TARGET", { target }),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log(colors.dim(`→ [${name}] fetch origin...`));
|
|
294
|
+
runGitCapture(["fetch", "--quiet", "origin"], { cwd: destination });
|
|
295
|
+
|
|
296
|
+
if (!refExists(destination, `refs/remotes/origin/${target}`)) {
|
|
297
|
+
return {
|
|
298
|
+
...record,
|
|
299
|
+
status: "err",
|
|
300
|
+
message: t("MR_MSG_ORIGIN_MISSING__TARGET", { target }),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const aheadTargetResult = runGitCapture(
|
|
305
|
+
["rev-list", "--count", `origin/${target}..HEAD`],
|
|
306
|
+
{ cwd: destination }
|
|
307
|
+
);
|
|
308
|
+
const aheadTarget = Number.parseInt(aheadTargetResult.stdout || "0", 10) || 0;
|
|
309
|
+
if (aheadTarget === 0) {
|
|
310
|
+
return {
|
|
311
|
+
...record,
|
|
312
|
+
status: "skip",
|
|
313
|
+
message: t("MR_MSG_NO_AHEAD__TARGET", { target }),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!refExists(destination, `refs/remotes/origin/${branch}`)) {
|
|
318
|
+
return {
|
|
319
|
+
...record,
|
|
320
|
+
status: "warn",
|
|
321
|
+
message: t("MR_MSG_BRANCH_MISSING_REMOTE__BRANCH", { branch }),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const aheadRemoteResult = runGitCapture(
|
|
325
|
+
["rev-list", "--count", `origin/${branch}..HEAD`],
|
|
326
|
+
{ cwd: destination }
|
|
327
|
+
);
|
|
328
|
+
const aheadRemote = Number.parseInt(aheadRemoteResult.stdout || "0", 10) || 0;
|
|
329
|
+
if (aheadRemote > 0) {
|
|
330
|
+
return {
|
|
331
|
+
...record,
|
|
332
|
+
status: "warn",
|
|
333
|
+
message: t("MR_MSG_HEAD_AHEAD__BRANCH_N", { branch, n: aheadRemote }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const apiBase = `/api/v4/projects/${encodeURIComponent(projPath)}`;
|
|
338
|
+
const listQuery =
|
|
339
|
+
`${apiBase}/merge_requests?state=opened` +
|
|
340
|
+
`&source_branch=${encodeURIComponent(branch)}` +
|
|
341
|
+
`&target_branch=${encodeURIComponent(target)}`;
|
|
342
|
+
|
|
343
|
+
if (dryRun) {
|
|
344
|
+
return {
|
|
345
|
+
...record,
|
|
346
|
+
status: "skip",
|
|
347
|
+
message: t("MR_MSG_DRY_RUN_GET__URL", { url: `https://${host}${listQuery}` }),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let listRes;
|
|
352
|
+
try {
|
|
353
|
+
listRes = await apiRequest(host, "GET", listQuery, token);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
return { ...record, status: "err", message: t("MR_MSG_GET_ERROR__MSG", { msg: err.message }) };
|
|
356
|
+
}
|
|
357
|
+
if (listRes.status !== 200) {
|
|
358
|
+
return {
|
|
359
|
+
...record,
|
|
360
|
+
status: "err",
|
|
361
|
+
message: t("MR_MSG_GET_HTTP__STATUS_BODY", {
|
|
362
|
+
status: listRes.status, body: truncate(listRes.body, 200),
|
|
363
|
+
}),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const list = safeJsonParse(listRes.body);
|
|
367
|
+
if (Array.isArray(list) && list.length > 0 && list[0].web_url) {
|
|
368
|
+
return {
|
|
369
|
+
...record,
|
|
370
|
+
status: "exists",
|
|
371
|
+
url: list[0].web_url,
|
|
372
|
+
message: list[0].web_url,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const titleResult = runGitCapture(
|
|
377
|
+
["log", "--reverse", "--pretty=%s", `origin/${target}..HEAD`],
|
|
378
|
+
{ cwd: destination }
|
|
379
|
+
);
|
|
380
|
+
const firstTitle = (titleResult.stdout || "").split(/\r?\n/)[0] || "";
|
|
381
|
+
const title = firstTitle || `Merge ${branch} → ${target}`;
|
|
382
|
+
const descResult = runGitCapture(
|
|
383
|
+
["log", "--pretty=- %s (%h)", `origin/${target}..HEAD`],
|
|
384
|
+
{ cwd: destination }
|
|
385
|
+
);
|
|
386
|
+
const description = descResult.stdout || "";
|
|
387
|
+
|
|
388
|
+
const body = JSON.stringify({
|
|
389
|
+
source_branch: branch,
|
|
390
|
+
target_branch: target,
|
|
391
|
+
title,
|
|
392
|
+
description,
|
|
393
|
+
remove_source_branch: false,
|
|
394
|
+
squash: false,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
let createRes;
|
|
398
|
+
try {
|
|
399
|
+
createRes = await apiRequest(
|
|
400
|
+
host,
|
|
401
|
+
"POST",
|
|
402
|
+
`${apiBase}/merge_requests`,
|
|
403
|
+
token,
|
|
404
|
+
body
|
|
405
|
+
);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
return { ...record, status: "err", message: t("MR_MSG_POST_ERROR__MSG", { msg: err.message }) };
|
|
408
|
+
}
|
|
409
|
+
if (createRes.status !== 201) {
|
|
410
|
+
return {
|
|
411
|
+
...record,
|
|
412
|
+
status: "err",
|
|
413
|
+
message: t("MR_MSG_POST_HTTP__STATUS_BODY", {
|
|
414
|
+
status: createRes.status, body: truncate(createRes.body, 300),
|
|
415
|
+
}),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const created = safeJsonParse(createRes.body);
|
|
419
|
+
if (created && created.web_url) {
|
|
420
|
+
return {
|
|
421
|
+
...record,
|
|
422
|
+
status: "ok",
|
|
423
|
+
url: created.web_url,
|
|
424
|
+
message: created.web_url,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return { ...record, status: "err", message: t("MR_MSG_NO_WEB_URL") };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Reporting ──────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function styleForStatus(status) {
|
|
433
|
+
switch (status) {
|
|
434
|
+
case "ok":
|
|
435
|
+
return { icon: ICON.ok, suffix: t("MR_LABEL_NEW"), color: colors.green };
|
|
436
|
+
case "exists":
|
|
437
|
+
return { icon: ICON.exists, suffix: t("MR_LABEL_EXISTS"), color: colors.cyan };
|
|
438
|
+
case "warn":
|
|
439
|
+
return { icon: ICON.warn, suffix: "", color: colors.yellow };
|
|
440
|
+
case "skip":
|
|
441
|
+
return { icon: ICON.skip, suffix: "", color: colors.dim };
|
|
442
|
+
case "err":
|
|
443
|
+
return { icon: ICON.err, suffix: "", color: colors.red };
|
|
444
|
+
default:
|
|
445
|
+
return { icon: "?", suffix: "", color: (text) => text };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function printSummary(results, { target }) {
|
|
450
|
+
const nameWidth = results.reduce(
|
|
451
|
+
(max, item) => Math.max(max, item.name.length),
|
|
452
|
+
0
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
console.log("");
|
|
456
|
+
console.log(colors.bold(t("MR_RESULT_HEADER__TARGET", { target })));
|
|
457
|
+
for (const result of results) {
|
|
458
|
+
const { icon, suffix, color } = styleForStatus(result.status);
|
|
459
|
+
const line = `${icon} ${result.name.padEnd(nameWidth)} ${result.message}${suffix}`;
|
|
460
|
+
console.log(color(line));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const created = results.filter((r) => r.status === "ok").length;
|
|
464
|
+
const existing = results.filter((r) => r.status === "exists").length;
|
|
465
|
+
const warned = results.filter((r) => r.status === "warn").length;
|
|
466
|
+
const skipped = results.filter((r) => r.status === "skip").length;
|
|
467
|
+
const errored = results.filter((r) => r.status === "err").length;
|
|
468
|
+
|
|
469
|
+
console.log("");
|
|
470
|
+
console.log(colors.dim(t("MR_LEGEND")));
|
|
471
|
+
console.log(
|
|
472
|
+
colors.bold(
|
|
473
|
+
t("MR_SUMMARY__OK_EXISTS_WARN_SKIP_ERR", {
|
|
474
|
+
ok: created, exists: existing, warn: warned, skip: skipped, err: errored,
|
|
475
|
+
})
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── Main ───────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
async function main() {
|
|
483
|
+
const args = parseMrArgs(process.argv.slice(2));
|
|
484
|
+
if (args.help) {
|
|
485
|
+
printHelp();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const target = (args.target || DEFAULT_TARGET_BRANCH).trim();
|
|
490
|
+
|
|
491
|
+
const { repos, workspaceRoot } = loadManifestRepos({
|
|
492
|
+
manifestPath: args.manifest,
|
|
493
|
+
baseDir: args.baseDir,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const token = loadGitlabToken(workspaceRoot, args.token);
|
|
497
|
+
if (!token) {
|
|
498
|
+
console.error(t("MR_MISSING_TOKEN"));
|
|
499
|
+
console.error(t("MR_MISSING_TOKEN_1"));
|
|
500
|
+
console.error(t("MR_MISSING_TOKEN_2__PATH", { path: workspaceRoot }));
|
|
501
|
+
console.error(t("MR_MISSING_TOKEN_URL"));
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const targets = await resolveMultiTargets({
|
|
506
|
+
repos,
|
|
507
|
+
workspaceRoot,
|
|
508
|
+
includeWorkspace: !args.noWorkspace,
|
|
509
|
+
selection: args.selection,
|
|
510
|
+
action: t("MR_SELECT_ACTION__TARGET", { target }),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
console.log("");
|
|
514
|
+
console.log(
|
|
515
|
+
colors.bold(t("MR_HEADER__TARGET_N", { target, n: targets.length }))
|
|
516
|
+
);
|
|
517
|
+
for (const target1 of targets) {
|
|
518
|
+
console.log(colors.dim(` • ${target1.name} (${target1.destination})`));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const results = [];
|
|
522
|
+
for (const target1 of targets) {
|
|
523
|
+
console.log("");
|
|
524
|
+
const result = await processRepo({
|
|
525
|
+
name: target1.name,
|
|
526
|
+
destination: target1.destination,
|
|
527
|
+
target,
|
|
528
|
+
token,
|
|
529
|
+
gitlabHostOverride: args.gitlabHost,
|
|
530
|
+
dryRun: args.dryRun,
|
|
531
|
+
});
|
|
532
|
+
results.push(result);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
printSummary(results, { target });
|
|
536
|
+
|
|
537
|
+
const hasError = results.some(
|
|
538
|
+
(item) => item.status === "err" || item.status === "warn"
|
|
539
|
+
);
|
|
540
|
+
process.exit(hasError ? 1 : 0);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
main().catch((error) => {
|
|
544
|
+
console.error(error.message);
|
|
545
|
+
process.exit(1);
|
|
546
|
+
});
|