cawdev-cli 1.1.0 → 1.2.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/lib/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +4 -2
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +52 -1
- package/lib/version-files.mjs +99 -0
- package/mcp/README.md +5 -6
- package/mcp/server.mjs +183 -25
- package/package.json +1 -1
- package/runner/README.md +79 -0
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +681 -180
package/runner/git-host.mjs
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// The runner has only ever spoken to GitHub, through `gh`. A GitLab project
|
|
4
4
|
// could be pushed to but not opened, found or merged against, and a run's link
|
|
5
5
|
// to its pull request came back empty. This is the one place that knows there
|
|
6
|
-
// are
|
|
6
|
+
// are three: `adapterFor('github')` is the `gh` calls the daemon always made,
|
|
7
7
|
// moved here without change; `adapterFor('gitlab')` is the same questions asked
|
|
8
|
-
// of `glab
|
|
8
|
+
// of `glab`; `adapterFor('bitbucket')` (R565) asks Bitbucket Cloud's REST API
|
|
9
|
+
// with a token that lives on this machine (`configureBitbucket`), because
|
|
10
|
+
// Bitbucket has no CLI to lean on.
|
|
9
11
|
//
|
|
10
12
|
// No dependencies, and in `tools/runner/` rather than `tools/lib/` on purpose:
|
|
11
13
|
// this half of the tree is open-licence and must not import the other.
|
|
@@ -36,17 +38,20 @@ export function repoOf(originUrl) {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
|
-
* Which host
|
|
41
|
+
* Which host to talk to: `'github'`, `'gitlab'` or `'bitbucket'`.
|
|
40
42
|
*
|
|
41
43
|
* A host the project DECLARED wins; otherwise a host name containing `gitlab` is
|
|
42
|
-
* GitLab; otherwise GitHub, which is what the
|
|
44
|
+
* GitLab, `bitbucket.org` is Bitbucket; otherwise GitHub, which is what the
|
|
45
|
+
* runner has always assumed — so a
|
|
43
46
|
* project that never set anything behaves exactly as it did.
|
|
44
47
|
*/
|
|
45
48
|
export function hostFor({ declared = null, originUrl = null } = {}) {
|
|
46
49
|
const said = String(declared ?? '').trim().toUpperCase();
|
|
47
50
|
if (said === 'GITLAB') return 'gitlab';
|
|
48
51
|
if (said === 'GITHUB') return 'github';
|
|
52
|
+
if (said === 'BITBUCKET') return 'bitbucket';
|
|
49
53
|
const repo = repoOf(originUrl);
|
|
54
|
+
if (repo && /bitbucket\.org$/i.test(repo.host)) return 'bitbucket';
|
|
50
55
|
return repo && /gitlab/i.test(repo.host) ? 'gitlab' : 'github';
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -60,6 +65,9 @@ export function compareUrl(host, repo, base, branch) {
|
|
|
60
65
|
return null;
|
|
61
66
|
}
|
|
62
67
|
const name = encodeURIComponent(branch);
|
|
68
|
+
if (host === 'bitbucket') {
|
|
69
|
+
return `https://bitbucket.org/${repo.path}/branches/compare/${name}%0D${encodeURIComponent(base || 'main')}`;
|
|
70
|
+
}
|
|
63
71
|
if (host === 'gitlab') {
|
|
64
72
|
return `https://${repo.host}/${repo.path}/-/compare/${encodeURIComponent(base || 'main')}...${name}`;
|
|
65
73
|
}
|
|
@@ -77,6 +85,11 @@ export function iidOf(url) {
|
|
|
77
85
|
return /\/merge_requests\/(\d+)/.exec(url ?? '')?.[1] ?? null;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/** The pull request id at the end of a Bitbucket pull request URL. */
|
|
89
|
+
export function prIdOf(url) {
|
|
90
|
+
return /\/pull-requests\/(\d+)/.exec(url ?? '')?.[1] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
const GITLAB_WAIT = new Set(['checking', 'unchecked', 'preparing', 'ci_still_running']);
|
|
81
94
|
const GITLAB_BLOCKED = new Set([
|
|
82
95
|
'not_approved', 'blocked_status', 'ci_must_pass', 'discussions_not_resolved',
|
|
@@ -131,6 +144,8 @@ function parse(text) {
|
|
|
131
144
|
|
|
132
145
|
// --- the adapters ----------------------------------------------------------------
|
|
133
146
|
|
|
147
|
+
const STATES = { OPEN: 'OPEN', MERGED: 'MERGED', CLOSED: 'CLOSED' };
|
|
148
|
+
|
|
134
149
|
const github = {
|
|
135
150
|
name: 'github',
|
|
136
151
|
bin: 'gh',
|
|
@@ -178,6 +193,36 @@ const github = {
|
|
|
178
193
|
return null;
|
|
179
194
|
},
|
|
180
195
|
|
|
196
|
+
/** `{ state, mergedAt, mergeCommit }` for a pull request URL (or branch), or null. */
|
|
197
|
+
async view(cwd, target) {
|
|
198
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', target, '--json', 'state,mergedAt,mergeCommit']);
|
|
199
|
+
const parsed = asked.code === 0 ? parse(asked.out) : null;
|
|
200
|
+
if (!parsed?.state) return null;
|
|
201
|
+
return {
|
|
202
|
+
state: STATES[parsed.state] ?? null,
|
|
203
|
+
mergedAt: parsed.mergedAt ?? null,
|
|
204
|
+
mergeCommit: parsed.mergeCommit?.oid ?? null,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** Every pull request whose head is `branch`, in the shape all three hosts share. */
|
|
209
|
+
async listByHead(cwd, branch) {
|
|
210
|
+
const asked = await runCli('gh', cwd, ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '10', '--json',
|
|
211
|
+
'number,url,state,mergedAt,mergeCommit,baseRefName,headRefName,isCrossRepository']);
|
|
212
|
+
const rows = asked.code === 0 ? parse(asked.out) : null;
|
|
213
|
+
if (!Array.isArray(rows)) return null;
|
|
214
|
+
return rows.map((row) => ({
|
|
215
|
+
number: row.number,
|
|
216
|
+
url: row.url ?? null,
|
|
217
|
+
state: STATES[row.state] ?? null,
|
|
218
|
+
mergedAt: row.mergedAt ?? null,
|
|
219
|
+
mergeCommit: row.mergeCommit?.oid ?? null,
|
|
220
|
+
baseRefName: row.baseRefName ?? null,
|
|
221
|
+
headRefName: row.headRefName ?? null,
|
|
222
|
+
isCrossRepository: Boolean(row.isCrossRepository),
|
|
223
|
+
}));
|
|
224
|
+
},
|
|
225
|
+
|
|
181
226
|
/** The compare link fallback — only for github.com, as it always was. */
|
|
182
227
|
fallbackLink(repo, _base, branch) {
|
|
183
228
|
return repo && repo.host === 'github.com' ? compareUrl('github', repo, null, branch) : null;
|
|
@@ -255,9 +300,257 @@ const gitlab = {
|
|
|
255
300
|
return verdict === 'CONFLICT' || verdict === 'NOT_PERMITTED' ? verdict : null;
|
|
256
301
|
},
|
|
257
302
|
|
|
303
|
+
async view(cwd, target) {
|
|
304
|
+
const asked = await runCli('glab', cwd, ['mr', 'view', iidOf(target) ?? target, '--output', 'json']);
|
|
305
|
+
const row = asked.code === 0 ? parse(asked.out) : null;
|
|
306
|
+
if (!row?.state) return null;
|
|
307
|
+
const { state, mergedAt, mergeCommit } = gitlabRow(row);
|
|
308
|
+
return state ? { state, mergedAt, mergeCommit } : null;
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
async listByHead(cwd, branch) {
|
|
312
|
+
// `--all` is newer than some installs; without it, ask for merged and closed
|
|
313
|
+
// separately and join them to the open ones.
|
|
314
|
+
if (glabHasAll === null) {
|
|
315
|
+
const help = await runCli('glab', cwd, ['mr', 'list', '--help']);
|
|
316
|
+
if (help.code === 0) glabHasAll = /--all\b/.test(`${help.out}\n${help.err}`);
|
|
317
|
+
}
|
|
318
|
+
const ask = (...flags) => runCli('glab', cwd,
|
|
319
|
+
['mr', 'list', '--source-branch', branch, ...flags, '--output', 'json']);
|
|
320
|
+
const asked = glabHasAll === false ? null : await ask('--all');
|
|
321
|
+
const lists = asked
|
|
322
|
+
? [asked]
|
|
323
|
+
: await Promise.all([ask(), ask('--merged'), ask('--closed')]);
|
|
324
|
+
const rows = [];
|
|
325
|
+
for (const one of lists) {
|
|
326
|
+
const parsed = one.code === 0 ? parse(one.out) : null;
|
|
327
|
+
if (Array.isArray(parsed)) rows.push(...parsed);
|
|
328
|
+
}
|
|
329
|
+
if (!rows.length && lists.every((one) => one.code !== 0)) return null;
|
|
330
|
+
return rows.map((row) => gitlabRow(row));
|
|
331
|
+
},
|
|
332
|
+
|
|
258
333
|
fallbackLink: (repo, base, branch) => compareUrl('gitlab', repo, base, branch),
|
|
259
334
|
};
|
|
260
335
|
|
|
336
|
+
let glabHasAll = null;
|
|
337
|
+
|
|
338
|
+
/** A merge request as the shared row. A squash merge's commit is `squash_commit_sha`. */
|
|
339
|
+
function gitlabRow(row) {
|
|
340
|
+
return {
|
|
341
|
+
number: row.iid ?? null,
|
|
342
|
+
url: row.web_url ?? null,
|
|
343
|
+
state: { opened: 'OPEN', merged: 'MERGED', closed: 'CLOSED', locked: 'OPEN' }[row.state] ?? null,
|
|
344
|
+
mergedAt: row.merged_at ?? null,
|
|
345
|
+
mergeCommit: row.merge_commit_sha ?? row.squash_commit_sha ?? null,
|
|
346
|
+
baseRefName: row.target_branch ?? null,
|
|
347
|
+
headRefName: row.source_branch ?? null,
|
|
348
|
+
isCrossRepository: row.source_project_id != null && row.source_project_id !== row.target_project_id,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- Bitbucket Cloud --------------------------------------------------------------
|
|
353
|
+
//
|
|
354
|
+
// No CLI: the REST API, with a token this machine keeps (`configureBitbucket`,
|
|
355
|
+
// from the runner's own config). The platform never sees it, and the agent's
|
|
356
|
+
// environment has it removed (`agentEnv` in runner.mjs).
|
|
357
|
+
|
|
358
|
+
let bitbucketSettings = { email: null, token: null, apiUrl: 'https://api.bitbucket.org/2.0' };
|
|
359
|
+
|
|
360
|
+
/** Called once at boot, and by tests. `apiUrl` is only for tests. */
|
|
361
|
+
export function configureBitbucket({ email = null, token = null, apiUrl = null } = {}) {
|
|
362
|
+
bitbucketSettings = {
|
|
363
|
+
email: email || null,
|
|
364
|
+
token: token || null,
|
|
365
|
+
apiUrl: (apiUrl || 'https://api.bitbucket.org/2.0').replace(/\/+$/, ''),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const NO_TOKEN = 'Bitbucket needs an API token on this machine. Set "bitbucket": { "email": "…", '
|
|
370
|
+
+ '"tokenEnv": "BITBUCKET_TOKEN" } in this machine\'s cawdev config and export the token.';
|
|
371
|
+
|
|
372
|
+
/** `{ status, json, headers }`, or `{ status: -1, error }` when the call never landed. */
|
|
373
|
+
async function bitbucketCall(method, url, body) {
|
|
374
|
+
const { email, token } = bitbucketSettings;
|
|
375
|
+
const auth = email
|
|
376
|
+
? `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`
|
|
377
|
+
: `Bearer ${token}`;
|
|
378
|
+
try {
|
|
379
|
+
const response = await fetch(url, {
|
|
380
|
+
method,
|
|
381
|
+
headers: {
|
|
382
|
+
Authorization: auth,
|
|
383
|
+
Accept: 'application/json',
|
|
384
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
385
|
+
},
|
|
386
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
387
|
+
redirect: 'manual',
|
|
388
|
+
signal: AbortSignal.timeout(30_000),
|
|
389
|
+
});
|
|
390
|
+
const text = await response.text();
|
|
391
|
+
return { status: response.status, json: parse(text), headers: response.headers };
|
|
392
|
+
} catch (failure) {
|
|
393
|
+
return { status: -1, error: failure.message };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** `{ base, ws, repo }` for the checkout's origin, or an error result. */
|
|
398
|
+
async function bitbucketRepo(cwd) {
|
|
399
|
+
if (!bitbucketSettings.token) {
|
|
400
|
+
return { failure: { code: -1, out: '', err: NO_TOKEN } };
|
|
401
|
+
}
|
|
402
|
+
const origin = await runCli('git', cwd, ['remote', 'get-url', 'origin']);
|
|
403
|
+
const repo = origin.code === 0 ? repoOf(origin.out) : null;
|
|
404
|
+
const [ws, slug] = (repo?.path ?? '').split('/');
|
|
405
|
+
if (!ws || !slug) {
|
|
406
|
+
return { failure: { code: 1, out: '', err: 'Could not read the Bitbucket workspace and repository from origin.' } };
|
|
407
|
+
}
|
|
408
|
+
return { base: `${bitbucketSettings.apiUrl}/repositories/${ws}/${slug}` };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function bitbucketProblem(reply) {
|
|
412
|
+
if (reply.status === -1) return reply.error;
|
|
413
|
+
return reply.json?.error?.message ?? `Bitbucket answered ${reply.status}`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const BITBUCKET_STATES = { OPEN: 'OPEN', MERGED: 'MERGED', DECLINED: 'CLOSED', SUPERSEDED: 'CLOSED' };
|
|
417
|
+
|
|
418
|
+
function bitbucketRow(row) {
|
|
419
|
+
const state = BITBUCKET_STATES[row.state] ?? null;
|
|
420
|
+
return {
|
|
421
|
+
number: row.id ?? null,
|
|
422
|
+
url: row.links?.html?.href ?? null,
|
|
423
|
+
state,
|
|
424
|
+
mergedAt: state === 'MERGED' ? (row.updated_on ?? null) : null,
|
|
425
|
+
mergeCommit: row.merge_commit?.hash ?? null,
|
|
426
|
+
baseRefName: row.destination?.branch?.name ?? null,
|
|
427
|
+
headRefName: row.source?.branch?.name ?? null,
|
|
428
|
+
isCrossRepository: row.source?.repository?.full_name !== row.destination?.repository?.full_name
|
|
429
|
+
&& Boolean(row.source?.repository?.full_name),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A value inside a Bitbucket query's double quotes. */
|
|
434
|
+
const quoted = (value) => String(value).replace(/[\\"]/g, '\\$&');
|
|
435
|
+
|
|
436
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
437
|
+
|
|
438
|
+
const bitbucket = {
|
|
439
|
+
name: 'bitbucket',
|
|
440
|
+
bin: null,
|
|
441
|
+
noun: 'pull request',
|
|
442
|
+
installHint: 'set "bitbucket": { "email": "…", "tokenEnv": "BITBUCKET_TOKEN" } in this machine\'s cawdev config and export the token',
|
|
443
|
+
|
|
444
|
+
async run() {
|
|
445
|
+
return { code: -1, out: '', err: 'Bitbucket has no command line here; cawdev calls its API directly.' };
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
async find(cwd, branch) {
|
|
449
|
+
const where = await bitbucketRepo(cwd);
|
|
450
|
+
if (where.failure) return null;
|
|
451
|
+
const q = encodeURIComponent(`source.branch.name="${quoted(branch)}" AND state="OPEN"`);
|
|
452
|
+
const reply = await bitbucketCall('GET', `${where.base}/pullrequests?q=${q}`);
|
|
453
|
+
return reply.status === 200 ? (reply.json?.values?.[0]?.links?.html?.href ?? null) : null;
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
async create(cwd, { branch, base, title, body }) {
|
|
457
|
+
const where = await bitbucketRepo(cwd);
|
|
458
|
+
if (where.failure) return where.failure;
|
|
459
|
+
const reply = await bitbucketCall('POST', `${where.base}/pullrequests`, {
|
|
460
|
+
title,
|
|
461
|
+
description: body,
|
|
462
|
+
source: { branch: { name: branch } },
|
|
463
|
+
...(base ? { destination: { branch: { name: base } } } : {}),
|
|
464
|
+
close_source_branch: true,
|
|
465
|
+
});
|
|
466
|
+
if (reply.status === 201 && reply.json?.links?.html?.href) {
|
|
467
|
+
return { code: 0, out: reply.json.links.html.href, err: '' };
|
|
468
|
+
}
|
|
469
|
+
return { code: 1, out: '', err: bitbucketProblem(reply) };
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
urlOf: (created) => created.out,
|
|
473
|
+
|
|
474
|
+
/** Bitbucket does not compute mergeability lazily: there is nothing to wait for. */
|
|
475
|
+
async mergeability() {
|
|
476
|
+
return 'KNOWN';
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
async merge(cwd, url, { mergeCommit }) {
|
|
480
|
+
const where = await bitbucketRepo(cwd);
|
|
481
|
+
if (where.failure) return where.failure;
|
|
482
|
+
const id = prIdOf(url);
|
|
483
|
+
if (!id) return { code: 1, out: '', err: `Not a Bitbucket pull request URL: ${url}` };
|
|
484
|
+
const reply = await bitbucketCall('POST', `${where.base}/pullrequests/${id}/merge`, {
|
|
485
|
+
merge_strategy: mergeCommit ? 'merge_commit' : 'squash',
|
|
486
|
+
close_source_branch: true,
|
|
487
|
+
});
|
|
488
|
+
if (reply.status === 200) {
|
|
489
|
+
return { code: 0, out: reply.json?.links?.html?.href ?? url, err: '' };
|
|
490
|
+
}
|
|
491
|
+
if (reply.status !== 202) {
|
|
492
|
+
return { code: 1, out: '', err: bitbucketProblem(reply) };
|
|
493
|
+
}
|
|
494
|
+
// 202: the merge runs as a background task; its URL is in Location.
|
|
495
|
+
const task = reply.headers.get('location');
|
|
496
|
+
// Only ever follow it on the API we were talking to: the poll carries our token.
|
|
497
|
+
if (task && !task.startsWith(bitbucketSettings.apiUrl) && !task.startsWith(new URL(bitbucketSettings.apiUrl).origin)) {
|
|
498
|
+
return { code: 1, out: '', err: 'Bitbucket accepted the merge but pointed at a task on another host; not following it.' };
|
|
499
|
+
}
|
|
500
|
+
if (!task) return { code: 1, out: '', err: 'Bitbucket accepted the merge but gave no task to follow.' };
|
|
501
|
+
const wait = Number(process.env.CAWDEV_MERGEABILITY_WAIT_MS ?? 5000);
|
|
502
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
503
|
+
await sleep(wait);
|
|
504
|
+
const polled = await bitbucketCall('GET', task);
|
|
505
|
+
const status = polled.json?.task_status;
|
|
506
|
+
if (status === 'SUCCESS') return { code: 0, out: url, err: '' };
|
|
507
|
+
if (status === 'FAILED') {
|
|
508
|
+
return { code: 1, out: '', err: polled.json?.merge_result?.error?.message ?? polled.json?.error?.message ?? 'Bitbucket failed to merge the pull request.' };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { code: 1, out: '', err: 'Bitbucket is still merging this pull request; check it in a moment.' };
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
async refusal(cwd, url) {
|
|
515
|
+
const where = await bitbucketRepo(cwd);
|
|
516
|
+
const id = prIdOf(url);
|
|
517
|
+
if (where.failure || !id) return null;
|
|
518
|
+
const reply = await bitbucketCall('GET', `${where.base}/pullrequests/${id}/diffstat`);
|
|
519
|
+
const values = reply.status === 200 ? reply.json?.values : null;
|
|
520
|
+
return Array.isArray(values) && values.some((entry) => /conflict/i.test(entry?.status ?? ''))
|
|
521
|
+
? 'CONFLICT'
|
|
522
|
+
: null;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
async view(cwd, target) {
|
|
526
|
+
const where = await bitbucketRepo(cwd);
|
|
527
|
+
if (where.failure) return null;
|
|
528
|
+
const id = prIdOf(target);
|
|
529
|
+
if (!id) {
|
|
530
|
+
// A branch name, not a URL: the newest pull request from it.
|
|
531
|
+
const rows = await this.listByHead(cwd, target);
|
|
532
|
+
const newest = rows?.sort((a, b) => b.number - a.number)[0];
|
|
533
|
+
return newest ? { state: newest.state, mergedAt: newest.mergedAt, mergeCommit: newest.mergeCommit } : null;
|
|
534
|
+
}
|
|
535
|
+
const reply = await bitbucketCall('GET', `${where.base}/pullrequests/${id}`);
|
|
536
|
+
if (reply.status !== 200 || !reply.json) return null;
|
|
537
|
+
const row = bitbucketRow(reply.json);
|
|
538
|
+
return row.state ? { state: row.state, mergedAt: row.mergedAt, mergeCommit: row.mergeCommit } : null;
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
async listByHead(cwd, branch) {
|
|
542
|
+
const where = await bitbucketRepo(cwd);
|
|
543
|
+
if (where.failure) return null;
|
|
544
|
+
const q = encodeURIComponent(`source.branch.name="${quoted(branch)}"`);
|
|
545
|
+
const states = ['OPEN', 'MERGED', 'DECLINED'].map((state) => `state=${state}`).join('&');
|
|
546
|
+
const reply = await bitbucketCall('GET', `${where.base}/pullrequests?q=${q}&${states}&pagelen=10`);
|
|
547
|
+
if (reply.status !== 200 || !Array.isArray(reply.json?.values)) return null;
|
|
548
|
+
return reply.json.values.map(bitbucketRow);
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
fallbackLink: (repo, base, branch) => compareUrl('bitbucket', repo, base, branch),
|
|
552
|
+
};
|
|
553
|
+
|
|
261
554
|
export function adapterFor(host) {
|
|
262
|
-
return host === 'gitlab' ? gitlab : github;
|
|
555
|
+
return host === 'gitlab' ? gitlab : host === 'bitbucket' ? bitbucket : github;
|
|
263
556
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// What a queued run's note says when this machine is paused — R571.
|
|
2
|
+
//
|
|
3
|
+
// Pure, zero dependencies, so `node --test` pins the two sentences. There are
|
|
4
|
+
// two kinds of pause and they have different ways out: one a person made (R73:
|
|
5
|
+
// Settings → Machines, or `c` in an attached terminal) and one cawdev made for
|
|
6
|
+
// usage (R158: it lifts itself when the window resets, and a Resume would be
|
|
7
|
+
// undone within the minute while the machine is still over the line).
|
|
8
|
+
//
|
|
9
|
+
// The usage sentence is a second implementation of the console's
|
|
10
|
+
// (`runners/pause-words.ts`) across the zero-dependency boundary. Both read the
|
|
11
|
+
// server's `percent`, never recompute it.
|
|
12
|
+
|
|
13
|
+
/** The fullest whole window (no `model`) with a reading, or null. */
|
|
14
|
+
export function worstWindow(limits) {
|
|
15
|
+
const whole = (Array.isArray(limits) ? limits : [])
|
|
16
|
+
.filter((limit) => limit && !limit.model && Number.isFinite(limit.percent));
|
|
17
|
+
if (!whole.length) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return whole.reduce((worst, each) => (each.percent > worst.percent ? each : worst));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `1h 20m` · `12m` · `under a minute`. */
|
|
24
|
+
export function inWords(ms) {
|
|
25
|
+
if (!Number.isFinite(ms) || ms < 60_000) {
|
|
26
|
+
return 'under a minute';
|
|
27
|
+
}
|
|
28
|
+
const minutes = Math.floor(ms / 60_000);
|
|
29
|
+
if (minutes < 60) {
|
|
30
|
+
return `${minutes}m`;
|
|
31
|
+
}
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 24) {
|
|
34
|
+
return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
|
|
35
|
+
}
|
|
36
|
+
return hours % 24 ? `${Math.floor(hours / 24)}d ${hours % 24}h` : `${Math.floor(hours / 24)}d`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The queued line for a paused machine.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ pausedByUsage?: boolean, limits?: object[] }} machine what the heartbeat said
|
|
43
|
+
* @param {number} now milliseconds
|
|
44
|
+
*/
|
|
45
|
+
export function pausedNote({ pausedByUsage = false, limits = [] } = {}, now = Date.now()) {
|
|
46
|
+
if (!pausedByUsage) {
|
|
47
|
+
return 'this machine is paused from the console '
|
|
48
|
+
+ '(resume it on Settings → Machines, or press c)';
|
|
49
|
+
}
|
|
50
|
+
const worst = worstWindow(limits);
|
|
51
|
+
if (!worst) {
|
|
52
|
+
return 'cawdev paused this machine for usage — it resumes by itself when the window resets';
|
|
53
|
+
}
|
|
54
|
+
const name = worst.window === 'WEEKLY' ? 'weekly' : 'five-hour';
|
|
55
|
+
const resets = worst.resetsAt ? Date.parse(worst.resetsAt) : NaN;
|
|
56
|
+
const back = Number.isFinite(resets) ? `, back in ${inWords(resets - now)}` : '';
|
|
57
|
+
return `cawdev paused this machine for usage: its ${name} window is at ${worst.percent}%${back}`
|
|
58
|
+
+ ' — it resumes by itself';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The key a note changes on: the kind of pause, the window and its percent and
|
|
63
|
+
* reset — never the minutes left, which would change the sentence every
|
|
64
|
+
* heartbeat and make the log chatter.
|
|
65
|
+
*/
|
|
66
|
+
export function noteKey({ pausedByUsage = false, limits = [] } = {}) {
|
|
67
|
+
if (!pausedByUsage) {
|
|
68
|
+
return 'hand';
|
|
69
|
+
}
|
|
70
|
+
const worst = worstWindow(limits);
|
|
71
|
+
return worst ? `usage:${worst.window}:${worst.percent}:${worst.resetsAt ?? ''}` : 'usage';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The heartbeat's change line, or null when nothing about the pause moved.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ paused: boolean, pausedByUsage: boolean }} before
|
|
78
|
+
* @param {{ paused?: boolean, pausedByUsage?: boolean }} after
|
|
79
|
+
*/
|
|
80
|
+
export function pauseLogLine(before, after) {
|
|
81
|
+
const paused = !!after.paused;
|
|
82
|
+
const byUsage = paused && !!after.pausedByUsage;
|
|
83
|
+
if (before.paused === paused && before.pausedByUsage === byUsage) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!paused) {
|
|
87
|
+
return before.pausedByUsage
|
|
88
|
+
? 'cawdev lifted the usage pause: claiming again'
|
|
89
|
+
: 'unpaused from the console';
|
|
90
|
+
}
|
|
91
|
+
return byUsage
|
|
92
|
+
? 'cawdev paused this machine for usage: claiming nothing new, finishing what is running'
|
|
93
|
+
: 'paused from the console: claiming nothing new, finishing what is running';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What the platform is told about a pause: nothing. The queued-reasons call is
|
|
98
|
+
* served to EVERY project viewer as `queuedReason`, so the usage figures and
|
|
99
|
+
* "press c" stay in this daemon's log and terminal; the platform says it
|
|
100
|
+
* itself, per viewer, as `waitingOn` (R571).
|
|
101
|
+
*/
|
|
102
|
+
export function reportedPauseNote() {
|
|
103
|
+
return null;
|
|
104
|
+
}
|