@goodtek/vibeops 2.4.3 → 2.5.0
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/CHANGELOG.md +20 -0
- package/README.md +10 -10
- package/dist/cli.js +9 -29
- package/dist/commands/init.js +43 -21
- package/dist/commands/status.js +91 -72
- package/dist/commands/task-merge.js +23 -5
- package/dist/commands/task-release.js +19 -9
- package/dist/commands/task-sync.js +5 -3
- package/dist/lib/git-host-cli.js +115 -0
- package/dist/lib/git-remote.js +177 -59
- package/dist/lib/merge-request-readiness.js +57 -0
- package/dist/lib/pr-create.js +103 -12
- package/dist/lib/task-context.js +28 -16
- package/dist/lib/task-push-pr.js +6 -2
- package/dist/lib/task-reship.js +1 -1
- package/package.json +14 -12
- package/templates/core/AGENTS.md +1 -1
- package/templates/core/docs/project/06-decisions.md +1 -1
- package/dist/commands/task-reship.js +0 -21
|
@@ -61,7 +61,7 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
61
61
|
log.info(` · git fetch ${remote} --prune`);
|
|
62
62
|
log.info(` · git switch ${integrationBranch}`);
|
|
63
63
|
log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
|
|
64
|
-
log.info(` · git branch
|
|
64
|
+
log.info(` · git branch -D ${taskBranch}`);
|
|
65
65
|
if (options.noRemoteDelete !== true) {
|
|
66
66
|
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
67
67
|
}
|
|
@@ -133,13 +133,15 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
133
133
|
log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
|
|
134
134
|
if (await gitBranchExists(cwd, taskBranch)) {
|
|
135
135
|
try {
|
|
136
|
-
|
|
136
|
+
// Guard verified integration contains the work — squash merges leave task/* SHAs
|
|
137
|
+
// off develop ancestry, so -D is required even without --force.
|
|
138
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
137
139
|
log.ok(`Deleted local branch ${taskBranch}`);
|
|
138
140
|
}
|
|
139
141
|
catch (e) {
|
|
140
142
|
const msg = e instanceof Error ? e.message : String(e);
|
|
141
143
|
log.error(`Could not delete local ${taskBranch}: ${msg}`);
|
|
142
|
-
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (
|
|
144
|
+
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (skip merge verification)."));
|
|
143
145
|
process.exitCode = 1;
|
|
144
146
|
return;
|
|
145
147
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** Run a CLI with inherited stdio (e.g. `gh auth login`). */
|
|
5
|
+
export function runInteractiveCli(bin, args) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(bin, [...args], { stdio: "inherit" });
|
|
8
|
+
child.on("error", reject);
|
|
9
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function hostCliTool(host) {
|
|
13
|
+
return host === "gitlab" ? "glab" : "gh";
|
|
14
|
+
}
|
|
15
|
+
export async function commandExists(bin) {
|
|
16
|
+
try {
|
|
17
|
+
await execFileAsync(bin, ["--version"], { maxBuffer: 1024 });
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function probeGhAuth() {
|
|
25
|
+
try {
|
|
26
|
+
await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function probeGlabAuth() {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function probeHostCliAuth(host) {
|
|
43
|
+
const tool = hostCliTool(host);
|
|
44
|
+
if (!(await commandExists(tool)))
|
|
45
|
+
return "missing";
|
|
46
|
+
const ok = host === "gitlab" ? await probeGlabAuth() : await probeGhAuth();
|
|
47
|
+
return ok ? "ok" : "unauthenticated";
|
|
48
|
+
}
|
|
49
|
+
/** Install / login lines shared by init and ship/merge. */
|
|
50
|
+
export function formatHostCliHint(host) {
|
|
51
|
+
if (host === "gitlab") {
|
|
52
|
+
return [
|
|
53
|
+
`Install glab: https://gitlab.com/gitlab-org/cli#installation`,
|
|
54
|
+
` brew install glab`,
|
|
55
|
+
`Then: glab auth login`,
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
return [
|
|
59
|
+
`Install gh: https://cli.github.com/`,
|
|
60
|
+
` brew install gh`,
|
|
61
|
+
`Then: gh auth login`,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
export function formatHostCliMissingMessage(host, purpose) {
|
|
65
|
+
const tool = hostCliTool(host);
|
|
66
|
+
return `${tool} CLI is required to ${purpose}.`;
|
|
67
|
+
}
|
|
68
|
+
export async function probeBrew() {
|
|
69
|
+
return commandExists("brew");
|
|
70
|
+
}
|
|
71
|
+
export async function brewInstall(pkg) {
|
|
72
|
+
await execFileAsync("brew", ["install", pkg], {
|
|
73
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Parse `owner/repo`, HTTPS, or SSH GitHub/GitLab URLs. */
|
|
77
|
+
export function parseOwnerRepo(input) {
|
|
78
|
+
const raw = input.trim();
|
|
79
|
+
if (raw.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
const slug = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
|
|
82
|
+
const fromSlug = slug.exec(raw);
|
|
83
|
+
if (fromSlug && !raw.includes(":") && !raw.includes("@")) {
|
|
84
|
+
return { owner: fromSlug[1], repo: fromSlug[2] };
|
|
85
|
+
}
|
|
86
|
+
const https = /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(raw);
|
|
87
|
+
if (https) {
|
|
88
|
+
return { owner: https[1], repo: https[2].replace(/\.git$/i, "") };
|
|
89
|
+
}
|
|
90
|
+
const ssh = /^(?:git@|ssh:\/\/git@)(?:github\.com|gitlab\.com)[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i.exec(raw);
|
|
91
|
+
if (ssh) {
|
|
92
|
+
return { owner: ssh[1], repo: ssh[2].replace(/\.git$/i, "") };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
export function remoteUrlForHost(host, owner, repo) {
|
|
97
|
+
if (host === "gitlab") {
|
|
98
|
+
return `https://gitlab.com/${owner}/${repo}.git`;
|
|
99
|
+
}
|
|
100
|
+
return `https://github.com/${owner}/${repo}.git`;
|
|
101
|
+
}
|
|
102
|
+
export async function repoExistsOnHost(host, owner, repo) {
|
|
103
|
+
const slug = `${owner}/${repo}`;
|
|
104
|
+
try {
|
|
105
|
+
if (host === "gitlab") {
|
|
106
|
+
await execFileAsync("glab", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
await execFileAsync("gh", ["repo", "view", slug], { maxBuffer: 1024 * 1024 });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/lib/git-remote.js
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
4
|
+
import { brewInstall, formatHostCliHint, formatHostCliMissingMessage, hostCliTool, parseOwnerRepo, probeBrew, probeHostCliAuth, remoteUrlForHost, repoExistsOnHost, runInteractiveCli, } from "./git-host-cli.js";
|
|
4
5
|
import { detectGitHost } from "./git-host.js";
|
|
5
6
|
import { gitRemoteUrl, runGit } from "./git.js";
|
|
6
|
-
import {
|
|
7
|
+
import { askInput, askSelect, askYesNo } from "./inquirer-helpers.js";
|
|
8
|
+
import { dim, log, yellow } from "./logger.js";
|
|
7
9
|
const execFileAsync = promisify(execFile);
|
|
8
|
-
async function probeGh() {
|
|
9
|
-
try {
|
|
10
|
-
await execFileAsync("gh", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
11
|
-
return true;
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
async function probeGlab() {
|
|
18
|
-
try {
|
|
19
|
-
await execFileAsync("glab", ["auth", "status"], { maxBuffer: 1024 * 1024 });
|
|
20
|
-
return true;
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
10
|
async function createGithubRepo(cwd, name) {
|
|
27
|
-
const { stdout } = await execFileAsync("gh",
|
|
28
|
-
// gh requires an explicit visibility flag when non-interactive.
|
|
29
|
-
["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
11
|
+
const { stdout } = await execFileAsync("gh", ["repo", "create", name, "--source=.", "--remote=origin", "--push", "--private"], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
30
12
|
const match = stdout.match(/https:\/\/github\.com\/[^\s]+/);
|
|
31
13
|
if (match)
|
|
32
14
|
return match[0].replace(/\.git$/, "") + ".git";
|
|
@@ -42,8 +24,135 @@ async function createGitlabRepo(cwd, name, defaultBranch) {
|
|
|
42
24
|
return url;
|
|
43
25
|
throw new Error("glab repo create finished but origin URL could not be read.");
|
|
44
26
|
}
|
|
27
|
+
async function addRemoteUrl(cwd, remote, url) {
|
|
28
|
+
const host = detectGitHost(url);
|
|
29
|
+
if (!host) {
|
|
30
|
+
throw new Error("Only GitHub and GitLab HTTPS/SSH URLs are supported.");
|
|
31
|
+
}
|
|
32
|
+
await runGit(cwd, ["remote", "add", remote, url.trim()]);
|
|
33
|
+
return { remote, url: url.trim(), host };
|
|
34
|
+
}
|
|
35
|
+
async function resolveConnectUrl(host, input) {
|
|
36
|
+
const trimmed = input.trim();
|
|
37
|
+
const parsed = parseOwnerRepo(trimmed);
|
|
38
|
+
if (parsed) {
|
|
39
|
+
if (trimmed.startsWith("http") || trimmed.startsWith("git@") || trimmed.startsWith("ssh://")) {
|
|
40
|
+
return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
|
|
41
|
+
}
|
|
42
|
+
return remoteUrlForHost(host, parsed.owner, parsed.repo);
|
|
43
|
+
}
|
|
44
|
+
if (detectGitHost(trimmed)) {
|
|
45
|
+
return trimmed.endsWith(".git") ? trimmed : `${trimmed.replace(/\/$/, "")}.git`;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Could not parse remote: ${trimmed}`);
|
|
48
|
+
}
|
|
49
|
+
async function repairMissingCli(host) {
|
|
50
|
+
const tool = hostCliTool(host);
|
|
51
|
+
const status = await probeHostCliAuth(host);
|
|
52
|
+
log.warn(status === "missing"
|
|
53
|
+
? formatHostCliMissingMessage(host, "create a remote repository")
|
|
54
|
+
: `${tool} is installed but not logged in.`);
|
|
55
|
+
for (const line of formatHostCliHint(host)) {
|
|
56
|
+
log.info(dim(` ${line}`));
|
|
57
|
+
}
|
|
58
|
+
const brewOk = status === "missing" ? await probeBrew() : false;
|
|
59
|
+
const choices = [
|
|
60
|
+
"I installed / logged in — retry",
|
|
61
|
+
...(brewOk ? [`Run brew install ${tool}`] : []),
|
|
62
|
+
"Enter remote URL manually",
|
|
63
|
+
"Skip remote for now",
|
|
64
|
+
];
|
|
65
|
+
// Loop so brew install / retry can re-prompt.
|
|
66
|
+
for (;;) {
|
|
67
|
+
const pick = await askSelect({
|
|
68
|
+
message: "How do you want to continue?",
|
|
69
|
+
nonInteractive: false,
|
|
70
|
+
choices,
|
|
71
|
+
});
|
|
72
|
+
if (pick === "I installed / logged in — retry") {
|
|
73
|
+
const again = await probeHostCliAuth(host);
|
|
74
|
+
if (again === "ok")
|
|
75
|
+
return "retry";
|
|
76
|
+
log.warn(again === "missing"
|
|
77
|
+
? `${tool} still not found on PATH.`
|
|
78
|
+
: `${tool} still not authenticated. Run \`${tool} auth login\`.`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (pick.startsWith("Run brew install")) {
|
|
82
|
+
log.info(dim(`Running: brew install ${tool}`));
|
|
83
|
+
try {
|
|
84
|
+
await brewInstall(tool);
|
|
85
|
+
log.ok(`Installed ${tool}`);
|
|
86
|
+
log.info(dim(`Next: ${tool} auth login`));
|
|
87
|
+
const loginNow = await askYesNo({
|
|
88
|
+
message: `Run \`${tool} auth login\` now?`,
|
|
89
|
+
nonInteractive: false,
|
|
90
|
+
defaultValue: true,
|
|
91
|
+
});
|
|
92
|
+
if (loginNow) {
|
|
93
|
+
try {
|
|
94
|
+
await runInteractiveCli(tool, ["auth", "login"]);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* user may cancel */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
103
|
+
log.warn(`brew install failed: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (pick === "Enter remote URL manually")
|
|
108
|
+
return "manual";
|
|
109
|
+
return "skip";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function connectExisting(cwd, remote, host) {
|
|
113
|
+
const input = await askInput({
|
|
114
|
+
message: "Repository URL or owner/repo",
|
|
115
|
+
nonInteractive: false,
|
|
116
|
+
required: true,
|
|
117
|
+
});
|
|
118
|
+
const url = await resolveConnectUrl(host, input);
|
|
119
|
+
const parsed = parseOwnerRepo(url) ?? parseOwnerRepo(input);
|
|
120
|
+
if (parsed && (await probeHostCliAuth(host)) === "ok") {
|
|
121
|
+
const exists = await repoExistsOnHost(host, parsed.owner, parsed.repo);
|
|
122
|
+
if (!exists) {
|
|
123
|
+
log.warn(yellow(`Could not verify ${parsed.owner}/${parsed.repo} on ${host} (missing or private). Continuing with remote add.`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return addRemoteUrl(cwd, remote, url);
|
|
127
|
+
}
|
|
128
|
+
async function createOnHost(cwd, remote, host, defaultBranch) {
|
|
129
|
+
const folder = basename(cwd);
|
|
130
|
+
const label = host === "gitlab" ? "GitLab project path (group/project)" : "GitHub repository (owner/repo or name)";
|
|
131
|
+
const name = await askInput({
|
|
132
|
+
message: label,
|
|
133
|
+
nonInteractive: false,
|
|
134
|
+
required: true,
|
|
135
|
+
default: folder,
|
|
136
|
+
});
|
|
137
|
+
log.info(dim(`Creating ${host} repository via ${hostCliTool(host)}…`));
|
|
138
|
+
if (host === "gitlab") {
|
|
139
|
+
const url = await createGitlabRepo(cwd, name, defaultBranch);
|
|
140
|
+
// glab may name remote differently; ensure our remote name.
|
|
141
|
+
const current = await gitRemoteUrl(cwd, remote);
|
|
142
|
+
if (!current) {
|
|
143
|
+
const origin = await gitRemoteUrl(cwd, "origin");
|
|
144
|
+
if (origin && remote !== "origin") {
|
|
145
|
+
await runGit(cwd, ["remote", "rename", "origin", remote]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { remote, url, host };
|
|
149
|
+
}
|
|
150
|
+
const url = await createGithubRepo(cwd, name);
|
|
151
|
+
return { remote, url, host };
|
|
152
|
+
}
|
|
45
153
|
/**
|
|
46
|
-
* Ensures `origin` (or named remote) exists.
|
|
154
|
+
* Ensures `origin` (or named remote) exists.
|
|
155
|
+
* Interactive: ask host → create/connect; soft-gate when CLI missing.
|
|
47
156
|
*/
|
|
48
157
|
export async function ensureOriginRemote(opts) {
|
|
49
158
|
const remote = opts.remoteName ?? "origin";
|
|
@@ -59,8 +168,9 @@ export async function ensureOriginRemote(opts) {
|
|
|
59
168
|
return { remote, url: existing, host };
|
|
60
169
|
}
|
|
61
170
|
if (opts.dryRun) {
|
|
62
|
-
|
|
63
|
-
|
|
171
|
+
const host = opts.preferredHost ?? "github";
|
|
172
|
+
log.info(dim(`would configure git remote ${remote} (${host})`));
|
|
173
|
+
return { remote, url: "(dry-run)", host };
|
|
64
174
|
}
|
|
65
175
|
if (opts.allowMissing) {
|
|
66
176
|
return null;
|
|
@@ -69,40 +179,48 @@ export async function ensureOriginRemote(opts) {
|
|
|
69
179
|
throw new Error(`Git remote "${remote}" is not configured. Add it with: git remote add ${remote} <url>`);
|
|
70
180
|
}
|
|
71
181
|
log.blank();
|
|
72
|
-
log.info("Remote
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
message: "GitLab project path (group/project)",
|
|
88
|
-
nonInteractive: false,
|
|
89
|
-
required: true,
|
|
90
|
-
});
|
|
91
|
-
log.info(dim("Creating GitLab project via glab…"));
|
|
92
|
-
const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
|
|
93
|
-
? opts.defaultBranch
|
|
94
|
-
: "main";
|
|
95
|
-
const url = await createGitlabRepo(opts.cwd, name, defaultBranch);
|
|
96
|
-
return { remote, url, host: "gitlab" };
|
|
182
|
+
log.info("Remote setup — used later for pull requests / merge requests.");
|
|
183
|
+
const hostPick = await askSelect({
|
|
184
|
+
message: "Where will this project's remote live?",
|
|
185
|
+
nonInteractive: false,
|
|
186
|
+
choices: ["GitHub", "GitLab", "Skip for now (local only)"],
|
|
187
|
+
default: opts.preferredHost === "gitlab"
|
|
188
|
+
? "GitLab"
|
|
189
|
+
: opts.preferredHost === "github"
|
|
190
|
+
? "GitHub"
|
|
191
|
+
: undefined,
|
|
192
|
+
});
|
|
193
|
+
if (hostPick.startsWith("Skip")) {
|
|
194
|
+
log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
|
|
195
|
+
log.info(dim(` Later: git remote add ${remote} <url>`));
|
|
196
|
+
return null;
|
|
97
197
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
198
|
+
const host = hostPick === "GitLab" ? "gitlab" : "github";
|
|
199
|
+
const mode = await askSelect({
|
|
200
|
+
message: "Set up the remote",
|
|
100
201
|
nonInteractive: false,
|
|
101
|
-
|
|
202
|
+
choices: ["Create a new repository", "Connect an existing repository"],
|
|
102
203
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
204
|
+
if (mode.startsWith("Connect")) {
|
|
205
|
+
return connectExisting(opts.cwd, remote, host);
|
|
206
|
+
}
|
|
207
|
+
// Create path — requires authenticated CLI (soft gate).
|
|
208
|
+
for (;;) {
|
|
209
|
+
const auth = await probeHostCliAuth(host);
|
|
210
|
+
if (auth === "ok") {
|
|
211
|
+
const defaultBranch = typeof opts.defaultBranch === "string" && opts.defaultBranch.length > 0
|
|
212
|
+
? opts.defaultBranch
|
|
213
|
+
: "main";
|
|
214
|
+
return createOnHost(opts.cwd, remote, host, defaultBranch);
|
|
215
|
+
}
|
|
216
|
+
const action = await repairMissingCli(host);
|
|
217
|
+
if (action === "retry")
|
|
218
|
+
continue;
|
|
219
|
+
if (action === "manual") {
|
|
220
|
+
return connectExisting(opts.cwd, remote, host);
|
|
221
|
+
}
|
|
222
|
+
log.warn("No git remote configured. task ship push/MR will fail until origin exists.");
|
|
223
|
+
log.info(dim(` Later: git remote add ${remote} <url>`));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
108
226
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const ACTIVE_PIPELINE_STATUSES = new Set([
|
|
2
|
+
"pending",
|
|
3
|
+
"running",
|
|
4
|
+
"created",
|
|
5
|
+
"waiting_for_resource",
|
|
6
|
+
"preparing",
|
|
7
|
+
]);
|
|
8
|
+
const TERMINAL_PIPELINE_FAILURES = new Set(["failed", "canceled"]);
|
|
9
|
+
function normalizePipelineStatus(raw) {
|
|
10
|
+
if (raw === null || raw === undefined || raw.trim().length === 0)
|
|
11
|
+
return "none";
|
|
12
|
+
const status = raw.trim().toLowerCase();
|
|
13
|
+
if (status === "success")
|
|
14
|
+
return "success";
|
|
15
|
+
if (status === "skipped")
|
|
16
|
+
return "skipped";
|
|
17
|
+
if (status === "failed")
|
|
18
|
+
return "failed";
|
|
19
|
+
if (status === "canceled" || status === "cancelled")
|
|
20
|
+
return "canceled";
|
|
21
|
+
if (ACTIVE_PIPELINE_STATUSES.has(status))
|
|
22
|
+
return "running";
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
export function pipelineStatusFromHost(raw) {
|
|
26
|
+
return normalizePipelineStatus(raw);
|
|
27
|
+
}
|
|
28
|
+
export function isPipelineActive(status) {
|
|
29
|
+
return status === "running" || status === "pending";
|
|
30
|
+
}
|
|
31
|
+
/** True when the host reports the MR can be merged now (CI done or not required). */
|
|
32
|
+
export function isMergeRequestReadyToMerge(readiness) {
|
|
33
|
+
if (readiness.state === "merged")
|
|
34
|
+
return true;
|
|
35
|
+
if (readiness.state !== "open")
|
|
36
|
+
return false;
|
|
37
|
+
if (readiness.hasConflicts === true)
|
|
38
|
+
return false;
|
|
39
|
+
const detailed = readiness.detailedMergeStatus?.trim().toLowerCase() ?? "";
|
|
40
|
+
if (detailed === "conflict" ||
|
|
41
|
+
detailed === "not_approved" ||
|
|
42
|
+
detailed === "draft_status" ||
|
|
43
|
+
detailed === "ci_still_running" ||
|
|
44
|
+
detailed === "checking" ||
|
|
45
|
+
detailed === "not_open" ||
|
|
46
|
+
detailed === "discussions_not_resolved" ||
|
|
47
|
+
detailed === "need_rebase" ||
|
|
48
|
+
detailed === "blocked_status") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (TERMINAL_PIPELINE_FAILURES.has(readiness.pipelineStatus))
|
|
52
|
+
return false;
|
|
53
|
+
if (isPipelineActive(readiness.pipelineStatus))
|
|
54
|
+
return false;
|
|
55
|
+
const mergeStatus = readiness.mergeStatus?.trim().toLowerCase() ?? "";
|
|
56
|
+
return mergeStatus === "can_be_merged" || detailed === "mergeable";
|
|
57
|
+
}
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { mergeRequestLabel } from "./git-host.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isMergeRequestReadyToMerge, isPipelineActive, pipelineStatusFromHost, } from "./merge-request-readiness.js";
|
|
5
|
+
import { dim, log } from "./logger.js";
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
async function ghPrCreate(opts) {
|
|
7
8
|
const { stdout } = await execFileAsync("gh", [
|
|
@@ -64,6 +65,7 @@ export async function probeMergeRequestCli(host) {
|
|
|
64
65
|
return false;
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
export { isMergeRequestReadyToMerge, isPipelineActive };
|
|
67
69
|
function normalizeGhMergeRequestState(raw) {
|
|
68
70
|
const state = raw.trim().toUpperCase();
|
|
69
71
|
if (state === "MERGED")
|
|
@@ -202,15 +204,35 @@ export async function getMergeRequestDetails(cwd, host, url) {
|
|
|
202
204
|
mergedAt: parsed.merged_at ?? null,
|
|
203
205
|
mergeCommitSha: parsed.merge_commit_sha ?? null,
|
|
204
206
|
squashCommitSha: parsed.squash_commit_sha ?? null,
|
|
207
|
+
mergeStatus: parsed.merge_status ?? null,
|
|
208
|
+
detailedMergeStatus: parsed.detailed_merge_status ?? null,
|
|
209
|
+
pipelineStatus: pipelineStatusFromHost(parsed.head_pipeline?.status),
|
|
210
|
+
hasConflicts: parsed.has_conflicts ?? null,
|
|
205
211
|
};
|
|
206
212
|
}
|
|
207
|
-
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit"], { cwd, maxBuffer: 1024 * 1024 });
|
|
213
|
+
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit,mergeable,statusCheckRollup"], { cwd, maxBuffer: 1024 * 1024 });
|
|
208
214
|
const parsed = JSON.parse(stdout.trim());
|
|
215
|
+
const checks = parsed.statusCheckRollup ?? [];
|
|
216
|
+
let pipelineStatus = "none";
|
|
217
|
+
if (checks.some((c) => (c.state ?? "").toUpperCase() === "PENDING")) {
|
|
218
|
+
pipelineStatus = "running";
|
|
219
|
+
}
|
|
220
|
+
else if (checks.some((c) => (c.state ?? "").toUpperCase() === "FAILURE")) {
|
|
221
|
+
pipelineStatus = "failed";
|
|
222
|
+
}
|
|
223
|
+
else if (checks.length > 0) {
|
|
224
|
+
pipelineStatus = "success";
|
|
225
|
+
}
|
|
226
|
+
const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
|
|
209
227
|
return {
|
|
210
228
|
state: normalizeGhMergeRequestState(parsed.state ?? ""),
|
|
211
229
|
mergedAt: parsed.mergedAt ?? null,
|
|
212
230
|
mergeCommitSha: parsed.mergeCommit?.oid ?? null,
|
|
213
231
|
squashCommitSha: null,
|
|
232
|
+
mergeStatus: mergeable === "MERGEABLE" ? "can_be_merged" : mergeable.toLowerCase(),
|
|
233
|
+
detailedMergeStatus: mergeable.toLowerCase(),
|
|
234
|
+
pipelineStatus,
|
|
235
|
+
hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
|
|
214
236
|
};
|
|
215
237
|
}
|
|
216
238
|
catch {
|
|
@@ -231,6 +253,42 @@ export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
|
|
|
231
253
|
}
|
|
232
254
|
return false;
|
|
233
255
|
}
|
|
256
|
+
function mergeRequestReadinessFromDetails(details) {
|
|
257
|
+
if (details === null)
|
|
258
|
+
return null;
|
|
259
|
+
return {
|
|
260
|
+
state: details.state,
|
|
261
|
+
mergeStatus: details.mergeStatus,
|
|
262
|
+
detailedMergeStatus: details.detailedMergeStatus,
|
|
263
|
+
pipelineStatus: details.pipelineStatus,
|
|
264
|
+
hasConflicts: details.hasConflicts,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** Poll until MR/PR CI finished and host reports mergeable (or already merged). */
|
|
268
|
+
export async function waitForMergeRequestReadyToMerge(cwd, host, url, options = {}) {
|
|
269
|
+
const timeoutMs = options.timeoutMs ?? 900_000;
|
|
270
|
+
const intervalMs = options.intervalMs ?? 5_000;
|
|
271
|
+
const deadline = Date.now() + timeoutMs;
|
|
272
|
+
const label = mergeRequestLabel(host);
|
|
273
|
+
let loggedWait = false;
|
|
274
|
+
while (Date.now() < deadline) {
|
|
275
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
276
|
+
const readiness = mergeRequestReadinessFromDetails(details);
|
|
277
|
+
if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (!loggedWait) {
|
|
281
|
+
log.info(dim(`Waiting for ${label} pipeline / merge checks…`));
|
|
282
|
+
loggedWait = true;
|
|
283
|
+
}
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function isMergeHttp405(error) {
|
|
289
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
290
|
+
return msg.includes("405") || msg.toLowerCase().includes("method not allowed");
|
|
291
|
+
}
|
|
234
292
|
export async function closeMergeRequest(opts) {
|
|
235
293
|
const ref = prRefFromUrl(opts.url);
|
|
236
294
|
const label = mergeRequestLabel(opts.host);
|
|
@@ -254,10 +312,19 @@ export async function mergeMergeRequest(opts) {
|
|
|
254
312
|
const ref = prRefFromUrl(opts.url);
|
|
255
313
|
const method = opts.method ?? "squash";
|
|
256
314
|
const label = mergeRequestLabel(opts.host);
|
|
315
|
+
if (opts.waitForCi === true && opts.dryRun !== true) {
|
|
316
|
+
const ready = await waitForMergeRequestReadyToMerge(opts.cwd, opts.host, opts.url);
|
|
317
|
+
if (!ready) {
|
|
318
|
+
throw new Error(`${label} is not ready to merge yet (pipeline running, checks pending, or timeout).`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const details = await getMergeRequestDetails(opts.cwd, opts.host, opts.url);
|
|
322
|
+
const readiness = mergeRequestReadinessFromDetails(details);
|
|
323
|
+
const pipelineActive = readiness !== null ? isPipelineActive(readiness.pipelineStatus) : false;
|
|
257
324
|
if (opts.dryRun === true) {
|
|
258
325
|
if (opts.host === "gitlab") {
|
|
259
|
-
const immediate = opts.immediate !== false;
|
|
260
|
-
const flags = immediate ? " --auto-merge=false" : "";
|
|
326
|
+
const immediate = opts.immediate !== false && !pipelineActive;
|
|
327
|
+
const flags = immediate ? " --auto-merge=false" : " (auto-merge when pipeline succeeds)";
|
|
261
328
|
const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
|
|
262
329
|
log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
|
|
263
330
|
}
|
|
@@ -267,22 +334,46 @@ export async function mergeMergeRequest(opts) {
|
|
|
267
334
|
return;
|
|
268
335
|
}
|
|
269
336
|
if (opts.host === "gitlab") {
|
|
270
|
-
|
|
271
|
-
|
|
337
|
+
await mergeGitLabMergeRequest({
|
|
338
|
+
cwd: opts.cwd,
|
|
339
|
+
ref,
|
|
340
|
+
method,
|
|
341
|
+
immediate: opts.immediate !== false && !pipelineActive,
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const args = ["pr", "merge", ref, `--${method}`];
|
|
346
|
+
await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
347
|
+
}
|
|
348
|
+
async function mergeGitLabMergeRequest(input) {
|
|
349
|
+
const attempt = async (immediate) => {
|
|
350
|
+
const args = ["mr", "merge", input.ref];
|
|
351
|
+
if (immediate) {
|
|
272
352
|
args.push("--auto-merge=false");
|
|
273
353
|
}
|
|
274
|
-
if (method === "squash") {
|
|
354
|
+
if (input.method === "squash") {
|
|
275
355
|
args.push("--squash");
|
|
276
356
|
}
|
|
277
|
-
else if (method === "rebase") {
|
|
357
|
+
else if (input.method === "rebase") {
|
|
278
358
|
args.push("--rebase");
|
|
279
359
|
}
|
|
280
360
|
await execFileAsync("glab", args, {
|
|
281
|
-
cwd:
|
|
361
|
+
cwd: input.cwd,
|
|
282
362
|
maxBuffer: 4 * 1024 * 1024,
|
|
283
363
|
});
|
|
284
|
-
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
await attempt(input.immediate);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (!input.immediate && isMergeHttp405(error)) {
|
|
370
|
+
await attempt(false);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (input.immediate && isMergeHttp405(error)) {
|
|
374
|
+
await attempt(false);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
285
378
|
}
|
|
286
|
-
const args = ["pr", "merge", ref, `--${method}`];
|
|
287
|
-
await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
288
379
|
}
|