cawdev-cli 1.0.10-beta → 1.0.11-beta
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/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +10 -0
- package/mcp/README.md +3 -2
- package/mcp/server.mjs +19 -3
- package/package.json +1 -1
- package/runner/README.md +7 -2
- package/runner/attach.mjs +19 -8
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +308 -117
- package/runner/split.mjs +45 -5
- package/runner/workspace-check.mjs +78 -0
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
* - `BashOutput` (or `TaskOutput`) reads one by `bash_id`/`shell_id`/`task_id`;
|
|
18
18
|
* the command is over only when a read says a status other than running;
|
|
19
19
|
* - `KillShell`/`KillBash`/`TaskStop` ends one.
|
|
20
|
+
* And a delegation started in the background: `Agent` (or `Task`) with
|
|
21
|
+
* `run_in_background: true`, or whose result says "Async agent launched" —
|
|
22
|
+
* its id is the result's `agentId: <id>`. It is over when a `<task-notification>`
|
|
23
|
+
* naming that id arrives with a status other than running, when a
|
|
24
|
+
* `TaskOutput` read says so, or when `TaskStop` ends it. R6 on saasng is why:
|
|
25
|
+
* IMPLEMENT handed the whole card to two background agents, said "both agents
|
|
26
|
+
* are still working in the background" and ended its turn; the walk marched on
|
|
27
|
+
* and the run FINISHED with nothing built.
|
|
28
|
+
*
|
|
20
29
|
* A command whose id cannot be read off its result stays unfinished: failing
|
|
21
30
|
* loudly and re-running a stage costs a turn, calling unproven work proven
|
|
22
31
|
* costs somebody's afternoon.
|
|
@@ -26,6 +35,7 @@
|
|
|
26
35
|
*/
|
|
27
36
|
|
|
28
37
|
const READS = new Set(['BashOutput', 'TaskOutput']);
|
|
38
|
+
const DELEGATES = new Set(['Agent', 'Task']);
|
|
29
39
|
const KILLS = new Set(['KillShell', 'KillBash', 'TaskStop']);
|
|
30
40
|
|
|
31
41
|
/** The text of a tool_result's content, whichever of its two shapes it came in. */
|
|
@@ -44,15 +54,19 @@ function shellIdOf(input) {
|
|
|
44
54
|
|
|
45
55
|
export class BackgroundShells {
|
|
46
56
|
constructor() {
|
|
47
|
-
/** tool_use id of each background `Bash` → { command, shellId, done }. */
|
|
57
|
+
/** tool_use id of each background `Bash` or `Agent` → { command, shellId, done }. */
|
|
48
58
|
this.started = new Map();
|
|
59
|
+
/** tool_use id of each `Agent` call not yet known to be in the background → its label and input. */
|
|
60
|
+
this.delegations = new Map();
|
|
49
61
|
/** tool_use id of each read → the shell id it read. */
|
|
50
62
|
this.reads = new Map();
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** One stream-json event. Anything that is not about a shell is ignored. */
|
|
54
66
|
observe(event) {
|
|
55
|
-
const
|
|
67
|
+
const content = event?.message?.content;
|
|
68
|
+
if (event?.type === 'user') this.notified(textOf(content));
|
|
69
|
+
const parts = content;
|
|
56
70
|
if (!Array.isArray(parts)) return;
|
|
57
71
|
if (event.type === 'assistant') {
|
|
58
72
|
for (const part of parts) {
|
|
@@ -63,6 +77,9 @@ export class BackgroundShells {
|
|
|
63
77
|
shellId: null,
|
|
64
78
|
done: false,
|
|
65
79
|
});
|
|
80
|
+
} else if (DELEGATES.has(part.name)) {
|
|
81
|
+
const label = `Agent: ${String(part.input?.description ?? part.input?.subagent_type ?? 'a delegation').trim()}`;
|
|
82
|
+
this.delegations.set(part.id, { label, background: part.input?.run_in_background === true });
|
|
66
83
|
} else if (READS.has(part.name)) {
|
|
67
84
|
const id = shellIdOf(part.input);
|
|
68
85
|
if (id) this.reads.set(part.id, id);
|
|
@@ -76,6 +93,17 @@ export class BackgroundShells {
|
|
|
76
93
|
for (const part of parts) {
|
|
77
94
|
if (part?.type !== 'tool_result') continue;
|
|
78
95
|
const text = textOf(part.content);
|
|
96
|
+
const delegation = this.delegations.get(part.tool_use_id);
|
|
97
|
+
if (delegation) {
|
|
98
|
+
this.delegations.delete(part.tool_use_id);
|
|
99
|
+
// A foreground delegation's result IS its end; only one that came
|
|
100
|
+
// back as "launched" is still out there.
|
|
101
|
+
if (delegation.background || /async agent launched/i.test(text)) {
|
|
102
|
+
const id = /\bagentId:?\s*([A-Za-z0-9_-]+)/i.exec(text);
|
|
103
|
+
this.started.set(part.tool_use_id, { command: delegation.label, shellId: id ? id[1] : null, done: false });
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
79
107
|
const launched = this.started.get(part.tool_use_id);
|
|
80
108
|
if (launched && !launched.shellId) {
|
|
81
109
|
const id = /\bID:?\s*([A-Za-z0-9_-]+)/.exec(text);
|
|
@@ -94,6 +122,20 @@ export class BackgroundShells {
|
|
|
94
122
|
}
|
|
95
123
|
}
|
|
96
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The CLI tells a session that a background task ended with a
|
|
127
|
+
* `<task-notification>` in a user turn — `<task-id>` and `<status>`. Any
|
|
128
|
+
* status but running is over: a failed or killed agent is not one to wait for.
|
|
129
|
+
*/
|
|
130
|
+
notified(text) {
|
|
131
|
+
if (!text || !text.includes('<task-notification>')) return;
|
|
132
|
+
for (const block of text.split('<task-notification>').slice(1)) {
|
|
133
|
+
const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block);
|
|
134
|
+
const status = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block);
|
|
135
|
+
if (id && status && status[1].toLowerCase() !== 'running') this.finish(id[1]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
97
139
|
finish(shellId) {
|
|
98
140
|
if (!shellId) return;
|
|
99
141
|
for (const each of this.started.values()) {
|
|
@@ -101,7 +143,7 @@ export class BackgroundShells {
|
|
|
101
143
|
}
|
|
102
144
|
}
|
|
103
145
|
|
|
104
|
-
/** The commands started in the background whose end this session never saw. */
|
|
146
|
+
/** The commands and delegations started in the background whose end this session never saw. */
|
|
105
147
|
unfinished() {
|
|
106
148
|
return [...this.started.values()].filter((each) => !each.done).map((each) => each.command);
|
|
107
149
|
}
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -253,6 +253,12 @@ export function parseRoadmap(text) {
|
|
|
253
253
|
entry.targetVersion = target[1].trim();
|
|
254
254
|
continue;
|
|
255
255
|
}
|
|
256
|
+
// R378. The versions an issue was found in, comma separated.
|
|
257
|
+
const affects = /^Affects:\s*(.+)$/.exec(line.trim());
|
|
258
|
+
if (affects) {
|
|
259
|
+
entry.affectedVersions = affects[1].split(',').map((v) => v.trim()).filter(Boolean);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
256
262
|
const estimate = /^Estimate:\s*(.+)$/.exec(line.trim());
|
|
257
263
|
if (estimate) {
|
|
258
264
|
const n = Number(estimate[1].trim());
|
|
@@ -534,6 +540,10 @@ function renderEntry(entry) {
|
|
|
534
540
|
if (entry.targetVersion && !entry.version) {
|
|
535
541
|
lines.push(`Target: ${entry.targetVersion}`);
|
|
536
542
|
}
|
|
543
|
+
// R378. Where an issue was found; history, so it stays once the issue ships.
|
|
544
|
+
if (entry.affectedVersions?.length) {
|
|
545
|
+
lines.push(`Affects: ${entry.affectedVersions.join(', ')}`);
|
|
546
|
+
}
|
|
537
547
|
|
|
538
548
|
// Each status writes only what it currently claims. A MERGED entry keeps its
|
|
539
549
|
// branch in the database as history, but the file must not print it: the
|
package/mcp/README.md
CHANGED
|
@@ -85,7 +85,8 @@ without opening pull requests has none to give it.
|
|
|
85
85
|
when you name no status is the project's rule, per door, and the default for a
|
|
86
86
|
card or issue an agent files is the backlog: waiting until a person or a sprint
|
|
87
87
|
brings it in, in no sprint and on no board column. Its origin names your
|
|
88
|
-
session and the card your run was on.
|
|
88
|
+
session and the card your run was on. A plan session's card lands where the console's
|
|
89
|
+
do (R380). Name a status (`CONSIDERING`, `NEW`) when
|
|
89
90
|
you mean the pipeline. Giving a backlog card a sprint moves it to the first
|
|
90
91
|
status of its kind; moving a card to `BACKLOG` takes it out of its sprint.
|
|
91
92
|
|
|
@@ -171,7 +172,7 @@ what you are allowed to do.
|
|
|
171
172
|
| `roadmap_list` | Entries, optionally by status. `brief` omits bodies — use it to survey. |
|
|
172
173
|
| `roadmap_get` | One entry in full: its body, **the plan agreed for it**, and the discussion on it. |
|
|
173
174
|
| `roadmap_create` | Create an entry; the platform allocates its permanent number. |
|
|
174
|
-
| `roadmap_update` | Title, body, section, related ids, the cards it starts coding after (`after` — R181), when it is due and how big it is (`due` — R351: an ISO date, `""` clears; `estimate`: a positive number, `0` clears), the version it is meant for (`targetVersion` — R358, `""` clears; since R362 it must be one the project defined under Project settings → Versions, and any other name is refused), and who it is assigned to (`assignee` — R331: a username, `""` clears; `roadmap_list`/`issue_list` take `assignee` too, and `me` means the token's owner). |
|
|
175
|
+
| `roadmap_update` | Title, body, section, related ids, the cards it starts coding after (`after` — R181), when it is due and how big it is (`due` — R351: an ISO date, `""` clears; `estimate`: a positive number, `0` clears), the version it is meant for (`targetVersion` — R358, `""` clears; since R362 it must be one the project defined under Project settings → Versions, and any other name is refused), the versions an issue was found in (`affectedVersions` — R378, issues only: names the project defined or shipped in, `[]` clears), and who it is assigned to (`assignee` — R331: a username, `""` clears; `roadmap_list`/`issue_list` take `assignee` too, and `me` means the token's owner). |
|
|
175
176
|
| `roadmap_comment` | Say something *beside* an entry rather than inside it. |
|
|
176
177
|
| `done_when_tick` | Tick (or untick) one numbered item of the card's Done-when checklist as built, with one line of evidence (R368). `task_current` lists the items. Defaults to the run's own card and refuses another; the reviewer's column is a person's and no token can set it. |
|
|
177
178
|
| `roadmap_set_status` | Move an entry, carrying whatever the status requires. |
|
package/mcp/server.mjs
CHANGED
|
@@ -685,7 +685,8 @@ const TOOLS = [
|
|
|
685
685
|
'project decides where a card you file starts — the BACKLOG by default for a card an ' +
|
|
686
686
|
'agent files, waiting until a person or a sprint brings it in — unless you say ' +
|
|
687
687
|
'otherwise; a status that requires something must be given it. A card in the BACKLOG ' +
|
|
688
|
-
'holds no sprint: giving it one moves it to CONSIDERING.'
|
|
688
|
+
'holds no sprint: giving it one moves it to CONSIDERING. A plan session\'s card lands ' +
|
|
689
|
+
'where the console\'s do (R380).',
|
|
689
690
|
inputSchema: {
|
|
690
691
|
type: 'object',
|
|
691
692
|
properties: {
|
|
@@ -866,6 +867,12 @@ const TOOLS = [
|
|
|
866
867
|
description:
|
|
867
868
|
'How much it matters; it does not reorder the board. Leave out for MEDIUM.',
|
|
868
869
|
},
|
|
870
|
+
affectedVersions: {
|
|
871
|
+
type: 'array',
|
|
872
|
+
items: { type: 'string' },
|
|
873
|
+
description:
|
|
874
|
+
'Versions the project defined or shipped in where this issue was found — R378. Any other name is refused.',
|
|
875
|
+
},
|
|
869
876
|
},
|
|
870
877
|
required: ['title', 'severity'],
|
|
871
878
|
},
|
|
@@ -873,7 +880,7 @@ const TOOLS = [
|
|
|
873
880
|
const slug = await resolveProject(config, args.project);
|
|
874
881
|
const issue = await api(config, `/api/projects/${slug}/issues`, {
|
|
875
882
|
method: 'POST',
|
|
876
|
-
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags', 'assignee', 'priority']),
|
|
883
|
+
body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags', 'assignee', 'priority', 'affectedVersions']),
|
|
877
884
|
});
|
|
878
885
|
return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
|
|
879
886
|
},
|
|
@@ -993,6 +1000,13 @@ const TOOLS = [
|
|
|
993
1000
|
'The card this one sits under (an epic), e.g. "R300". "" takes the card out of its ' +
|
|
994
1001
|
'parent; leave out to leave it alone. Same rules as roadmap_create.',
|
|
995
1002
|
},
|
|
1003
|
+
affectedVersions: {
|
|
1004
|
+
type: 'array',
|
|
1005
|
+
items: { type: 'string' },
|
|
1006
|
+
description:
|
|
1007
|
+
'Issues only (R378): the versions the project defined or shipped in where it was found. ' +
|
|
1008
|
+
'Leave out to leave them alone; [] clears them. A roadmap card refuses a non-empty list.',
|
|
1009
|
+
},
|
|
996
1010
|
},
|
|
997
1011
|
required: ['number'],
|
|
998
1012
|
},
|
|
@@ -1003,7 +1017,7 @@ const TOOLS = [
|
|
|
1003
1017
|
// pick keeps a present 0, which is how a card leaves its sprint.
|
|
1004
1018
|
body: pick(args, [
|
|
1005
1019
|
'title', 'body', 'section', 'related', 'after', 'sprint', 'tags', 'assignee', 'priority',
|
|
1006
|
-
'due', 'estimate', 'targetVersion', 'parent',
|
|
1020
|
+
'due', 'estimate', 'targetVersion', 'parent', 'affectedVersions',
|
|
1007
1021
|
]),
|
|
1008
1022
|
});
|
|
1009
1023
|
return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
|
|
@@ -2341,6 +2355,8 @@ function formatEntry(entry, { brief, comments, plan }) {
|
|
|
2341
2355
|
if (entry.estimate) lines.push(` estimate: ${entry.estimate}`);
|
|
2342
2356
|
// R358. Printed until the card ships; `version` says where it did.
|
|
2343
2357
|
if (entry.targetVersion && !entry.version) lines.push(` target: ${entry.targetVersion}`);
|
|
2358
|
+
// R378. History, not plan: still printed once the issue has shipped.
|
|
2359
|
+
if (entry.affectedVersions?.length) lines.push(` affects: ${entry.affectedVersions.join(', ')}`);
|
|
2344
2360
|
// R257. Always printed when the card has one, so a reader's shape does not
|
|
2345
2361
|
// depend on which tool printed the card; a closed sprint says so.
|
|
2346
2362
|
if (entry.sprint) {
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -469,7 +469,7 @@ this machine serves — unless the command names it with `@project`, or
|
|
|
469
469
|
advanced mode is narrowed to ONE project (`/projects`), which is a choice
|
|
470
470
|
already made rather than a guess. Never guessed from the focused window, nor
|
|
471
471
|
from a machine serving just one, since a session on the wrong project is a
|
|
472
|
-
card planned or a branch cut in the wrong repository. `/new dev` alone and `d` list the planned cards of every project,
|
|
472
|
+
card planned or a branch cut in the wrong repository. `/new dev` alone and `d` list the planned cards and the confirmed issues of every project,
|
|
473
473
|
each with its own, so they ask nothing more. A usage window over this machine's threshold is refused in a
|
|
474
474
|
sentence; `--anyway` starts it regardless, the same decision the console's
|
|
475
475
|
panel asks for. All four commands need you signed in (`/login`), and they work
|
|
@@ -575,7 +575,12 @@ serves as a small board — Considering, Planned and In development side by
|
|
|
575
575
|
side, one line per card, `●` on a card with a session running now; Merged,
|
|
576
576
|
Shipped and Declined — work that is over, and only grows — are counts beside
|
|
577
577
|
the project's name (`1 merged · 2 shipped`), as the console's board folds its
|
|
578
|
-
own last columns.
|
|
578
|
+
own last columns. The Backlog is a count there too, first (`3 backlog`): since
|
|
579
|
+
R371 it is where a new issue lands, and it would be the first column to go. **Issues are on it too** (R85's other board, read from
|
|
580
|
+
`/issues` beside `/roadmap`): a NEW issue sits in Considering and a CONFIRMED
|
|
581
|
+
one in Planned — their counterparts — first in the column and most severe first,
|
|
582
|
+
so a `+N more` never hides a critical defect, with the ref (`i12`) in the colour
|
|
583
|
+
of its severity. As tall as the fullest column, never more than a
|
|
579
584
|
third of the screen, and gone before the windows are on a short terminal.
|
|
580
585
|
When the room runs out it is SAID: a column ends in `+N more`, empty columns
|
|
581
586
|
go first when the width does, then whole columns (Considering, then Planned —
|
package/runner/attach.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } fro
|
|
|
61
61
|
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
62
62
|
import {
|
|
63
63
|
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
|
-
carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
64
|
+
carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
65
65
|
windowLines,
|
|
66
66
|
} from './split.mjs';
|
|
67
67
|
import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
|
|
@@ -3413,9 +3413,10 @@ export class Attached {
|
|
|
3413
3413
|
}
|
|
3414
3414
|
|
|
3415
3415
|
/**
|
|
3416
|
-
* Every card of every project this machine serves, whatever its
|
|
3417
|
-
* the strip draws them all, and `d` offers the PLANNED
|
|
3418
|
-
*
|
|
3416
|
+
* Every card AND ISSUE of every project this machine serves, whatever its
|
|
3417
|
+
* status — the strip draws them all, and `d` offers the PLANNED cards and
|
|
3418
|
+
* the CONFIRMED issues. Two requests per project, the two boards' own
|
|
3419
|
+
* (`brief`, so no bodies). Needs a person's
|
|
3419
3420
|
* session, like everything read off the roadmap here.
|
|
3420
3421
|
*
|
|
3421
3422
|
* A project that cannot be read keeps ITS last cards and no other's: this
|
|
@@ -3435,6 +3436,15 @@ export class Attached {
|
|
|
3435
3436
|
for (const card of Array.isArray(cards) ? cards : []) {
|
|
3436
3437
|
all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
|
|
3437
3438
|
}
|
|
3439
|
+
// The issues are another list on the platform (R85), and were never
|
|
3440
|
+
// read here: the strip drew a project's ideas and not one of its bugs.
|
|
3441
|
+
const issues = await this.session.request(`/api/projects/${slug}/issues?brief=true`);
|
|
3442
|
+
for (const issue of Array.isArray(issues) ? issues : []) {
|
|
3443
|
+
all.push({
|
|
3444
|
+
ref: issue.ref, title: issue.title, slug, status: issue.status,
|
|
3445
|
+
live: Boolean(issue.activeRun), severity: issue.severity ?? 'MINOR',
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3438
3448
|
read.push(slug);
|
|
3439
3449
|
this.gone.delete(slug);
|
|
3440
3450
|
} catch (failure) {
|
|
@@ -3447,7 +3457,8 @@ export class Attached {
|
|
|
3447
3457
|
}
|
|
3448
3458
|
}
|
|
3449
3459
|
this.cards = all;
|
|
3450
|
-
|
|
3460
|
+
// Ready to be developed: a planned card, or a confirmed issue.
|
|
3461
|
+
this.planned = all.filter((card) => columnOf(card.status) === 'PLANNED');
|
|
3451
3462
|
this.dirty = true;
|
|
3452
3463
|
if (!hold) this.sayGone();
|
|
3453
3464
|
return { signedIn: true, read, failed };
|
|
@@ -3482,7 +3493,7 @@ export class Attached {
|
|
|
3482
3493
|
const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
|
|
3483
3494
|
const mine = this.cards.filter((card) => card.slug === slug);
|
|
3484
3495
|
const tally = BOARD_COLUMNS
|
|
3485
|
-
.map(([status, label]) => `${mine.filter((card) => card.status === status).length} ${label.toLowerCase()}`)
|
|
3496
|
+
.map(([status, label]) => `${mine.filter((card) => columnOf(card.status) === status).length} ${label.toLowerCase()}`)
|
|
3486
3497
|
.join(' · ');
|
|
3487
3498
|
return `${slug}: ${tally}`;
|
|
3488
3499
|
});
|
|
@@ -3498,8 +3509,8 @@ export class Attached {
|
|
|
3498
3509
|
}
|
|
3499
3510
|
|
|
3500
3511
|
/**
|
|
3501
|
-
* `d`, or `/new dev` with nothing after it: the planned cards
|
|
3502
|
-
* the one picked is developed — `/new dev R3 @slug`, on this machine.
|
|
3512
|
+
* `d`, or `/new dev` with nothing after it: the planned cards — and the
|
|
3513
|
+
* confirmed issues — as a list, and the one picked is developed — `/new dev R3 @slug`, on this machine.
|
|
3503
3514
|
*/
|
|
3504
3515
|
async pickPlanned(slug = null) {
|
|
3505
3516
|
if (!this.requireSignIn('start a session')) return;
|
|
@@ -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
|
+
}
|