cawdev-cli 1.0.10-beta → 1.1.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/README.md +11 -1
- package/lib/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +31 -8
- package/lib/stage-tools.mjs +2 -0
- package/mcp/README.md +44 -30
- package/mcp/server.mjs +113 -49
- package/package.json +1 -1
- package/runner/README.md +29 -27
- package/runner/attach.mjs +19 -8
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +324 -117
- package/runner/split.mjs +46 -6
- package/runner/workspace-check.mjs +78 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The git host's side of the runner — R379.
|
|
2
|
+
//
|
|
3
|
+
// The runner has only ever spoken to GitHub, through `gh`. A GitLab project
|
|
4
|
+
// could be pushed to but not opened, found or merged against, and a run's link
|
|
5
|
+
// to its pull request came back empty. This is the one place that knows there
|
|
6
|
+
// are two: `adapterFor('github')` is the `gh` calls the daemon always made,
|
|
7
|
+
// moved here without change; `adapterFor('gitlab')` is the same questions asked
|
|
8
|
+
// of `glab`.
|
|
9
|
+
//
|
|
10
|
+
// No dependencies, and in `tools/runner/` rather than `tools/lib/` on purpose:
|
|
11
|
+
// this half of the tree is open-licence and must not import the other.
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
// --- the repository --------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `{ host, path }` for an origin URL, or null when it is not one we can read.
|
|
19
|
+
* `git@h:a/b/c.git`, `https://h/a/b/c(.git)` and `ssh://git@h:2222/a/b.git` —
|
|
20
|
+
* `path` is the repository's path on the host without `.git`, subgroups kept.
|
|
21
|
+
*/
|
|
22
|
+
export function repoOf(originUrl) {
|
|
23
|
+
const url = (originUrl ?? '').trim();
|
|
24
|
+
if (!url) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const scp = /^[\w.-]+@([\w.-]+):(?!\/\/)(.+?)(?:\.git)?\/?$/.exec(url);
|
|
28
|
+
if (scp) {
|
|
29
|
+
return { host: scp[1], path: scp[2].replace(/^\/+/, '') };
|
|
30
|
+
}
|
|
31
|
+
const full = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(url);
|
|
32
|
+
if (full) {
|
|
33
|
+
return { host: full[1], path: full[2] };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which host's CLI to use: `'github'` or `'gitlab'`.
|
|
40
|
+
*
|
|
41
|
+
* A host the project DECLARED wins; otherwise a host name containing `gitlab` is
|
|
42
|
+
* GitLab; otherwise GitHub, which is what the runner has always assumed — so a
|
|
43
|
+
* project that never set anything behaves exactly as it did.
|
|
44
|
+
*/
|
|
45
|
+
export function hostFor({ declared = null, originUrl = null } = {}) {
|
|
46
|
+
const said = String(declared ?? '').trim().toUpperCase();
|
|
47
|
+
if (said === 'GITLAB') return 'gitlab';
|
|
48
|
+
if (said === 'GITHUB') return 'github';
|
|
49
|
+
const repo = repoOf(originUrl);
|
|
50
|
+
return repo && /gitlab/i.test(repo.host) ? 'gitlab' : 'github';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A link somebody can click to open a pull or merge request themselves. It
|
|
55
|
+
* always contains `/compare/` — the platform tells a real pull request from this
|
|
56
|
+
* by that, and nothing else.
|
|
57
|
+
*/
|
|
58
|
+
export function compareUrl(host, repo, base, branch) {
|
|
59
|
+
if (!repo) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const name = encodeURIComponent(branch);
|
|
63
|
+
if (host === 'gitlab') {
|
|
64
|
+
return `https://${repo.host}/${repo.path}/-/compare/${encodeURIComponent(base || 'main')}...${name}`;
|
|
65
|
+
}
|
|
66
|
+
return `https://${repo.host}/${repo.path}/compare/${name}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The merge request URL a `glab mr create` printed, or null. */
|
|
70
|
+
export function mrUrlIn(text) {
|
|
71
|
+
const found = String(text ?? '').match(/https?:\/\/\S+?\/-\/merge_requests\/\d+/g);
|
|
72
|
+
return found ? found[found.length - 1] : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The iid at the end of a merge request URL. */
|
|
76
|
+
export function iidOf(url) {
|
|
77
|
+
return /\/merge_requests\/(\d+)/.exec(url ?? '')?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const GITLAB_WAIT = new Set(['checking', 'unchecked', 'preparing', 'ci_still_running']);
|
|
81
|
+
const GITLAB_BLOCKED = new Set([
|
|
82
|
+
'not_approved', 'blocked_status', 'ci_must_pass', 'discussions_not_resolved',
|
|
83
|
+
'draft_status', 'not_open', 'requested_changes',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `glab mr view --output json` says about whether a merge request can
|
|
88
|
+
* merge: `CONFLICT`, `UNKNOWN` (the host has not computed it — wait),
|
|
89
|
+
* `NOT_PERMITTED` or `KNOWN`.
|
|
90
|
+
*/
|
|
91
|
+
export function gitlabMergeability(view) {
|
|
92
|
+
if (!view || typeof view !== 'object') {
|
|
93
|
+
return 'KNOWN';
|
|
94
|
+
}
|
|
95
|
+
const status = String(view.detailed_merge_status ?? '').toLowerCase();
|
|
96
|
+
if (view.has_conflicts === true || status === 'conflict') return 'CONFLICT';
|
|
97
|
+
if (GITLAB_WAIT.has(status)) return 'UNKNOWN';
|
|
98
|
+
if (GITLAB_BLOCKED.has(status)) return 'NOT_PERMITTED';
|
|
99
|
+
return 'KNOWN';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- running a CLI ---------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Runs `bin` and hands back everything it said, including on failure. */
|
|
105
|
+
export function runCli(bin, cwd, args) {
|
|
106
|
+
return new Promise((resolvePromise) => {
|
|
107
|
+
const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
108
|
+
let out = '';
|
|
109
|
+
let err = '';
|
|
110
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
111
|
+
child.stderr.on('data', (chunk) => (err += chunk));
|
|
112
|
+
child.on('error', (failure) => resolvePromise({
|
|
113
|
+
code: -1,
|
|
114
|
+
out: '',
|
|
115
|
+
err: failure.code === 'ENOENT'
|
|
116
|
+
? `${bin} is not installed on this machine, so it cannot talk to the git host.`
|
|
117
|
+
: failure.message,
|
|
118
|
+
}));
|
|
119
|
+
// `close`, not `exit`: stdout may still hold data when the process exits.
|
|
120
|
+
child.on('close', (code) => resolvePromise({ code, out: out.trim(), err: err.trim() }));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parse(text) {
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text || '{}');
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- the adapters ----------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const github = {
|
|
135
|
+
name: 'github',
|
|
136
|
+
bin: 'gh',
|
|
137
|
+
noun: 'pull request',
|
|
138
|
+
installHint: 'brew install gh && gh auth login',
|
|
139
|
+
|
|
140
|
+
run: (cwd, args) => runCli('gh', cwd, args),
|
|
141
|
+
|
|
142
|
+
/** The URL of the pull request for a branch, or null. */
|
|
143
|
+
async find(cwd, branch) {
|
|
144
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', branch, '--json', 'url', '--jq', '.url']);
|
|
145
|
+
return asked.code === 0 && asked.out ? asked.out : null;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
create: (cwd, { branch, base, title, body }) => runCli('gh', cwd, [
|
|
149
|
+
'pr', 'create',
|
|
150
|
+
'--head', branch,
|
|
151
|
+
...(base ? ['--base', base] : []),
|
|
152
|
+
'--title', title,
|
|
153
|
+
'--body', body,
|
|
154
|
+
]),
|
|
155
|
+
|
|
156
|
+
/** The URL a successful create printed. */
|
|
157
|
+
urlOf: (created) => created.out.split('\n').pop().trim(),
|
|
158
|
+
|
|
159
|
+
/** `'UNKNOWN'` while the host has not computed it; anything else is "stop waiting". */
|
|
160
|
+
async mergeability(cwd, url) {
|
|
161
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
162
|
+
if (asked.code !== 0) return 'KNOWN';
|
|
163
|
+
const view = parse(asked.out);
|
|
164
|
+
return view && view.mergeable === 'UNKNOWN' ? 'UNKNOWN' : 'KNOWN';
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
merge: (cwd, url, { mergeCommit }) => runCli('gh', cwd,
|
|
168
|
+
['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']),
|
|
169
|
+
|
|
170
|
+
/** Why a merge was refused, when the host can say: `CONFLICT`, `NOT_PERMITTED` or null. */
|
|
171
|
+
async refusal(cwd, url) {
|
|
172
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
173
|
+
if (asked.code !== 0) return null;
|
|
174
|
+
const view = parse(asked.out);
|
|
175
|
+
if (!view) return null;
|
|
176
|
+
if (view.mergeable === 'CONFLICTING' || view.mergeStateStatus === 'DIRTY') return 'CONFLICT';
|
|
177
|
+
if (view.mergeStateStatus === 'BLOCKED') return 'NOT_PERMITTED';
|
|
178
|
+
return null;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/** The compare link fallback — only for github.com, as it always was. */
|
|
182
|
+
fallbackLink(repo, _base, branch) {
|
|
183
|
+
return repo && repo.host === 'github.com' ? compareUrl('github', repo, null, branch) : null;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// `glab mr merge` queues an auto-merge by default on versions that have the
|
|
188
|
+
// flag; asked once per process whether this glab does.
|
|
189
|
+
let glabHasAutoMerge = null;
|
|
190
|
+
|
|
191
|
+
async function autoMergeFlag(cwd) {
|
|
192
|
+
if (glabHasAutoMerge === null) {
|
|
193
|
+
const help = await runCli('glab', cwd, ['mr', 'merge', '--help']);
|
|
194
|
+
const has = /--auto-merge/.test(`${help.out}\n${help.err}`);
|
|
195
|
+
// Only a real answer is remembered: glab not installed yet, or a failed first
|
|
196
|
+
// call, must not decide for the life of the daemon.
|
|
197
|
+
if (help.code !== 0) {
|
|
198
|
+
return has ? ['--auto-merge=false'] : [];
|
|
199
|
+
}
|
|
200
|
+
glabHasAutoMerge = has;
|
|
201
|
+
}
|
|
202
|
+
return glabHasAutoMerge ? ['--auto-merge=false'] : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const gitlab = {
|
|
206
|
+
name: 'gitlab',
|
|
207
|
+
bin: 'glab',
|
|
208
|
+
noun: 'merge request',
|
|
209
|
+
installHint: 'install glab and run `glab auth login --hostname <your GitLab host>`',
|
|
210
|
+
|
|
211
|
+
run: (cwd, args) => runCli('glab', cwd, args),
|
|
212
|
+
|
|
213
|
+
async find(cwd, branch) {
|
|
214
|
+
const asked = await runCli('glab', cwd, ['mr', 'list', '--source-branch', branch, '--output', 'json']);
|
|
215
|
+
if (asked.code !== 0) return null;
|
|
216
|
+
const rows = parse(asked.out);
|
|
217
|
+
const first = Array.isArray(rows) ? rows[0] : null;
|
|
218
|
+
return first?.web_url ?? null;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
create: (cwd, { branch, base, title, body }) => runCli('glab', cwd, [
|
|
222
|
+
'mr', 'create',
|
|
223
|
+
'--source-branch', branch,
|
|
224
|
+
...(base ? ['--target-branch', base] : []),
|
|
225
|
+
'--title', title,
|
|
226
|
+
'--description', body,
|
|
227
|
+
'--yes',
|
|
228
|
+
]),
|
|
229
|
+
|
|
230
|
+
urlOf: (created) => mrUrlIn(`${created.out}\n${created.err}`),
|
|
231
|
+
|
|
232
|
+
async mergeability(cwd, url) {
|
|
233
|
+
const iid = iidOf(url);
|
|
234
|
+
if (!iid) return 'KNOWN';
|
|
235
|
+
const asked = await runCli('glab', cwd, ['mr', 'view', iid, '--output', 'json']);
|
|
236
|
+
if (asked.code !== 0) return 'KNOWN';
|
|
237
|
+
return gitlabMergeability(parse(asked.out)) === 'UNKNOWN' ? 'UNKNOWN' : 'KNOWN';
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async merge(cwd, url, { mergeCommit }) {
|
|
241
|
+
const iid = iidOf(url) ?? url;
|
|
242
|
+
return runCli('glab', cwd, [
|
|
243
|
+
'mr', 'merge', iid, '--yes', '--remove-source-branch',
|
|
244
|
+
...(mergeCommit ? [] : ['--squash']),
|
|
245
|
+
...(await autoMergeFlag(cwd)),
|
|
246
|
+
]);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async refusal(cwd, url) {
|
|
250
|
+
const iid = iidOf(url);
|
|
251
|
+
if (!iid) return null;
|
|
252
|
+
const asked = await runCli('glab', cwd, ['mr', 'view', iid, '--output', 'json']);
|
|
253
|
+
if (asked.code !== 0) return null;
|
|
254
|
+
const verdict = gitlabMergeability(parse(asked.out));
|
|
255
|
+
return verdict === 'CONFLICT' || verdict === 'NOT_PERMITTED' ? verdict : null;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
fallbackLink: (repo, base, branch) => compareUrl('gitlab', repo, base, branch),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export function adapterFor(host) {
|
|
262
|
+
return host === 'gitlab' ? gitlab : github;
|
|
263
|
+
}
|