cawdev-cli 1.0.9-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/runner/attach.mjs CHANGED
@@ -60,8 +60,8 @@ import {
60
60
  import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
61
61
  import { restartNeededFor } from '../lib/runner-config.mjs';
62
62
  import {
63
- BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
64
- carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
63
+ BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
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';
@@ -298,8 +298,14 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
298
298
  .find((lines) => lines.length <= 2)
299
299
  ?? rows(without(short));
300
300
 
301
+ // A stage gate's summary is the whole PLAN, many lines long: one row here,
302
+ // and how much more there is — `/view` reads it whole. Clipped as one
303
+ // string, its newlines went through and filled the prompt.
304
+ const [first = '', ...more] = String(approval.summary ?? '').split('\n');
305
+ const rest = more.filter((line) => line.trim()).length;
306
+ const tail = rest ? ` (+${rest} lines)` : '';
301
307
  return [
302
- `${ink.bold(ink.warn(' permission '))} ${clip(approval.summary, Math.max(8, width - 13))}`,
308
+ `${ink.bold(ink.warn(' permission '))} ${clip(first, Math.max(8, width - 13 - tail.length))}${ink.muted(tail)}`,
303
309
  ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
304
310
  ...keys,
305
311
  ];
@@ -832,8 +838,8 @@ const COMMANDS = [
832
838
  ['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
833
839
  ['/projects', 'which projects advanced mode shows — tick one or several'],
834
840
  ['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
835
- ['/open', "open the session's pull request in your browser — or, on a finished plan, its card and plan on cawdev"],
836
- ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone opens the session on cawdev"],
841
+ ['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you, its page on cawdev"],
842
+ ['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full, or opens it on cawdev"],
837
843
  ['/drop', 'delete the checkout a finished session was made on demand for'],
838
844
  ['/close', "close an ended session's window"],
839
845
  ['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
@@ -1625,6 +1631,9 @@ export class Attached {
1625
1631
  }
1626
1632
  this.interrupting = false;
1627
1633
 
1634
+ if (this.reader && this.mode === 'keys') {
1635
+ return this.onReaderKey(key);
1636
+ }
1628
1637
  if (this.mode === 'typing') {
1629
1638
  return this.onTyping(key);
1630
1639
  }
@@ -1755,7 +1764,8 @@ export class Attached {
1755
1764
 
1756
1765
  /** Everything open, closed. Returns whether there was anything. */
1757
1766
  closeEverything() {
1758
- const open = this.mode !== 'keys';
1767
+ const open = this.mode !== 'keys' || Boolean(this.reader);
1768
+ this.reader = null;
1759
1769
  this.mode = 'keys';
1760
1770
  this.select = null;
1761
1771
  this.input = null;
@@ -2082,6 +2092,16 @@ export class Attached {
2082
2092
  this.select = null;
2083
2093
  return this.note('');
2084
2094
  }
2095
+ // A question's or a permission's list holds only the start of what is
2096
+ // asked; `/` leaves it for a command — `/view` reads it whole, `/open`
2097
+ // answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
2098
+ if (key === '/' && (select.kind === 'question' || select.kind === 'permission')) {
2099
+ this.mode = 'keys';
2100
+ this.select = null;
2101
+ this.answering = null;
2102
+ this.deciding = null;
2103
+ return this.type('command', 'cawdev ▸', '/');
2104
+ }
2085
2105
 
2086
2106
  const outcome = select.key(key);
2087
2107
  if (!outcome) {
@@ -2287,8 +2307,9 @@ export class Attached {
2287
2307
  }
2288
2308
  return this.openPicker(new Select({
2289
2309
  kind: 'question',
2290
- title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
2310
+ title: clip(`${this.tagOf(this.current())} asks: ${String(asking.question.question).split('\n')[0]}`,
2291
2311
  Math.max(20, this.screen.width - 2)),
2312
+ hint: '/view reads it all',
2292
2313
  rows: [
2293
2314
  ...options.map((option) => ({ id: option, label: option })),
2294
2315
  // Always last, and always there.
@@ -2316,7 +2337,10 @@ export class Attached {
2316
2337
  kind: 'permission',
2317
2338
  // The call, on the title, as well as printed in full above: the picker
2318
2339
  // may still be on screen when the transcript under it has moved on.
2319
- title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${approval.summary}`,
2340
+ // The first line only: a stage gate's summary is the whole plan, and
2341
+ // `/view` is where it is read.
2342
+ title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${String(approval.summary ?? '').split('\n')[0]}`,
2343
+ hint: '/view reads it all',
2320
2344
  rows: [
2321
2345
  { id: 'y', label: 'Allow once', hint: 'this call and no more' },
2322
2346
  // R78: the middle grant NAMES what it covers, and it says so in the
@@ -2831,7 +2855,8 @@ export class Attached {
2831
2855
  case 'open':
2832
2856
  return void this.openInBrowser();
2833
2857
  case 'view':
2834
- return rest.length ? void this.viewCard(rest) : this.viewOnCawdev();
2858
+ if (rest.length) return void this.viewCard(rest);
2859
+ return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
2835
2860
  case 'drop':
2836
2861
  return void this.dropCheckout();
2837
2862
  case 'close':
@@ -3250,6 +3275,9 @@ export class Attached {
3250
3275
  splitFrame() {
3251
3276
  const { width, height } = this.split.screen;
3252
3277
  const ink = this.ink;
3278
+ if (this.reader) {
3279
+ return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
3280
+ }
3253
3281
  const windows = this.windows();
3254
3282
  const inner = Math.max(8, width - 2);
3255
3283
 
@@ -3269,6 +3297,11 @@ export class Attached {
3269
3297
  + ' · / commands · tab next window');
3270
3298
  // A question or a request on screen says which window it came from.
3271
3299
  const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
3300
+ // Under the banner and right above the line, so a long one never pushes
3301
+ // it out of the window: the way to read all of it, and the other door.
3302
+ // Also while its list is open, which hides the banner.
3303
+ const whole = banner.length || this.waitingOn(this.commandTarget())
3304
+ ? [ink.muted(' /view reads it all · /open answers it on cawdev')] : [];
3272
3305
  // The commands matching what is typed, above the line as in the footer —
3273
3306
  // advanced mode drew none, so enter took a highlighted command nobody saw.
3274
3307
  const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
@@ -3278,7 +3311,7 @@ export class Attached {
3278
3311
  const completing = completionLines(
3279
3312
  matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
3280
3313
  );
3281
- const fixed = [...from, ...banner, ...overlay, ...completing, input];
3314
+ const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
3282
3315
  const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
3283
3316
  // The roadmap across the top: as tall as its fullest column needs, never
3284
3317
  // more than a third of the screen, and never so tall that no window fits
@@ -3380,9 +3413,10 @@ export class Attached {
3380
3413
  }
3381
3414
 
3382
3415
  /**
3383
- * Every card of every project this machine serves, whatever its status —
3384
- * the strip draws them all, and `d` offers the PLANNED ones. One request
3385
- * 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
3386
3420
  * session, like everything read off the roadmap here.
3387
3421
  *
3388
3422
  * A project that cannot be read keeps ITS last cards and no other's: this
@@ -3402,6 +3436,15 @@ export class Attached {
3402
3436
  for (const card of Array.isArray(cards) ? cards : []) {
3403
3437
  all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
3404
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
+ }
3405
3448
  read.push(slug);
3406
3449
  this.gone.delete(slug);
3407
3450
  } catch (failure) {
@@ -3414,7 +3457,8 @@ export class Attached {
3414
3457
  }
3415
3458
  }
3416
3459
  this.cards = all;
3417
- 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');
3418
3462
  this.dirty = true;
3419
3463
  if (!hold) this.sayGone();
3420
3464
  return { signedIn: true, read, failed };
@@ -3449,7 +3493,7 @@ export class Attached {
3449
3493
  const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
3450
3494
  const mine = this.cards.filter((card) => card.slug === slug);
3451
3495
  const tally = BOARD_COLUMNS
3452
- .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()}`)
3453
3497
  .join(' · ');
3454
3498
  return `${slug}: ${tally}`;
3455
3499
  });
@@ -3465,8 +3509,8 @@ export class Attached {
3465
3509
  }
3466
3510
 
3467
3511
  /**
3468
- * `d`, or `/new dev` with nothing after it: the planned cards as a list, and
3469
- * 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.
3470
3514
  */
3471
3515
  async pickPlanned(slug = null) {
3472
3516
  if (!this.requireSignIn('start a session')) return;
@@ -3499,17 +3543,20 @@ export class Attached {
3499
3543
  if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
3500
3544
  words = [...words, `@${shown[0]}`];
3501
3545
  }
3502
- const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway');
3546
+ const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again');
3503
3547
  if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
3504
3548
  const named = words.find((word) => word.startsWith('@'));
3505
3549
  await this.pickPlanned(named ? named.slice(1) : null);
3506
3550
  return;
3507
3551
  }
3508
3552
  const anyway = words.includes('--anyway');
3553
+ // R369: the answer to "a session already finished here" — `--anyway`'s
3554
+ // shape, for a different question.
3555
+ const again = words.includes('--again');
3509
3556
  // `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
3510
3557
  const flagged = words.find((word) => AGENT_FLAGS.has(word));
3511
3558
  let agent = flagged ? flagged.slice(2) : this.agent;
3512
- const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
3559
+ const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again' && !AGENT_FLAGS.has(word)), {
3513
3560
  // In advanced mode, among the projects it shows.
3514
3561
  projects: this.shownProjects(),
3515
3562
  });
@@ -3557,7 +3604,7 @@ export class Attached {
3557
3604
  // R150: the session writes the card first, then plans it.
3558
3605
  : ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
3559
3606
  : ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
3560
- const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
3607
+ const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
3561
3608
  if (started && plan) {
3562
3609
  this.closeWindowOf(plan);
3563
3610
  this.focusOnArrival = plan.projectSlug;
@@ -3819,9 +3866,10 @@ export class Attached {
3819
3866
  * One POST that starts a session, aimed at THIS machine so its window opens
3820
3867
  * here. A usage window over the machine's threshold is refused by the
3821
3868
  * platform in a sentence; `--anyway` is the same decision the console's
3822
- * panel asks for.
3869
+ * panel asks for. A card whose last session of that kind finished asks too
3870
+ * (R369), and `--again` answers it.
3823
3871
  */
3824
- async startSession(slug, path, body, what, anyway) {
3872
+ async startSession(slug, path, body, what, anyway, again = false) {
3825
3873
  try {
3826
3874
  await this.session.request(`/api/projects/${slug}/${path}`, {
3827
3875
  method: 'POST',
@@ -3829,15 +3877,20 @@ export class Attached {
3829
3877
  ...body,
3830
3878
  ...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
3831
3879
  ...(anyway ? { acknowledgeUsage: true } : {}),
3880
+ ...(again ? { again: true } : {}),
3832
3881
  },
3833
3882
  });
3834
3883
  this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
3835
3884
  this.note('');
3836
3885
  return true;
3837
3886
  } catch (failure) {
3838
- this.note(/usage|window/i.test(failure.message)
3839
- ? `${failure.message} — add --anyway to start it regardless`
3840
- : failure.message);
3887
+ // R369's sentence is checked first: it is the one that names a session,
3888
+ // and the usage test is loose enough to match words in other sentences.
3889
+ this.note(/session that finished/.test(failure.message)
3890
+ ? `${failure.message} — add --again to start it regardless`
3891
+ : /usage|window/i.test(failure.message)
3892
+ ? `${failure.message} — add --anyway to start it regardless`
3893
+ : failure.message);
3841
3894
  return false;
3842
3895
  }
3843
3896
  }
@@ -3948,6 +4001,16 @@ export class Attached {
3948
4001
  if (!meta) {
3949
4002
  return this.note('/open needs a session — focus one');
3950
4003
  }
4004
+ if (this.waitingOn(meta)) {
4005
+ // Stopped on a question or a permission: the run page on cawdev, where
4006
+ // the question panel and the decision are, whole.
4007
+ const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
4008
+ if (!url) {
4009
+ return this.note('cannot tell where this session is on cawdev');
4010
+ }
4011
+ this.openLink(url);
4012
+ return this.note(`opened ${url}`);
4013
+ }
3951
4014
  if (this.finishedPlan(meta)) {
3952
4015
  const ref = meta.view.entryRef;
3953
4016
  const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
@@ -3989,6 +4052,129 @@ export class Attached {
3989
4052
  opener(url);
3990
4053
  }
3991
4054
 
4055
+ /** What a session is stopped on — a question or a permission request — or null. */
4056
+ waitingOn(meta) {
4057
+ const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
4058
+ if (!run) return null;
4059
+ const pending = this.pendingOn(run);
4060
+ if (pending) return { kind: 'permission', pending };
4061
+ const asking = this.askingOn(run);
4062
+ return asking ? { kind: 'question', asking } : null;
4063
+ }
4064
+
4065
+ /**
4066
+ * `/view` on a session waiting for you: what it is waiting on, in FULL. In
4067
+ * advanced mode the reader — the session's output and the request under
4068
+ * it, over the whole screen, scrolled with the arrows (the wheel arrives as
4069
+ * arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
4070
+ * and does what it does (`a` answers, `y`/`n` decide). In the scrollback
4071
+ * the request is printed whole, where the terminal scrolls it.
4072
+ */
4073
+ openReader() {
4074
+ const meta = this.commandTarget();
4075
+ if (!this.split) {
4076
+ this.say('');
4077
+ for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
4078
+ this.say('');
4079
+ return this.note('');
4080
+ }
4081
+ this.reader = { runId: meta.id, top: null, room: 1, max: 0 };
4082
+ this.dirty = true;
4083
+ return this.note('');
4084
+ }
4085
+
4086
+ /** The request a session waits on, every word of it. */
4087
+ requestLines(waiting, width) {
4088
+ const ink = this.ink;
4089
+ if (!waiting) {
4090
+ return [ink.muted(' no longer waiting — answered, decided, or gone')];
4091
+ }
4092
+ const room = Math.max(20, width - 4);
4093
+ if (waiting.kind === 'question') {
4094
+ const { question, yours, waitingOn } = waiting.asking;
4095
+ return [
4096
+ ink.bold(ink.accent(' question ')),
4097
+ ...String(question.question ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
4098
+ ...(question.options?.length
4099
+ ? ['', ...question.options.map((option, i) => ` ${ink.success(String(i + 1))} ${option}`)]
4100
+ : []),
4101
+ '',
4102
+ yours
4103
+ ? ` ${ink.success('a')} ${ink.muted('answers it here · /open answers it on cawdev')}`
4104
+ : ink.muted(` waiting on ${waitingOn ?? 'somebody else'} — theirs to answer`),
4105
+ ];
4106
+ }
4107
+ const approval = waiting.pending.approval ?? waiting.pending;
4108
+ return [
4109
+ `${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
4110
+ ...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
4111
+ ...(approval.toolInput && approval.toolInput !== approval.summary
4112
+ ? ['', ink.muted(' input'), ...String(approval.toolInput).split('\n').map((line) => ` ${ink.text(line)}`)]
4113
+ : []),
4114
+ '',
4115
+ ...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
4116
+ ];
4117
+ }
4118
+
4119
+ /** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
4120
+ readerScreen(width, height) {
4121
+ const ink = this.ink;
4122
+ const meta = this.windows().find((each) => each.id === this.reader.runId)
4123
+ ?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
4124
+ const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
4125
+ const waiting = this.waitingOn(meta);
4126
+ const request = this.requestLines(waiting, width - 2);
4127
+ const n = this.windowNumberOf(meta.id);
4128
+ const keys = waiting?.kind === 'permission'
4129
+ ? 'y once · s session · n refuse'
4130
+ : waiting?.asking?.yours ? 'a answer' : '';
4131
+ const drawn = readerFrame(width, height, {
4132
+ title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? `waiting for ${waiting.kind === 'question' ? 'an answer' : 'permission'}` : null)}`,
4133
+ lines: [...output, ink.muted('─'.repeat(Math.min(40, width - 2))), ...request],
4134
+ requestAt: output.length,
4135
+ top: this.reader.top,
4136
+ footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end', 'esc close',
4137
+ ...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
4138
+ }, ink);
4139
+ Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
4140
+ return drawn.lines;
4141
+ }
4142
+
4143
+ /** Keys while the reader is open. */
4144
+ onReaderKey(key) {
4145
+ const reader = this.reader;
4146
+ const move = (to) => {
4147
+ reader.top = Math.min(Math.max(0, to), reader.max);
4148
+ this.dirty = true;
4149
+ return undefined;
4150
+ };
4151
+ const at = reader.top ?? 0;
4152
+ switch (key) {
4153
+ case `${ESC}[A`: case `${ESC}OA`: case 'k':
4154
+ return move(at - 1);
4155
+ case `${ESC}[B`: case `${ESC}OB`: case 'j':
4156
+ return move(at + 1);
4157
+ case `${ESC}[5~`: case 'b':
4158
+ return move(at - reader.room);
4159
+ case `${ESC}[6~`: case ' ':
4160
+ return move(at + reader.room);
4161
+ case `${ESC}[H`: case `${ESC}[1~`: case 'g':
4162
+ return move(0);
4163
+ case `${ESC}[F`: case `${ESC}[4~`: case 'G':
4164
+ return move(reader.max);
4165
+ case ESC: case 'q':
4166
+ this.reader = null;
4167
+ this.dirty = true;
4168
+ return this.note('');
4169
+ default:
4170
+ // Anything else is what it always is — `a` answers, `y`/`n` decide,
4171
+ // `/` a command — with the reader closed first.
4172
+ this.reader = null;
4173
+ this.dirty = true;
4174
+ return this.onKey(key);
4175
+ }
4176
+ }
4177
+
3992
4178
  /**
3993
4179
  * `/view` alone — the focused session's page on cawdev (its transcript,
3994
4180
  * stages, card and checkout). On the console this terminal signed in to:
@@ -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
+ }