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.
@@ -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 parts = event?.message?.content;
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
  }
@@ -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. Name a status (`CONSIDERING`, `NEW`) when
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.10-beta",
3
+ "version": "1.0.11-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
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. As tall as the fullest column, never more than a
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 status —
3417
- * the strip draws them all, and `d` offers the PLANNED ones. One request
3418
- * per project, the board's own (`brief`, so no bodies). Needs a person's
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
- this.planned = all.filter((card) => card.status === 'PLANNED');
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 as a list, and
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
+ }