dw-mc 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/bin.js +2082 -1945
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -332,7 +332,7 @@ var ConfigMalformed = class extends Schema.TaggedError()("ConfigMalformed", {
332
332
  return `${this.path} is not valid dw-mc configuration: ${this.reason}\nFix the file, or delete it and run 'dw-mc init' again.`;
333
333
  }
334
334
  };
335
- const reasonOf = (cause) => cause instanceof Error ? cause.message : String(cause);
335
+ const reasonOf = (cause) => Predicate.isError(cause) ? cause.message : String(cause);
336
336
  /** The keys an earlier version had, read off a file loosely enough to find them. */
337
337
  const LegacySection = Schema.Struct({
338
338
  review: Schema.optionalKey(Schema.Struct({
@@ -535,6 +535,18 @@ const textStoreFor = Effect.fn("store.textStoreFor")(function* (namespace) {
535
535
  const store = yield* KeyValueStore.KeyValueStore;
536
536
  return KeyValueStore.prefix(store, `${namespace}/`);
537
537
  });
538
+ /**
539
+ * What a store holds under a key, and nothing where it holds nothing this
540
+ * version can read.
541
+ *
542
+ * A record this version cannot read is one another version of it wrote, and the
543
+ * state directory is a cache of work that can be done again ([ADR
544
+ * 0010](../../docs/adr/0010-configuration-that-cannot-be-read.md)): forgetting a
545
+ * record costs that work once, where failing here would cost the command I
546
+ * asked for. Every read of the tool's own records goes through this, so the
547
+ * bargain is struck once rather than at each of them.
548
+ */
549
+ const remembered = (read) => Effect.orElseSucceed(read, () => Option.none());
538
550
  /** The state directory on disk. */
539
551
  const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => KeyValueStore.layerFileSystem(directory)));
540
552
  KeyValueStore.layerMemory;
@@ -563,32 +575,63 @@ const sessionOf = (cut) => ({
563
575
  })[cut];
564
576
  /** Where the bare clones sit, under the state directory. */
565
577
  const clonesIn = "repos";
578
+ /** What a bare clone's directory is called, and what tells one from anything beside it. */
579
+ const bare = ".git";
580
+ /** Where the tool keeps one repository's bare clone. */
581
+ const cloneAt = Effect.fn("store.cloneAt")(function* (repo) {
582
+ return (yield* Path.Path).join(yield* stateDirectory, clonesIn, `${repo}${bare}`);
583
+ });
584
+ /** Where one pull request's checkout goes, under the cut it was made for. */
585
+ const cutAt = Effect.fn("store.cutAt")(function* (cut, repo, number) {
586
+ return (yield* Path.Path).join(yield* stateDirectory, cut, repo, String(number));
587
+ });
588
+ /**
589
+ * What the branch a standing session works on is called, inside that clone.
590
+ *
591
+ * It carries the session's name because a fix session and a session on a
592
+ * conflict stand at the same time on the same pull request, and one branch
593
+ * between them would be one holding the other's commits.
594
+ */
595
+ const sessionBranch = (session, number) => `dw-mc/${session}/${number}`;
596
+ /**
597
+ * What the ref a clone keeps one pull request's head under is called.
598
+ *
599
+ * A cut fetches it and a removal reads it back without fetching, so the two
600
+ * have to spell it the same or a session would be asked about a ref nothing
601
+ * ever wrote.
602
+ */
603
+ const pullRef = (number) => `refs/dw-mc/pr/${number}`;
566
604
  /** What a directory holds, or nothing at all where it is not there. */
567
605
  const entriesOf = Effect.fnUntraced(function* (directory) {
568
606
  const fs = yield* FileSystem.FileSystem;
569
607
  return yield* Effect.orElseSucceed(fs.readDirectory(directory), () => []);
570
608
  });
571
609
  /**
572
- * What `directory` and everything below it weighs.
610
+ * What the entries named under `directory` weigh together.
573
611
  *
574
612
  * A file that is gone by the time it is asked about weighs nothing rather than
575
613
  * failing the walk: the directory is being read while the tool may be writing
576
614
  * to it, and a size on a screen is worth less than the listing it sits in.
577
615
  */
578
- const weigh = Effect.fn("store.weigh")(function* (directory) {
616
+ const weightOf = Effect.fnUntraced(function* (directory, entries) {
579
617
  const fs = yield* FileSystem.FileSystem;
580
618
  const path = yield* Path.Path;
581
- const entries = yield* Effect.orElseSucceed(fs.readDirectory(directory, { recursive: true }), () => []);
582
619
  const sizes = yield* Effect.forEach(entries, (entry) => Effect.orElseSucceed(Effect.map(fs.stat(path.join(directory, entry)), (info) => ByteSize.toBigInt(info.size)), () => BigInt(0)), { concurrency: 16 });
583
620
  return ByteSize.bytes(sizes.reduce((total, size) => total + size, BigInt(0)));
584
621
  });
622
+ /** What `directory` and everything below it weighs. */
623
+ const weigh = Effect.fn("store.weigh")(function* (directory) {
624
+ const fs = yield* FileSystem.FileSystem;
625
+ const entries = yield* Effect.orElseSucceed(fs.readDirectory(directory, { recursive: true }), () => []);
626
+ return yield* weightOf(directory, entries);
627
+ });
585
628
  /** The bare clones, named by the `owner/repo` the two directory levels spell. */
586
629
  const clonesOf = Effect.fnUntraced(function* (state) {
587
630
  const path = yield* Path.Path;
588
631
  const root = path.join(state, clonesIn);
589
632
  const clones = [];
590
633
  for (const owner of yield* entriesOf(root)) for (const name of yield* entriesOf(path.join(root, owner))) {
591
- if (!name.endsWith(".git")) continue;
634
+ if (!name.endsWith(bare)) continue;
592
635
  const directory = path.join(root, owner, name);
593
636
  clones.push({
594
637
  repo: `${owner}/${name.slice(0, -4)}`,
@@ -617,19 +660,16 @@ const cuttingsOf = Effect.fnUntraced(function* (state) {
617
660
  });
618
661
  /** Everything the state directory holds, in one pass over the disk. */
619
662
  const inventory = Effect.gen(function* () {
620
- const fs = yield* FileSystem.FileSystem;
621
- const path = yield* Path.Path;
622
663
  const directory = yield* stateDirectory;
623
664
  const directories = /* @__PURE__ */ new Set([clonesIn, ...cuts]);
624
665
  const keys = (yield* entriesOf(directory)).filter((entry) => !directories.has(entry));
625
- const sizes = yield* Effect.forEach(keys, (entry) => Effect.orElseSucceed(Effect.map(fs.stat(path.join(directory, entry)), (info) => ByteSize.toBigInt(info.size)), () => BigInt(0)), { concurrency: 16 });
626
666
  return {
627
667
  directory,
628
668
  clones: yield* clonesOf(directory),
629
669
  cuttings: yield* cuttingsOf(directory),
630
670
  records: {
631
671
  keys: keys.length,
632
- size: ByteSize.bytes(sizes.reduce((a, b) => a + b, BigInt(0)))
672
+ size: yield* weightOf(directory, keys)
633
673
  }
634
674
  };
635
675
  }).pipe(Effect.withSpan("store.inventory"));
@@ -668,6 +708,92 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
668
708
  }
669
709
  });
670
710
  //#endregion
711
+ //#region src/adapters/picker.ts
712
+ /**
713
+ * Turns quitting into an answer rather than a failure.
714
+ *
715
+ * Bailing out of a prompt gives `None`, so no caller has to catch an error to
716
+ * learn that I walked away. The prompt itself decides what a `Some` carries.
717
+ */
718
+ const orNone = (prompt) => Effect.catchTag(prompt, "QuitError", () => Effect.succeedNone);
719
+ /**
720
+ * What a prompt looks like in this tool: the marker the rows already use, and
721
+ * the same colour for the choice I am standing on.
722
+ *
723
+ * It is set here rather than at each prompt, because this module is the only
724
+ * thing that opens one and four prompts that themed themselves would be four
725
+ * looks.
726
+ */
727
+ const theme = (paint) => paint === plain ? {
728
+ prefix: "▸",
729
+ pointer: "●"
730
+ } : {
731
+ prefix: "▸",
732
+ pointer: "●",
733
+ primaryColor: "cyan",
734
+ mutedColor: "gray"
735
+ };
736
+ /**
737
+ * What the keyboard does, said under the question.
738
+ *
739
+ * It rides in the message rather than being printed above the prompt, so it
740
+ * leaves with the prompt: a hint that outlives the answer is scrollback I did
741
+ * not ask for.
742
+ */
743
+ const moves = "↑↓ move · enter choose · q quit";
744
+ const asked$1 = (paint, message) => `${message}\n${paint.dim(moves)}`;
745
+ /** Asks which one of `choices` to act on. */
746
+ const pick = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.Select({
747
+ message: asked$1(paint, message),
748
+ choices,
749
+ theme: theme(paint)
750
+ }))));
751
+ /**
752
+ * Asks which of `choices` to act on, as many as I like.
753
+ *
754
+ * Nothing is selected to begin with, so what reaches the caller is what I
755
+ * picked rather than what I failed to unpick. Quitting is not the same as
756
+ * picking nothing: it gives `None`.
757
+ */
758
+ const choose = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.MultiSelect({
759
+ message: `${message}\n${paint.dim("↑↓ move · space pick · enter confirm · q quit")}`,
760
+ choices,
761
+ theme: theme(paint)
762
+ }))));
763
+ /**
764
+ * Asks a yes-or-no question about something that cannot be taken back.
765
+ *
766
+ * It starts on no, and walking away is no as well: the answer this returns is
767
+ * the one I typed, and every other way out of the prompt leaves the thing
768
+ * undone. A confirmation that defaulted to yes would be one keystroke, which is
769
+ * exactly what it exists to stop being.
770
+ */
771
+ const confirm = (message) => Effect.flatMap(Paint, (paint) => Effect.map(orNone(Effect.asSome(Prompt.Confirm({
772
+ message,
773
+ initial: false,
774
+ theme: theme(paint)
775
+ }))), Option.getOrElse(() => false)));
776
+ /**
777
+ * Asks for a line of prose, where having nothing to say is the ordinary answer.
778
+ *
779
+ * An empty line is no note, and that is not a failure: the prompt is optional
780
+ * by design. Quitting is the one thing it does not swallow. Ctrl-C part way
781
+ * through a list of notes means I want out of the whole command, and a prompt
782
+ * that turned it into "no note" would walk me through the rest of the list and
783
+ * then act on findings I was no longer sure about.
784
+ */
785
+ const note = (message) => Effect.map(Prompt.String({ message }), (text) => text.trim() === "" ? Option.none() : Option.some(text.trim()));
786
+ /**
787
+ * How wide the screen is, or zero where there is no screen to measure.
788
+ *
789
+ * A prompt has to fit its row on one line: a row that wraps takes the list's
790
+ * alignment with it. Nothing is piping into a prompt, so zero means the writing
791
+ * is going somewhere that does not wrap either.
792
+ */
793
+ const width = Effect.gen(function* () {
794
+ return yield* (yield* Terminal.Terminal).columns;
795
+ });
796
+ //#endregion
671
797
  //#region src/adapters/heartbeat.ts
672
798
  /** The frames of the spinner, in the order they turn. */
673
799
  const frames = [
@@ -787,6 +913,14 @@ const git = (args) => capture("git", args).pipe(Effect.catchTags({
787
913
  detail: error.stderr
788
914
  }))
789
915
  }));
916
+ /**
917
+ * `n` commits, which is the one thing this file counts out loud.
918
+ *
919
+ * It is said twice - before a session is cut and before one is taken away -
920
+ * and about the same commits both times: the ones the pull request's head does
921
+ * not have.
922
+ */
923
+ const commits = (n) => `${n} commit${n === 1 ? "" : "s"}`;
790
924
  /** A fix worktree that still holds work of mine, which nothing may cut away. */
791
925
  var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
792
926
  directory: Schema.String,
@@ -824,16 +958,14 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
824
958
  */
825
959
  const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
826
960
  const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
827
- const path = yield* Path.Path;
828
- const state = yield* stateDirectory;
829
- const clone = path.join(state, clonesIn, `${repo}.git`);
961
+ const clone = yield* cloneAt(repo);
830
962
  const bare = yield* Effect.orElseSucceed(git([
831
963
  "-C",
832
964
  clone,
833
965
  "rev-parse",
834
966
  "--is-bare-repository"
835
967
  ]), () => "");
836
- const pullRef = `refs/dw-mc/pr/${number}`;
968
+ const ref = pullRef(number);
837
969
  return {
838
970
  clone,
839
971
  head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
@@ -854,17 +986,17 @@ const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
854
986
  "--no-tags",
855
987
  "--force",
856
988
  "origin",
857
- `+refs/pull/${number}/head:${pullRef}`,
989
+ `+refs/pull/${number}/head:${ref}`,
858
990
  "+refs/heads/*:refs/heads/*"
859
991
  ]);
860
992
  return yield* git([
861
993
  "-C",
862
994
  clone,
863
995
  "rev-parse",
864
- pullRef
996
+ ref
865
997
  ]);
866
998
  })),
867
- directory: path.join(state, cut, repo, String(number))
999
+ directory: yield* cutAt(cut, repo, number)
868
1000
  };
869
1001
  });
870
1002
  /**
@@ -1025,11 +1157,11 @@ const reuseResolutions = Effect.fn("git.reuseResolutions")(function* (clone) {
1025
1157
  */
1026
1158
  const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, number, prBranch, session) {
1027
1159
  const { clone, directory, head } = yield* whereToCut(repo, number, under[session]);
1028
- const branch = `dw-mc/${session}/${number}`;
1160
+ const branch = sessionBranch(session, number);
1029
1161
  const ahead = yield* aheadOf(clone, branch, head);
1030
1162
  if (ahead > 0) return yield* new WorktreeHeld({
1031
1163
  directory,
1032
- detail: `The last fix session on ${repo}#${number} left ${ahead} commit${ahead === 1 ? "" : "s"} that the pull request's head does not have. Push them or drop them before opening another session.`
1164
+ detail: `The last fix session on ${repo}#${number} left ${commits(ahead)} that the pull request's head does not have. Push them or drop them before opening another session.`
1033
1165
  });
1034
1166
  if ((yield* worktreesOf(clone)).includes(directory)) yield* git([
1035
1167
  "-C",
@@ -1271,11 +1403,9 @@ const clear = { _tag: "clear" };
1271
1403
  * pruned or moved by hand still leaves the branch holding the commits.
1272
1404
  */
1273
1405
  const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1274
- const path = yield* Path.Path;
1275
- const state = yield* stateDirectory;
1276
- const clone = path.join(state, clonesIn, `${repo}.git`);
1277
- const directory = path.join(state, under[session], repo, String(number));
1278
- const branch = `dw-mc/${session}/${number}`;
1406
+ const clone = yield* cloneAt(repo);
1407
+ const directory = yield* cutAt(under[session], repo, number);
1408
+ const branch = sessionBranch(session, number);
1279
1409
  if ((yield* Effect.orElseSucceed(git([
1280
1410
  "-C",
1281
1411
  directory,
@@ -1298,7 +1428,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1298
1428
  "-C",
1299
1429
  clone,
1300
1430
  "rev-parse",
1301
- `refs/dw-mc/pr/${number}`
1431
+ pullRef(number)
1302
1432
  ]), () => "");
1303
1433
  if (head.trim() === "") return {
1304
1434
  _tag: "held",
@@ -1307,7 +1437,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1307
1437
  const ahead = yield* aheadOf(clone, branch, head.trim());
1308
1438
  return ahead === 0 ? clear : {
1309
1439
  _tag: "held",
1310
- detail: `${ahead} commit${ahead === 1 ? "" : "s"} that the pull request's head does not have`
1440
+ detail: `${commits(ahead)} that the pull request's head does not have`
1311
1441
  };
1312
1442
  });
1313
1443
  /**
@@ -1329,92 +1459,6 @@ const prune = Effect.fn("git.prune")(function* (clone) {
1329
1459
  ]));
1330
1460
  });
1331
1461
  //#endregion
1332
- //#region src/adapters/picker.ts
1333
- /**
1334
- * Turns quitting into an answer rather than a failure.
1335
- *
1336
- * Bailing out of a prompt gives `None`, so no caller has to catch an error to
1337
- * learn that I walked away. The prompt itself decides what a `Some` carries.
1338
- */
1339
- const orNone = (prompt) => Effect.catchTag(prompt, "QuitError", () => Effect.succeedNone);
1340
- /**
1341
- * What a prompt looks like in this tool: the marker the rows already use, and
1342
- * the same colour for the choice I am standing on.
1343
- *
1344
- * It is set here rather than at each prompt, because this module is the only
1345
- * thing that opens one and four prompts that themed themselves would be four
1346
- * looks.
1347
- */
1348
- const theme = (paint) => paint === plain ? {
1349
- prefix: "▸",
1350
- pointer: "●"
1351
- } : {
1352
- prefix: "▸",
1353
- pointer: "●",
1354
- primaryColor: "cyan",
1355
- mutedColor: "gray"
1356
- };
1357
- /**
1358
- * What the keyboard does, said under the question.
1359
- *
1360
- * It rides in the message rather than being printed above the prompt, so it
1361
- * leaves with the prompt: a hint that outlives the answer is scrollback I did
1362
- * not ask for.
1363
- */
1364
- const moves = "↑↓ move · enter choose · q quit";
1365
- const asked$1 = (paint, message) => `${message}\n${paint.dim(moves)}`;
1366
- /** Asks which one of `choices` to act on. */
1367
- const pick = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.Select({
1368
- message: asked$1(paint, message),
1369
- choices,
1370
- theme: theme(paint)
1371
- }))));
1372
- /**
1373
- * Asks which of `choices` to act on, as many as I like.
1374
- *
1375
- * Nothing is selected to begin with, so what reaches the caller is what I
1376
- * picked rather than what I failed to unpick. Quitting is not the same as
1377
- * picking nothing: it gives `None`.
1378
- */
1379
- const choose = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.MultiSelect({
1380
- message: `${message}\n${paint.dim("↑↓ move · space pick · enter confirm · q quit")}`,
1381
- choices,
1382
- theme: theme(paint)
1383
- }))));
1384
- /**
1385
- * Asks a yes-or-no question about something that cannot be taken back.
1386
- *
1387
- * It starts on no, and walking away is no as well: the answer this returns is
1388
- * the one I typed, and every other way out of the prompt leaves the thing
1389
- * undone. A confirmation that defaulted to yes would be one keystroke, which is
1390
- * exactly what it exists to stop being.
1391
- */
1392
- const confirm = (message) => Effect.flatMap(Paint, (paint) => Effect.map(orNone(Effect.asSome(Prompt.Confirm({
1393
- message,
1394
- initial: false,
1395
- theme: theme(paint)
1396
- }))), Option.getOrElse(() => false)));
1397
- /**
1398
- * Asks for a line of prose, where having nothing to say is the ordinary answer.
1399
- *
1400
- * An empty line is no note, and that is not a failure: the prompt is optional
1401
- * by design. Quitting is the one thing it does not swallow. Ctrl-C part way
1402
- * through a list of notes means I want out of the whole command, and a prompt
1403
- * that turned it into "no note" would walk me through the rest of the list and
1404
- * then act on findings I was no longer sure about.
1405
- */
1406
- const note = (message) => Effect.map(Prompt.String({ message }), (text) => text.trim() === "" ? Option.none() : Option.some(text.trim()));
1407
- /**
1408
- * How wide the screen is, or zero where there is no screen to measure.
1409
- *
1410
- * A prompt has to fit its row on one line: a row that wraps takes the list's
1411
- * alignment with it. Nothing is piping into a prompt, so zero means the writing
1412
- * is going somewhere that does not wrap either.
1413
- */
1414
- const width = Effect.gen(function* () {
1415
- return yield* (yield* Terminal.Terminal).columns;
1416
- });
1417
- //#endregion
1418
1462
  //#region src/cli/table.ts
1419
1463
  /**
1420
1464
  * The rows of a table, padded so the columns line up and with the trailing
@@ -1501,7 +1545,7 @@ const plan = (inventory) => {
1501
1545
  };
1502
1546
  };
1503
1547
  /** What the whole state directory weighs: the clones, the checkouts and the records. */
1504
- const everything = (inventory) => sum([
1548
+ const everything$1 = (inventory) => sum([
1505
1549
  ...inventory.clones.map((it) => it.size),
1506
1550
  ...inventory.cuttings.map((it) => it.size),
1507
1551
  inventory.records.size
@@ -1728,6 +1772,10 @@ const PrView = Schema.fromJsonString(Schema.Struct({
1728
1772
  reviewDecision: Schema.String,
1729
1773
  statusCheckRollup: Schema.NullOr(Schema.Array(CheckEntry))
1730
1774
  }));
1775
+ /**
1776
+ * The fields one `gh pr view` asks for, named so a test can spell the vector it
1777
+ * expects without copying the list and watching it drift.
1778
+ */
1731
1779
  const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
1732
1780
  /**
1733
1781
  * Everything about one pull request that arrives without paging through it:
@@ -2004,6 +2052,40 @@ const prConversation = Effect.fnUntraced(function* (repo, number) {
2004
2052
  }], ...threads];
2005
2053
  });
2006
2054
  //#endregion
2055
+ //#region src/cli/exit.ts
2056
+ /**
2057
+ * The failures a command owes me a sentence for rather than a stack.
2058
+ *
2059
+ * Every one of them is a machine or a file that needs fixing, and the message
2060
+ * says what to fix. Anything not named here is a fault of the tool's own, and a
2061
+ * stack is what I want to see for those.
2062
+ */
2063
+ const userFacing = [
2064
+ "ConfigMalformed",
2065
+ "GhUnavailable",
2066
+ "GhReadFailed",
2067
+ "GhUnreadable"
2068
+ ];
2069
+ /** The same, for a command that also runs `git` against the tool's own clone. */
2070
+ const userFacingAndGit = [...userFacing, "GitFailed"];
2071
+ /**
2072
+ * The same, for a command that cuts a standing worktree and opens an agent
2073
+ * session in it.
2074
+ *
2075
+ * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2076
+ * because they do the same thing to different findings: a worktree that holds
2077
+ * work of mine and an agent that would not run are the session's failures, not
2078
+ * either command's.
2079
+ */
2080
+ const userFacingAndSession = [
2081
+ ...userFacing,
2082
+ "GitFailed",
2083
+ "WorktreeHeld",
2084
+ "AgentFailed"
2085
+ ];
2086
+ /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2087
+ const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2088
+ //#endregion
2007
2089
  //#region src/domain/moment.ts
2008
2090
  const isLater = Order.isGreaterThan(DateTime.Order);
2009
2091
  /** Whether `self` happened after `other`, counting never as before anything. */
@@ -2102,6 +2184,15 @@ const order = [
2102
2184
  */
2103
2185
  const unanswered = "a comment I have not answered";
2104
2186
  /**
2187
+ * Why a PR is mine to move when a review run found something that withholds
2188
+ * the stamp.
2189
+ *
2190
+ * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2191
+ * sentence about this same number, and two spellings of it would be two
2192
+ * answers to what a blocking finding is worth.
2193
+ */
2194
+ const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2195
+ /**
2105
2196
  * The first of the rules that makes a PR mine to move, or null when none
2106
2197
  * does. The order is the order I would fix them in: a conflict makes every
2107
2198
  * other signal on the PR stale, and a red build is worth more than a comment.
@@ -2111,7 +2202,7 @@ const needsMe = (facts) => {
2111
2202
  if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2112
2203
  if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2113
2204
  if (facts.reviewDecision === "changes-requested") return "changes requested";
2114
- if (facts.blockingFindings > 0) return `${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`;
2205
+ if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2115
2206
  if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2116
2207
  return null;
2117
2208
  };
@@ -2221,2095 +2312,2158 @@ const resolve$1 = (text, registered) => {
2221
2312
  };
2222
2313
  };
2223
2314
  //#endregion
2224
- //#region src/cli/pr.ts
2225
- /** The pull request a command acts on, named the way I actually type it. */
2226
- const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
2227
- /** What to say about a reference that named no one pull request. */
2228
- const whyNothingNamed = (reference) => {
2229
- if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
2230
- const example = `${reference.repos[0] ?? "owner/name"}#28`;
2231
- return reference.repos.length === 0 ? `No repositories are registered, so a number alone names nothing. Run dw-mc init inside a repository, or name the pull request as ${example}.` : `${reference.repos.length} repositories are registered, so a number alone could be any of them. Name the pull request as ${example}.`;
2232
- };
2233
- /** The pull request the argument names, or the sentence saying why it names none. */
2234
- const named = (pr, registered) => {
2235
- const reference = resolve$1(pr, registered);
2236
- return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
2237
- };
2315
+ //#region src/domain/findings.ts
2316
+ /** Whether a review run found anything at all. */
2317
+ const Verdict = Schema.Literals(["clean", "findings"]);
2238
2318
  /**
2239
- * A domain guard's word, as the command's own failure.
2319
+ * Every severity word a review may answer with.
2240
2320
  *
2241
- * Every guard in the tool answers the same shape - the sentence saying why not,
2242
- * or null - so turning that answer into a refusal is spelled once here rather
2243
- * than beside each command that asks one.
2321
+ * The first three are ours, and the only ones a run is asked for. The rest are
2322
+ * the persona a run with no slash command carries, which grades in its own
2323
+ * words: a turn that comes back in them is worth reading rather than throwing
2324
+ * away.
2244
2325
  */
2245
- const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2246
- /**
2247
- * What the last sweep learned about one pull request, or the sentence sending
2248
- * me to a sweep.
2249
- *
2250
- * A command that reads these rather than GitHub says what the table said: the
2251
- * stamp and the cutoff a conversation is measured against are both computed
2252
- * from the facts a sweep wrote down, and asking GitHub again would make them a
2253
- * different answer from the one `dw-mc status` printed.
2254
- *
2255
- * Facts this version cannot read are facts another version of them wrote, and a
2256
- * sweep can write them again, so both cases say the same thing.
2326
+ const Spelling = Schema.Literals([
2327
+ "error",
2328
+ "warning",
2329
+ "info",
2330
+ "Critical",
2331
+ "Required",
2332
+ "Optional",
2333
+ "Nit",
2334
+ "FYI"
2335
+ ]);
2336
+ /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2337
+ const severityOf = {
2338
+ error: "error",
2339
+ warning: "warning",
2340
+ info: "info",
2341
+ Critical: "error",
2342
+ Required: "error",
2343
+ Optional: "warning",
2344
+ Nit: "info",
2345
+ FYI: "info"
2346
+ };
2347
+ const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2348
+ decode: (word) => severityOf[word],
2349
+ encode: (severity) => severity
2350
+ })));
2351
+ /** The fields both spellings of a finding share. Only the severity differs. */
2352
+ const shared = {
2353
+ file: Schema.String,
2354
+ line: Schema.Int,
2355
+ summary: Schema.String
2356
+ };
2357
+ /** One problem a review run reports, at a file and line. */
2358
+ const Finding = Schema.Struct({
2359
+ ...shared,
2360
+ severity: Severity
2361
+ });
2362
+ /**
2363
+ * What a review run found: the shape the tool keeps, and the one a fix session
2364
+ * is later handed.
2257
2365
  */
2258
- const swept = Effect.fn("pr.swept")(function* (repo, number) {
2259
- const store = yield* storeFor("prs", Facts);
2260
- const facts = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
2261
- if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2262
- return facts.value;
2366
+ const Findings = Schema.Struct({
2367
+ verdict: Verdict,
2368
+ findings: Schema.Array(Finding)
2263
2369
  });
2264
2370
  /**
2265
- * The guard reads of one command, under a heartbeat.
2266
- *
2267
- * Every command that acts on a pull request reads its guards live rather than
2268
- * off the last sweep, because each of them is about the pull request as it is
2269
- * now. That read is a second or two against GitHub before a word can be
2270
- * printed, and it used to be spent on a blank screen.
2371
+ * The same findings as a runner may spell them, which is what the second turn's
2372
+ * output is read with.
2271
2373
  *
2272
- * There is nothing to count here - two or three calls, and a number counting to
2273
- * three says less than the words do - so the line is what is being read and how
2274
- * long it has taken. It gives the heartbeat no aside, so a piped command prints
2275
- * what it always printed.
2374
+ * A word nothing maps fails here, and a failed read is a failure of the run:
2375
+ * findings the tool cannot weigh are not findings it can act on.
2276
2376
  */
2277
- const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2278
- //#endregion
2279
- //#region src/cli/row.ts
2377
+ const Reported = Schema.Struct({
2378
+ verdict: Verdict,
2379
+ findings: Schema.Array(Schema.Struct({
2380
+ ...shared,
2381
+ severity: Weighed
2382
+ }))
2383
+ });
2280
2384
  /**
2281
- * How one tracked PR is written down, wherever it is written down.
2282
- *
2283
- * The table `dw-mc status` prints and the list the picker asks me to choose
2284
- * from are the same rows, so a pull request reads the same in both and neither
2285
- * command owns how the other draws it.
2286
- *
2287
- * Colour here says one thing: which bucket the pull request is in, and so what
2288
- * it waits on. Everything else on the row is either `dim`, because it is
2289
- * context rather than state, or left alone. A row read with no colour at all
2290
- * says the same, which is what the marker is for.
2385
+ * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2291
2386
  *
2292
- * On a table, the pull request opens itself: the reference carries the URL for
2293
- * the terminal to follow, and nothing else on the row does. What it leads to is
2294
- * where the row already says it is, so a row read where no link can be followed
2295
- * - a pipe, a paste, a terminal that ignores the sequence - loses nothing.
2387
+ * It is derived from the schema the findings are kept under rather than written
2388
+ * out beside it, so a runner is asked for exactly the shape that is persisted.
2389
+ * `Reported` is wider on purpose and only on the severity: what a runner is
2390
+ * asked for is our three words, and a persona's five are read where they arrive
2391
+ * anyway rather than being asked for.
2296
2392
  */
2297
- /** The glossary's name for each bucket, which is what the heading says. */
2298
- const heading = {
2299
- "needs-me": "Needs me",
2300
- "needs-review-run": "Needs review run",
2301
- "waiting-on-others": "Waiting on others",
2302
- ready: "Ready"
2303
- };
2393
+ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2304
2394
  /**
2305
- * The mark that says which bucket a row is in without being read.
2395
+ * The findings as the Markdown a report is written in.
2306
2396
  *
2307
- * One character apiece, from the part of Unicode a terminal font has: the
2308
- * padding is counted in characters, and a glyph a terminal draws double width
2309
- * takes a column the count never gave it. How full the mark looks tracks how
2310
- * much of the pull request is done, so the column reads at a glance even where
2311
- * the colour is off.
2397
+ * It is what a schema-held run's report says: with a schema in force a run
2398
+ * answers in findings and not in prose, so the report kept beside it is written
2399
+ * from the findings themselves rather than left empty.
2312
2400
  */
2313
- const marker = {
2314
- "needs-me": "●",
2315
- "needs-review-run": "◐",
2316
- "waiting-on-others": "○",
2317
- ready: "◆"
2401
+ const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
2402
+ /** Where each severity sits against the others, so the bar can be compared with it. */
2403
+ const rank = {
2404
+ info: 0,
2405
+ warning: 1,
2406
+ error: 2
2318
2407
  };
2319
- /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2320
- const tint = (paint, bucket) => ({
2321
- "needs-me": paint.red,
2322
- "needs-review-run": paint.yellow,
2323
- "waiting-on-others": paint.dim,
2324
- ready: paint.green
2325
- })[bucket];
2326
- /** What sits between two columns: three columns of prose run into one another without a rule. */
2327
- const rule = " │ ";
2328
2408
  /**
2329
- * One row: which pull request, what it is, and what it waits on.
2330
- *
2331
- * A stamp is a mark beside the pull request rather than a column of its own, so
2332
- * a table where nothing is stamped is exactly the table it was before: the
2333
- * stamp is a thing I look for, not a thing I read every row of.
2334
- *
2335
- * The title is the only cell with give in it, so how much room it gets is the
2336
- * caller's to say: a table printed down the screen can afford a whole commit
2337
- * subject, and a row inside a prompt has a column more to carry and a frame
2338
- * around it.
2409
+ * The findings that withhold the stamp: everything at `blocksOn` or above it.
2339
2410
  *
2340
- * A named lead carries the colour for the whole row. It is the one place a
2341
- * prompt's row is coloured, and it carries no link at all: a prompt counts the
2342
- * lines it has to erase from the length of what it drew, escape sequences and
2343
- * all, so every colour on a row costs the title characters it could have shown,
2344
- * and a link costs it the whole URL. The table has no such arithmetic to keep
2345
- * straight, so its rows say it in more than one place and open the pull request
2346
- * besides.
2411
+ * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2412
+ * warnings I do not want to merge past is configured rather than coded. An
2413
+ * error blocks wherever the bar is, because nothing weighs more than one.
2347
2414
  */
2348
- const cells = (placed, stamped, room, paint, lead) => {
2349
- const { facts } = placed;
2350
- const { bucket } = placed.placement;
2351
- const say = tint(paint, bucket);
2352
- const reference = `${facts.repo}#${facts.number}`;
2353
- const named = lead === "named";
2354
- const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2355
- return named ? [
2356
- say(`${marker[bucket]} ${heading[bucket]}`),
2357
- pr,
2358
- truncate(facts.title, room),
2359
- placed.placement.reason
2360
- ] : [
2361
- `${say(marker[bucket])} ${pr}`,
2362
- paint.dim(truncate(facts.title, room)),
2363
- say(placed.placement.reason)
2364
- ];
2365
- };
2415
+ const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2366
2416
  //#endregion
2367
- //#region src/adapters/ci.ts
2417
+ //#region src/domain/review.ts
2368
2418
  /**
2369
- * What GitHub says about a pull request's checks, and the evidence a red one
2370
- * is classified on. Every read here goes through the same `gh` the rest of the
2371
- * tool does; what it owns is the checks, not the boundary.
2419
+ * What a review run came to, which is what its second turn reported.
2420
+ *
2421
+ * A failure is recorded as one and is never a clean verdict: a turn that exited
2422
+ * badly, ran out of patience or answered in a shape that does not validate has
2423
+ * found nothing, which is not the same as having found nothing wrong.
2372
2424
  */
2373
- const failing = /* @__PURE__ */ new Set([
2374
- "FAILURE",
2375
- "TIMED_OUT",
2376
- "CANCELLED",
2377
- "STARTUP_FAILURE",
2378
- "ACTION_REQUIRED",
2379
- "ERROR"
2380
- ]);
2381
- const running = /* @__PURE__ */ new Set([
2382
- "QUEUED",
2383
- "IN_PROGRESS",
2384
- "WAITING",
2385
- "PENDING",
2386
- "REQUESTED",
2387
- "EXPECTED"
2388
- ]);
2389
- const nameOf = (entry) => entry.name ?? entry.context ?? "";
2390
- const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
2391
- const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
2425
+ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2426
+ verdict: Verdict,
2427
+ findings: Schema.Array(Finding)
2428
+ }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2392
2429
  /**
2393
- * What the rollup comes to: red when anything failed, pending only while
2394
- * nothing has failed yet, green when every check that counts has passed.
2430
+ * One review run against a tracked PR at a specific head commit.
2395
2431
  *
2396
- * `ci.ignore` names the checks that do not count towards green, so a check I
2397
- * have decided to live with cannot hold a PR out of Ready.
2432
+ * It is a schema because a review run outlives the command that started it: the
2433
+ * state directory is where the next sweep learns that this head has been
2434
+ * reviewed, and where a fix session finds what there is to fix.
2398
2435
  */
2399
- const rollupState = (entries, ignore) => {
2400
- const checks = checksThatCount(entries, ignore);
2401
- if (checks.length === 0) return "none";
2402
- if (checks.some(hasFailed)) return "red";
2403
- if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
2404
- return "green";
2405
- };
2436
+ const ReviewRun = Schema.Struct({
2437
+ repo: Schema.String,
2438
+ number: Schema.Int,
2439
+ /** The head the run covers. A run never vouches for code it did not see. */
2440
+ head: Schema.String,
2441
+ /**
2442
+ * The slash command line the run opened on, or null where it opened on the
2443
+ * tool's own prompt. A report found months later says what it was asked, and a
2444
+ * record an earlier version wrote carries no such field and is forgotten.
2445
+ */
2446
+ command: Schema.NullOr(Schema.String),
2447
+ effort: Schema.NullOr(Effort),
2448
+ /**
2449
+ * The agent session the run happened in, or null where it never reached one.
2450
+ *
2451
+ * A run that would not start or exited before it said anything has no session,
2452
+ * and the run is still recorded: a failure is recorded as what it is.
2453
+ */
2454
+ sessionId: Schema.NullOr(Schema.String),
2455
+ ranAt: Schema.DateTimeUtcFromString,
2456
+ outcome: Outcome
2457
+ });
2458
+ /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2459
+ const short = (head) => head.slice(0, 7);
2406
2460
  /**
2407
- * The checks that failed and count, which are the ones there is a log to read.
2408
- *
2409
- * `ci.ignore` is applied here as well as in the rollup: a check that cannot
2410
- * hold a PR out of Ready is not one the classifier should be explaining either.
2461
+ * Where a run is kept: one key per head, so a run and the code it read cannot
2462
+ * drift apart, and a re-review replaces the run before it.
2411
2463
  */
2412
- const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
2464
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2465
+ /** Where the run's report is kept: beside the run, as the Markdown it is. */
2466
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2413
2467
  /**
2414
- * What a check reports on, out of the URL it reports at.
2468
+ * Which head a pull request was last reviewed at: an index beside `runKey` and
2469
+ * `reportKey` rather than a thing the glossary names.
2415
2470
  *
2416
- * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
2417
- * what the logs endpoint takes and the run id is what `gh run rerun` takes, so
2418
- * the two ids the tool needs are the two halves of one URL and are read
2419
- * together. A commit status points somewhere else entirely, which is null:
2420
- * there is no log of ours to read and no run of ours to re-run.
2421
- */
2422
- const reportedAt = (detailsUrl) => {
2423
- const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
2424
- return found?.[1] === void 0 || found[2] === void 0 ? null : {
2425
- run: found[1],
2426
- job: found[2]
2427
- };
2428
- };
2429
- const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
2430
- /**
2431
- * The branch a repository merges into, which is the one the first flaky signal
2432
- * asks about. An empty repository has none, and `main` is the better guess than
2433
- * failing the sweep over it.
2471
+ * A run is kept under the head it read, which answers the question a sweep asks
2472
+ * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2473
+ * head the last run was at - and this is where they read it, so neither has to
2474
+ * ask GitHub what is current before it can look anything up.
2434
2475
  */
2435
- const defaultBranch = Effect.fnUntraced(function* (repo) {
2436
- return (yield* readJson("repo view defaultBranchRef", "gh", [
2437
- "repo",
2438
- "view",
2439
- repo,
2440
- "--json",
2441
- "defaultBranchRef"
2442
- ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
2443
- });
2444
- const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
2445
- /** How far back to look for a run that reached a verdict at all. */
2446
- const recentRuns = 5;
2447
- /** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
2448
- const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
2449
- /** A run that decided something. A skipped or cancelled run says nothing either way. */
2450
- const verdicts = /* @__PURE__ */ new Set([
2451
- "failure",
2452
- "timed_out",
2453
- "success"
2454
- ]);
2476
+ const LastReviewed = Schema.Struct({ head: Schema.String });
2477
+ /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2478
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2455
2479
  /**
2456
- * Whether `workflow` is red on `branch` right now.
2480
+ * The run at one head, or none where nothing has reviewed it.
2457
2481
  *
2458
- * The newest run that reached a verdict is the whole answer: a workflow that
2459
- * broke last week and was fixed since is not red, and excusing a pull request
2460
- * for it would hide a failure that is real. A handful of runs are asked for
2461
- * because the newest ones are often skipped by a path filter.
2482
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
2483
+ * findings` all ask about one commit - and one read off the disk answers it
2484
+ * without an index to keep in step.
2485
+ *
2486
+ * Forgetting a run costs one review.
2462
2487
  */
2463
- const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
2464
- const newest = (yield* readJson("run list", "gh", [
2465
- "run",
2466
- "list",
2467
- "--repo",
2468
- repo,
2469
- "--branch",
2470
- branch,
2471
- "--workflow",
2472
- workflow,
2473
- "--limit",
2474
- String(recentRuns),
2475
- "--json",
2476
- "conclusion"
2477
- ], Runs)).find((run) => verdicts.has(run.conclusion));
2478
- return newest !== void 0 && failedRun.has(newest.conclusion);
2488
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2489
+ const runs = yield* storeFor("runs", ReviewRun);
2490
+ return yield* remembered(runs.get(runKey(repo, number, head)));
2479
2491
  });
2480
- const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
2481
- /** The repository paths a pull request changes. */
2482
- const prFiles = Effect.fnUntraced(function* (repo, number) {
2483
- return (yield* readJson("pr view files", "gh", [
2484
- "pr",
2485
- "view",
2486
- String(number),
2487
- "--repo",
2488
- repo,
2489
- "--json",
2490
- "files"
2491
- ], PrFiles)).files.map((file) => file.path);
2492
+ /** The last review run on a pull request, or none where it has had none. */
2493
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2494
+ const heads = yield* storeFor("runs", LastReviewed);
2495
+ const at = yield* remembered(heads.get(latestKey(repo, number)));
2496
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2492
2497
  });
2493
2498
  /**
2494
- * How much of a failing job's log is kept.
2495
- *
2496
- * A job that failed prints what went wrong at the end, so the tail is the part
2497
- * worth classifying, and a build that logged a whole dependency tree is not
2498
- * worth holding in memory beyond it.
2499
- */
2500
- const logTailBytes = 65536;
2501
- /**
2502
- * What one failing job printed, from the end.
2499
+ * What a run reported, or null where it reported nothing at all.
2503
2500
  *
2504
- * `gh api` refuses a response carrying terminal escape sequences unless it is
2505
- * told otherwise, and a runner log is full of them. Verified by running it: the
2506
- * endpoint answers with the plain log once the flag is passed.
2501
+ * A failure is not a clean verdict: a run that could not report has found
2502
+ * nothing, which is not the same as having found nothing wrong. Everything that
2503
+ * reads a run's findings reads them through here, so the distinction is drawn
2504
+ * once rather than at every caller that might forget it.
2507
2505
  */
2508
- const jobLog = Effect.fnUntraced(function* (repo, jobId) {
2509
- const log = yield* capture("gh", [
2510
- "api",
2511
- `repos/${repo}/actions/jobs/${jobId}/logs`,
2512
- "--allow-escape-sequences"
2513
- ]).pipe(Effect.catchTags({
2514
- PlatformError: (error) => Effect.fail(unavailable(error)),
2515
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
2516
- command: "api job logs",
2517
- detail: error.stderr
2518
- }))
2519
- }));
2520
- return log.length <= logTailBytes ? log : log.slice(-65536);
2521
- });
2506
+ const reportedBy = (run) => run.outcome._tag === "reported" ? {
2507
+ verdict: run.outcome.verdict,
2508
+ findings: run.outcome.findings
2509
+ } : null;
2522
2510
  /**
2523
- * The workflow runs behind the failing checks that count, each named once.
2524
- *
2525
- * One broken run usually fails several jobs, and re-running it once per failing
2526
- * job would start the same run over and over.
2511
+ * Why a run reported nothing, or null where it reported.
2527
2512
  *
2528
- * `ci.ignore` decides which checks get a run into this list, and no more than
2529
- * that: a run is re-run whole, so an ignored job sharing a run with a counted
2530
- * one is re-run beside it. What the setting buys is that an ignored check is
2531
- * never on its own a reason to spend CI minutes.
2513
+ * The sibling of `reportedBy`, and here for the same reason: the two halves of
2514
+ * an outcome are read through one place each rather than re-narrowed at every
2515
+ * caller.
2532
2516
  */
2533
- const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
2534
- const reported = reportedAt(check.detailsUrl);
2535
- return reported === null ? [] : [reported.run];
2536
- }))];
2517
+ const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2537
2518
  /**
2538
- * Asks GitHub to run one workflow run's failed jobs again.
2519
+ * Whether the files changed since the last run are worth paying for another.
2539
2520
  *
2540
- * `--failed` is what makes this cheap: the jobs that passed are not run a
2541
- * second time, so a flaky job costs the minutes it costs and no more. This is a
2542
- * write to GitHub, and it is one of the three ADR 0002 allows.
2521
+ * The question is deliberately about what changed rather than how much: one
2522
+ * line outside the `docs_only` globs is code nobody has reviewed, and a
2523
+ * thousand lines inside them are still prose.
2543
2524
  */
2544
- const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
2545
- yield* capture("gh", [
2546
- "run",
2547
- "rerun",
2548
- runId,
2549
- "--repo",
2550
- repo,
2551
- "--failed"
2552
- ]).pipe(Effect.catchTags({
2553
- PlatformError: (error) => Effect.fail(unavailable(error)),
2554
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
2555
- command: "run rerun",
2556
- detail: error.stderr
2557
- }))
2558
- }));
2559
- });
2560
- //#endregion
2561
- //#region src/domain/flaky.ts
2525
+ const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
2562
2526
  /**
2563
- * The failures that are flaky wherever they appear: a machine, a network or a
2564
- * runner giving up, never a test disagreeing with the code.
2527
+ * The re-run rule: the head this run is skipped against, or null where it runs.
2565
2528
  *
2566
- * `ci.flaky_patterns` adds to this list rather than replacing it, because the
2567
- * failures a repository of mine produces are extra ones, not different ones.
2529
+ * A review costs real money and minutes of my attention, and a typo fix is not
2530
+ * worth either. Four things are never skipped, because the rule is here to save
2531
+ * me a review and not to stand between me and one I asked for: a pull request
2532
+ * with no run behind it, a run that reported nothing, a comparison GitHub would
2533
+ * not answer, and anything that changed outside the globs. A head that has
2534
+ * already had a run changed nothing at all, which is the one case that needs no
2535
+ * comparison to decide.
2568
2536
  */
2569
- const builtInPatterns = [
2570
- "timed out",
2571
- "deadline exceeded",
2572
- "ETIMEDOUT",
2573
- "ECONNRESET",
2574
- "ECONNREFUSED",
2575
- "connection refused",
2576
- "socket hang up",
2577
- "lock timeout",
2578
- "could not obtain lock",
2579
- "runner lost communication",
2580
- "The runner has received a shutdown signal",
2581
- "net/http: request canceled",
2582
- "ResourceExhausted",
2583
- "Too many open files",
2584
- "no space left on device"
2585
- ];
2586
- const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
2537
+ const skippedSince = (asked, docsOnly) => {
2538
+ if (asked.last === null || reportedBy(asked.last) === null) return null;
2539
+ const changed = asked.last.head === asked.head ? [] : asked.changed;
2540
+ return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2541
+ };
2542
+ /** What a run was opened on, as the report says it. */
2543
+ const askedOf$2 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2587
2544
  /**
2588
- * The changed file the log names, preferring one it spells in full.
2545
+ * The report as it is written down: what it is of, then what the run said.
2589
2546
  *
2590
- * A bare file name is worth matching - a stack trace often prints nothing else
2591
- * - and it is worth matching second, because a name as ordinary as `index.ts`
2592
- * belongs to more repositories than mine.
2593
- */
2594
- const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2595
- /**
2596
- * Whether the log names a file called `base` rather than some longer name
2597
- * ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
2598
- * complaining about.
2547
+ * The heading is the whole point of writing it rather than storing the prose
2548
+ * alone - a file found months later says which pull request, which commit and
2549
+ * what the run was asked, without anything else having to be open.
2599
2550
  */
2600
- const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
2601
- const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
2551
+ const reportDocument = (run, title, prose) => [
2552
+ `# ${run.repo}#${run.number} ${title}`,
2553
+ "",
2554
+ `- head: ${run.head}`,
2555
+ `- run: ${askedOf$2(run)}`,
2556
+ `- ran: ${DateTime.formatIso(run.ranAt)}`,
2557
+ "",
2558
+ prose.trim(),
2559
+ ""
2560
+ ].join("\n");
2602
2561
  /**
2603
- * The flaky pattern the log matches, mine before the built-in ones.
2562
+ * Whether `head` has the review it needs.
2604
2563
  *
2605
- * A pattern is text and not a regular expression: it comes out of a
2606
- * configuration file I edit by hand, where a stray `*` should cost me a missed
2607
- * match and never a crash.
2564
+ * A run that reported nothing does not count, which is the same rule
2565
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
2566
+ * nothing wrong.
2608
2567
  */
2609
- const matchedPattern = (log, patterns) => {
2610
- const haystack = log.toLowerCase();
2611
- return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
2568
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2569
+ /** The findings at one head that withhold the stamp. */
2570
+ const blockingIn = (run, blocksOn) => {
2571
+ const found = run === null ? null : reportedBy(run);
2572
+ return found === null ? [] : blocking(found.findings, blocksOn);
2612
2573
  };
2613
2574
  /**
2614
- * Whether a red CI is mine to fix, and why.
2575
+ * What the review runs on `head` say about it, for the stamp to rest on.
2615
2576
  *
2616
- * Two of the signals say flaky and one says legitimate, and the one outranks
2617
- * the two: a log that names a file this pull request changes is the failure
2618
- * pointing at my own work, and a workflow that is broken everywhere does not
2619
- * stop it pointing there.
2577
+ * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
2578
+ * recorded against one head, and a head with no run of its own has not been
2579
+ * reviewed however many sweeps have seen the pull request. A run that could not
2580
+ * report findings does not count either: its verdict is what takes a pull
2581
+ * request out of Needs review run, and it reached none.
2620
2582
  *
2621
- * Everything else that is unexplained is mine as well. The two mistakes do not
2622
- * cost the same - a real failure called flaky is a broken pull request nobody
2623
- * tells me about, while a flake called mine costs me one look - so the default
2624
- * is the one I can recover from.
2583
+ * It is one function because the two callers are a sweep and `dw-mc merge`, and
2584
+ * the second exists to land what the first only describes: two spellings of
2585
+ * this would be two answers to whether a head has been reviewed.
2625
2586
  */
2626
- const classify = (evidence, flakyPatterns) => {
2627
- const named = namedChangedFile(evidence.log, evidence.changedFiles);
2628
- if (named !== null) return {
2629
- classification: "legitimate",
2630
- reason: `the log names ${named}, which this PR changes`
2631
- };
2632
- const redOnDefaultBranch = evidence.alsoRedOnDefaultBranch[0];
2633
- const pattern = matchedPattern(evidence.log, flakyPatterns);
2634
- const excuses = [redOnDefaultBranch === void 0 ? null : `${redOnDefaultBranch} is red on the default branch too`, pattern === null ? null : `the log matches "${pattern}"`].filter((it) => it !== null);
2635
- return excuses.length === 0 ? {
2636
- classification: "legitimate",
2637
- reason: "nothing explains the failure"
2638
- } : {
2639
- classification: "flaky",
2640
- reason: excuses.join(", and ")
2587
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
2588
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
2589
+ return {
2590
+ reviewRunHead: reviewedBy(run) ? head : null,
2591
+ blockingFindings: blockingIn(run, blocksOn).length
2641
2592
  };
2642
- };
2643
- /**
2644
- * How many failing jobs the log is read from.
2645
- *
2646
- * One broken workflow usually fails several jobs with the same cause, and the
2647
- * logs are the one read here that is measured in megabytes.
2648
- */
2649
- const loggedJobs = 3;
2650
- /** The values of `xs` that `f` has one for. */
2651
- const filterMap = (xs, f) => xs.flatMap((x) => {
2652
- const b = f(x);
2653
- return b === null ? [] : [b];
2654
2593
  });
2655
- /** No evidence at all, which is what an unreadable CI comes to. */
2656
- const nothing$1 = {
2657
- alsoRedOnDefaultBranch: [],
2658
- changedFiles: [],
2659
- log: ""
2594
+ //#endregion
2595
+ //#region src/cli/pr.ts
2596
+ /** The pull request a command acts on, named the way I actually type it. */
2597
+ const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
2598
+ /** What to say about a reference that named no one pull request. */
2599
+ const whyNothingNamed = (reference) => {
2600
+ if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
2601
+ const example = `${reference.repos[0] ?? "owner/name"}#28`;
2602
+ return reference.repos.length === 0 ? `No repositories are registered, so a number alone names nothing. Run dw-mc init inside a repository, or name the pull request as ${example}.` : `${reference.repos.length} repositories are registered, so a number alone could be any of them. Name the pull request as ${example}.`;
2603
+ };
2604
+ /** The pull request the argument names, or the sentence saying why it names none. */
2605
+ const named = (pr, registered) => {
2606
+ const reference = resolve$1(pr, registered);
2607
+ return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
2660
2608
  };
2661
2609
  /**
2662
- * What a red CI looks like to the classifier.
2610
+ * What a command that acts on one pull request opens with: which pull request
2611
+ * it is, and what the configuration says about its repository.
2663
2612
  *
2664
- * A read that fails costs its own signal and nothing else. GitHub drops an
2665
- * Actions log after ninety days, so a pull request open that long would
2666
- * otherwise lose its row over a log nobody can fetch any more - and a missing
2667
- * signal only ever moves the verdict towards legitimate, which is the answer
2668
- * that puts the pull request in front of me rather than hiding it.
2613
+ * Nine commands ask the file the same three questions before they do anything
2614
+ * else, and asking them here is what keeps the answers the same: which
2615
+ * repositories are registered decides what a bare `28` may name, and a command
2616
+ * that read the file its own way would resolve a different pull request from
2617
+ * the one beside it.
2618
+ *
2619
+ * `settings` and `launcher` come back whether or not this command wants them,
2620
+ * because both are a merge of records already in hand and neither reads
2621
+ * anything. The file itself does not, so nothing downstream keeps a copy of it.
2669
2622
  */
2670
- const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entries, ignore) {
2671
- const failed = failedChecks(entries, ignore);
2672
- const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
2673
- const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
2674
- const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
2675
- if (branch === null) return nothing$1;
2676
- const [alsoRed, changedFiles, logs] = yield* Effect.all([
2677
- Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
2678
- Effect.orElseSucceed(prFiles(repo, number), () => []),
2679
- Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
2680
- ], { concurrency: 3 });
2623
+ const forPr = Effect.fn("pr.forPr")(function* (pr) {
2624
+ const file = Option.getOrElse(yield* read, () => ({}));
2625
+ const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
2681
2626
  return {
2682
- alsoRedOnDefaultBranch: alsoRed.flat(),
2683
- changedFiles,
2684
- log: logs.join("\n")
2627
+ repo,
2628
+ number,
2629
+ settings: settingsFor(file, repo),
2630
+ launcher: launcherOf(file)
2685
2631
  };
2686
2632
  });
2687
2633
  /**
2688
- * Why a red CI is excused, or null where it is mine to fix.
2634
+ * A domain guard's word, as the command's own failure.
2689
2635
  *
2690
- * Reading the evidence and classifying it is one act, so it is one function:
2691
- * a sweep writes what it returns down as `ciFlaky`, and `dw-mc rerun` asks it
2692
- * again live. Two callers asking the same question have to get the same answer,
2693
- * which they cannot if each of them spells the question out.
2636
+ * Every guard in the tool answers the same shape - the sentence saying why not,
2637
+ * or null - so turning that answer into a refusal is spelled once here rather
2638
+ * than beside each command that asks one.
2694
2639
  */
2695
- const flakyReason = Effect.fn("flaky.flakyReason")(function* (repo, number, entries, ignore, patterns) {
2696
- const verdict = classify(yield* evidenceFor(repo, number, entries, ignore), patterns);
2697
- return verdict.classification === "flaky" ? verdict.reason : null;
2698
- });
2699
- //#endregion
2700
- //#region src/domain/quiet.ts
2701
- /** The pulse of a PR a previous sweep recorded. */
2702
- const pulseOf = (facts) => ({
2703
- head: facts.head,
2704
- checks: facts.checks,
2705
- newestHumanCommentAt: facts.newestHumanCommentAt
2706
- });
2640
+ const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2707
2641
  /**
2708
- * Whether a PR is where the last sweep left it.
2642
+ * What the last sweep learned about one pull request, or the sentence sending
2643
+ * me to a sweep.
2709
2644
  *
2710
- * A quiet PR keeps the facts it already had rather than being read out again,
2711
- * so a sweep over many pull requests spends its time on the few that moved.
2645
+ * A command that reads these rather than GitHub says what the table said: the
2646
+ * stamp and the cutoff a conversation is measured against are both computed
2647
+ * from the facts a sweep wrote down, and asking GitHub again would make them a
2648
+ * different answer from the one `dw-mc status` printed.
2649
+ *
2650
+ * Facts that are missing and facts this version cannot read come to the same
2651
+ * sentence, because a sweep can write them again either way.
2712
2652
  */
2713
- const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
2714
- //#endregion
2715
- //#region src/domain/rebase.ts
2653
+ const swept = Effect.fn("pr.swept")(function* (repo, number) {
2654
+ const store = yield* storeFor("prs", Facts);
2655
+ const facts = yield* remembered(store.get(prKey(repo, number)));
2656
+ if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2657
+ return facts.value;
2658
+ });
2716
2659
  /**
2717
- * How many pull requests this one stands on.
2660
+ * The review run whose findings are the current ones, or the sentence saying
2661
+ * there are none.
2718
2662
  *
2719
- * A branch is walked to what it merges into and on from there, however deep the
2720
- * stack goes. Every pull request the walk has already counted is left alone,
2721
- * which is what keeps two branches that merge into each other from being walked
2722
- * around forever.
2663
+ * The last run on the pull request is what "current" means here, and it is read
2664
+ * off the state directory rather than worked out from GitHub: the commands that
2665
+ * ask are ones I run inside a fix session, where another round trip to GitHub
2666
+ * buys nothing the run it is about to fix does not already say.
2723
2667
  */
2724
- const ancestorsOf = (pr, open, seen) => {
2725
- let count = 0;
2726
- let current = pr;
2727
- for (;;) {
2728
- const parent = open.find((it) => it.head === current.base && !seen.has(it.number));
2729
- if (parent === void 0) return count;
2730
- seen.add(parent.number);
2731
- count += 1;
2732
- current = parent;
2733
- }
2734
- };
2668
+ const currentRun = Effect.fn("pr.currentRun")(function* (repo, number) {
2669
+ const run = yield* lastRun(repo, number);
2670
+ if (Option.isNone(run)) return yield* new CliError.UserError({ cause: `No review run on ${repo}#${number}. Run dw-mc review ${number} first.` });
2671
+ return run.value;
2672
+ });
2735
2673
  /**
2736
- * How deep the stack goes above this pull request.
2674
+ * The guard reads of one command, under a heartbeat.
2737
2675
  *
2738
- * Two branches cut from the same one are not two stacks deep, they are two
2739
- * branches, so what counts is the deepest single line of them rather than how
2740
- * many pull requests stand above it in total.
2676
+ * Every command that acts on a pull request reads its guards live rather than
2677
+ * off the last sweep, because each of them is about the pull request as it is
2678
+ * now. That read is a second or two against GitHub before a word can be
2679
+ * printed, and it used to be spent on a blank screen.
2680
+ *
2681
+ * There is nothing to count here - two or three calls, and a number counting to
2682
+ * three says less than the words do - so the line is what is being read and how
2683
+ * long it has taken. It gives the heartbeat no aside, so a piped command prints
2684
+ * what it always printed.
2741
2685
  */
2742
- const descendantsOf = (pr, open, seen) => {
2743
- let deepest = 0;
2744
- for (const child of open.filter((it) => it.base === pr.head && !seen.has(it.number))) {
2745
- seen.add(child.number);
2746
- deepest = Math.max(deepest, 1 + descendantsOf(child, open, seen));
2747
- }
2748
- return deepest;
2749
- };
2686
+ const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2687
+ //#endregion
2688
+ //#region src/cli/row.ts
2750
2689
  /**
2751
- * Where a pull request sits in its stack, or null where it is in none.
2690
+ * How one tracked PR is written down, wherever it is written down.
2752
2691
  *
2753
- * A stack is read off the branches alone: a pull request that merges into
2754
- * another pull request's branch, or that another one merges into, is part of
2755
- * one. The tool does not understand stacks and never drives them, so this
2756
- * exists to recognise one and say where the pull request sits in it.
2692
+ * The table `dw-mc status` prints and the list the picker asks me to choose
2693
+ * from are the same rows, so a pull request reads the same in both and neither
2694
+ * command owns how the other draws it.
2695
+ *
2696
+ * Colour here says one thing: which bucket the pull request is in, and so what
2697
+ * it waits on. Everything else on the row is either `dim`, because it is
2698
+ * context rather than state, or left alone. A row read with no colour at all
2699
+ * says the same, which is what the marker is for.
2700
+ *
2701
+ * On a table, the pull request opens itself: the reference carries the URL for
2702
+ * the terminal to follow, and nothing else on the row does. What it leads to is
2703
+ * where the row already says it is, so a row read where no link can be followed
2704
+ * - a pipe, a paste, a terminal that ignores the sequence - loses nothing.
2757
2705
  */
2758
- const stackOf = (number, open) => {
2759
- const pr = open.find((it) => it.number === number);
2760
- if (pr === void 0) return null;
2761
- const seen = /* @__PURE__ */ new Set([number]);
2762
- const below = ancestorsOf(pr, open, seen);
2763
- const above = descendantsOf(pr, open, seen);
2764
- return below === 0 && above === 0 ? null : {
2765
- position: below + 1,
2766
- length: below + above + 1
2767
- };
2706
+ /** The glossary's name for each bucket, which is what the heading says. */
2707
+ const heading = {
2708
+ "needs-me": "Needs me",
2709
+ "needs-review-run": "Needs review run",
2710
+ "waiting-on-others": "Waiting on others",
2711
+ ready: "Ready"
2768
2712
  };
2769
2713
  /**
2770
- * Why this branch is nobody's to touch here, or null where it is mine.
2714
+ * The mark that says which bucket a row is in without being read.
2771
2715
  *
2772
- * These are the guards about the branch rather than about what is done to it,
2773
- * which is why they are their own and why they say nothing about pushing: who
2774
- * authored the pull request and where its branch lives is the boundary itself -
2775
- * a branch somebody else authored and a branch in a fork are not mine to work
2776
- * on, whatever else is true of them and whichever command asks. A stack comes
2777
- * next, and a pull request the stack was not read from counts as one, because a
2778
- * stack the tool cannot see is one it could drive: the tool does not understand
2779
- * stacks, so the one thing it has to say about one is where the pull request
2780
- * sits in it.
2716
+ * One character apiece, from the part of Unicode a terminal font has: the
2717
+ * padding is counted in characters, and a glyph a terminal draws double width
2718
+ * takes a column the count never gave it. How full the mark looks tracks how
2719
+ * much of the pull request is done, so the column reads at a glance even where
2720
+ * the colour is off.
2781
2721
  */
2782
- const boundary = (branch) => {
2783
- const where = `${branch.repo}#${branch.number}`;
2784
- if (!branch.mine) return `${where} is not mine. dw-mc works on branches I author and on nothing else.`;
2785
- if (branch.fromFork) return `${where} is opened from a fork, so its branch is not in ${branch.repo}. dw-mc works only on a branch in the repository it read.`;
2786
- if (!branch.listed) return `${where} was not among the open pull requests of ${branch.repo}, so nothing here can say whether it is in a stack. Read it again before touching the branch.`;
2787
- if (branch.stack !== null) return `${where} is ${branch.stack.position} of ${branch.stack.length} in a stack. dw-mc does not understand stacks and will not drive one; rebase it with whatever built the stack.`;
2788
- return null;
2722
+ const marker = {
2723
+ "needs-me": "●",
2724
+ "needs-review-run": "◐",
2725
+ "waiting-on-others": "○",
2726
+ ready: "◆"
2789
2727
  };
2728
+ /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2729
+ const tint = (paint, bucket) => ({
2730
+ "needs-me": paint.red,
2731
+ "needs-review-run": paint.yellow,
2732
+ "waiting-on-others": paint.dim,
2733
+ ready: paint.green
2734
+ })[bucket];
2735
+ /** What sits between two columns: three columns of prose run into one another without a rule. */
2736
+ const rule = " │ ";
2790
2737
  /**
2791
- * Why this branch is not one to rebase, or null where it is.
2738
+ * One row: which pull request, what it is, and what it waits on.
2792
2739
  *
2793
- * This is the single place the guards live, and they matter more than the
2794
- * rebase itself: a force push is the one write the tool makes that can lose
2795
- * work, and every rule here is about it never being a surprise.
2740
+ * A stamp is a mark beside the pull request rather than a column of its own, so
2741
+ * a table where nothing is stamped is exactly the table it was before: the
2742
+ * stamp is a thing I look for, not a thing I read every row of.
2796
2743
  *
2797
- * Being off is said first, because a repository that has not turned rebase on
2798
- * has decided the question and nothing else about the pull request changes it.
2799
- * The branch's own guards come next. CI is last and costs the most to get
2800
- * wrong - rebasing while a run is in flight cancels the run I am waiting on,
2801
- * and a red build is mine to fix where it is.
2802
- */
2803
- const decide$3 = (situation) => {
2804
- const where = `${situation.repo}#${situation.number}`;
2805
- if (!situation.enabled) return `Rebase is off for ${situation.repo}. Set rebase.enabled: true for it in the config to turn it on, so a force push is never a surprise.`;
2806
- const refused = boundary(situation);
2807
- if (refused !== null) return refused;
2808
- if (situation.checks === "pending") return `CI is still running on ${where}. A rebase now would cancel the run you are waiting on.`;
2809
- if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
2810
- return null;
2744
+ * The title is the only cell with give in it, so how much room it gets is the
2745
+ * caller's to say: a table printed down the screen can afford a whole commit
2746
+ * subject, and a row inside a prompt has a column more to carry and a frame
2747
+ * around it.
2748
+ *
2749
+ * A named lead carries the colour for the whole row. It is the one place a
2750
+ * prompt's row is coloured, and it carries no link at all: a prompt counts the
2751
+ * lines it has to erase from the length of what it drew, escape sequences and
2752
+ * all, so every colour on a row costs the title characters it could have shown,
2753
+ * and a link costs it the whole URL. The table has no such arithmetic to keep
2754
+ * straight, so its rows say it in more than one place and open the pull request
2755
+ * besides.
2756
+ */
2757
+ const cells = (placed, stamped, room, paint, lead) => {
2758
+ const { facts } = placed;
2759
+ const { bucket } = placed.placement;
2760
+ const say = tint(paint, bucket);
2761
+ const reference = `${facts.repo}#${facts.number}`;
2762
+ const named = lead === "named";
2763
+ const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2764
+ return named ? [
2765
+ say(`${marker[bucket]} ${heading[bucket]}`),
2766
+ pr,
2767
+ truncate(facts.title, room),
2768
+ placed.placement.reason
2769
+ ] : [
2770
+ `${say(marker[bucket])} ${pr}`,
2771
+ paint.dim(truncate(facts.title, room)),
2772
+ say(placed.placement.reason)
2773
+ ];
2811
2774
  };
2775
+ //#endregion
2776
+ //#region src/domain/comments.ts
2812
2777
  /**
2813
- * A rebase that conflicted: the head it conflicted at and the files it stopped
2814
- * on.
2778
+ * One thread's share of a strand, cut to what is worth reading.
2815
2779
  *
2816
- * The head is what the record is scoped to, as it is for a withdrawn stamp: a
2817
- * conflict is about the code the branch is at, so it lasts exactly as long as
2818
- * that code is what the pull request is. A branch that moved is a branch
2819
- * nothing here has tried to rebase yet.
2780
+ * A review thread is answered as a whole, so a single comment newer than my
2781
+ * last activity brings the whole thread with it: the follow-up on its own is a
2782
+ * line answering something the screen does not show, which is what sends me to
2783
+ * the browser.
2820
2784
  *
2821
- * The paths are what makes the conflict something to open: `a rebase
2822
- * conflicted` cannot tell a stale lockfile from half the pull request. They are
2823
- * an optional key rather than a required one so a record an older version wrote
2824
- * still reads, and a conflict with no paths still puts the pull request in
2825
- * Needs me.
2785
+ * The pull request's own comments are not a thread but a stream, and there is
2786
+ * no reply to lose the question of, so they are cut comment by comment.
2826
2787
  */
2827
- const Conflict = Schema.Struct({
2828
- head: Schema.String,
2829
- paths: Schema.optionalKey(Schema.Array(Schema.String))
2830
- });
2788
+ const only = (thread, keep, since, all) => {
2789
+ const strand = thread.comments.filter((it) => keep(it.bot));
2790
+ const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
2791
+ return comments.length === 0 ? [] : [{
2792
+ ...thread,
2793
+ comments
2794
+ }];
2795
+ };
2831
2796
  /**
2832
- * The conflict a rebase last left on this pull request, or null where it left
2833
- * none.
2797
+ * The threads worth putting on screen, given what I have already done.
2834
2798
  *
2835
- * A record this version cannot read is one another version of it wrote, and a
2836
- * conflict is worth a bucket rather than a failed sweep: forgetting it costs
2837
- * the pull request one reason to be in Needs me, where failing here would cost
2838
- * me the whole table.
2839
- */
2840
- const conflictFor = Effect.fn("rebase.conflictFor")(function* (repo, number) {
2841
- const store = yield* storeFor("rebases", Conflict);
2842
- const conflict = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
2843
- return Option.getOrNull(conflict);
2844
- });
2845
- /** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
2846
- const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
2847
- yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
2848
- head,
2849
- paths
2850
- });
2851
- });
2852
- //#endregion
2853
- //#region src/domain/findings.ts
2854
- /** Whether a review run found anything at all. */
2855
- const Verdict = Schema.Literals(["clean", "findings"]);
2856
- /**
2857
- * Every severity word a review may answer with.
2799
+ * `since` is my last activity on the pull request - the later of my last
2800
+ * comment and my last commit - which is the same moment the bucket rule
2801
+ * measures a comment against. Showing exactly what is newer than it means the
2802
+ * command answers the question the bucket asked.
2858
2803
  *
2859
- * The first three are ours, and the only ones a run is asked for. The rest are
2860
- * the persona a run with no slash command carries, which grades in its own
2861
- * words: a turn that comes back in them is worth reading rather than throwing
2862
- * away.
2804
+ * A thread somebody resolved and one against code that is gone are left out:
2805
+ * neither is something to answer, and both are still there to read under
2806
+ * `--all`, which asks for the whole conversation and so measures nothing
2807
+ * against anything.
2863
2808
  */
2864
- const Spelling = Schema.Literals([
2865
- "error",
2866
- "warning",
2867
- "info",
2868
- "Critical",
2869
- "Required",
2870
- "Optional",
2871
- "Nit",
2872
- "FYI"
2873
- ]);
2874
- /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2875
- const severityOf = {
2876
- error: "error",
2877
- warning: "warning",
2878
- info: "info",
2879
- Critical: "error",
2880
- Required: "error",
2881
- Optional: "warning",
2882
- Nit: "info",
2883
- FYI: "info"
2884
- };
2885
- const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2886
- decode: (word) => severityOf[word],
2887
- encode: (severity) => severity
2888
- })));
2889
- /** The fields both spellings of a finding share. Only the severity differs. */
2890
- const shared = {
2891
- file: Schema.String,
2892
- line: Schema.Int,
2893
- summary: Schema.String
2809
+ const shown = (threads, options) => {
2810
+ const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
2811
+ return {
2812
+ people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
2813
+ bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2814
+ };
2894
2815
  };
2895
- /** One problem a review run reports, at a file and line. */
2896
- const Finding = Schema.Struct({
2897
- ...shared,
2898
- severity: Severity
2899
- });
2900
- /**
2901
- * What a review run found: the shape the tool keeps, and the one a fix session
2902
- * is later handed.
2903
- */
2904
- const Findings = Schema.Struct({
2905
- verdict: Verdict,
2906
- findings: Schema.Array(Finding)
2907
- });
2816
+ //#endregion
2817
+ //#region src/cli/comments.ts
2818
+ const allFlag$1 = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2819
+ /** Where a thread hangs: a line of the diff, or the pull request itself. */
2820
+ const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2908
2821
  /**
2909
- * The same findings as a runner may spell them, which is what the second turn's
2910
- * output is read with.
2822
+ * What is true of a thread beyond where it hangs.
2911
2823
  *
2912
- * A word nothing maps fails here, and a failed read is a failure of the run:
2913
- * findings the tool cannot weigh are not findings it can act on.
2824
+ * It is only ever printed under `--all`, which is the only way a settled thread
2825
+ * reaches the screen at all, and it is there so that reading one is never
2826
+ * reading it as something still open.
2914
2827
  */
2915
- const Reported = Schema.Struct({
2916
- verdict: Verdict,
2917
- findings: Schema.Array(Schema.Struct({
2918
- ...shared,
2919
- severity: Weighed
2920
- }))
2921
- });
2828
+ const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
2922
2829
  /**
2923
- * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2830
+ * One thread as a block: where it hangs, then everybody who said something in
2831
+ * it, then what they said in full.
2924
2832
  *
2925
- * It is derived from the schema the findings are kept under rather than written
2926
- * out beside it, so a runner is asked for exactly the shape that is persisted.
2927
- * `Reported` is wider on purpose and only on the severity: what a runner is
2928
- * asked for is our three words, and a persona's five are read where they arrive
2929
- * anyway rather than being asked for.
2833
+ * In full because a review comment is usually a paragraph carrying a
2834
+ * suggestion, and a first line is what sends me to the browser this command
2835
+ * exists to replace. No diff hunk with it: the code is on this machine, under
2836
+ * the path the heading already prints.
2930
2837
  */
2931
- const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2838
+ const block$1 = (thread, paint) => [`${paint.bold(where$1(thread))}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, ...thread.comments.flatMap((comment) => [` ${paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`)}`, ...comment.body.split("\n").map((line) => ` ${line}`)])];
2839
+ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
2932
2840
  /**
2933
- * The findings as the Markdown a report is written in.
2841
+ * The conversation on screen: people first, then a rule, then the bots.
2934
2842
  *
2935
- * It is what a schema-held run's report says: with a schema in force a run
2936
- * answers in findings and not in prose, so the report kept beside it is written
2937
- * from the findings themselves rather than left empty.
2843
+ * The rule is there so the two are never read as one list. A bot's comment is
2844
+ * observed and never answered, and the bucket rules ignore bots for exactly
2845
+ * this reason.
2846
+ *
2847
+ * A bot is cut at the same moment I am measured against, because the window is
2848
+ * what has happened since I last acted rather than what is owed an answer. A
2849
+ * verdict older than my last push is one I have already had the chance to read,
2850
+ * and `--all` is where it still is.
2938
2851
  */
2939
- const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
2940
- /** Where each severity sits against the others, so the bar can be compared with it. */
2941
- const rank = {
2942
- info: 0,
2943
- warning: 1,
2944
- error: 2
2852
+ const lines$2 = (view, paint) => {
2853
+ const people = view.people.map((thread) => block$1(thread, paint));
2854
+ const bots = view.bots.map((thread) => block$1(thread, paint));
2855
+ return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
2856
+ };
2857
+ /** What to say where there is nothing to print, which depends on why there is not. */
2858
+ const nothing$1 = (facts, all) => {
2859
+ const pr = `${facts.repo}#${facts.number}`;
2860
+ if (all) return [`Nothing has been said on ${pr}.`];
2861
+ const placement = place(facts);
2862
+ const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2863
+ return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
2864
+ "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
2865
+ `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
2866
+ rest
2867
+ ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
2945
2868
  };
2946
2869
  /**
2947
- * The findings that withhold the stamp: everything at `blocksOn` or above it.
2870
+ * The conversation on one tracked pull request, and nothing else.
2948
2871
  *
2949
- * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2950
- * warnings I do not want to merge past is configured rather than coded. An
2951
- * error blocks wherever the bar is, because nothing weighs more than one.
2952
- */
2953
- const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2954
- //#endregion
2955
- //#region src/domain/review.ts
2956
- /**
2957
- * What a review run came to, which is what its second turn reported.
2872
+ * What it shows by default is what the bucket rule measures: the comments newer
2873
+ * than the later of my last comment and my last commit, which are the ones that
2874
+ * put the pull request in Needs me. Reading it answers the question the table
2875
+ * asked.
2958
2876
  *
2959
- * A failure is recorded as one and is never a clean verdict: a turn that exited
2960
- * badly, ran out of patience or answered in a shape that does not validate has
2961
- * found nothing, which is not the same as having found nothing wrong.
2962
- */
2963
- const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2964
- verdict: Verdict,
2965
- findings: Schema.Array(Finding)
2966
- }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2967
- /**
2968
- * One review run against a tracked PR at a specific head commit.
2877
+ * The cutoff is read off the last sweep rather than worked out again here, so
2878
+ * the command shows exactly what `dw-mc status` counted rather than a second
2879
+ * opinion about it.
2969
2880
  *
2970
- * It is a schema because a review run outlives the command that started it: the
2971
- * state directory is where the next sweep learns that this head has been
2972
- * reviewed, and where a fix session finds what there is to fix.
2881
+ * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2882
+ * (ADR 0002). Reading is the whole command.
2973
2883
  */
2974
- const ReviewRun = Schema.Struct({
2975
- repo: Schema.String,
2976
- number: Schema.Int,
2977
- /** The head the run covers. A run never vouches for code it did not see. */
2978
- head: Schema.String,
2979
- /**
2980
- * The slash command line the run opened on, or null where it opened on the
2981
- * tool's own prompt. A report found months later says what it was asked, and a
2982
- * record an earlier version wrote carries no such field and is forgotten.
2983
- */
2984
- command: Schema.NullOr(Schema.String),
2985
- effort: Schema.NullOr(Effort),
2986
- /**
2987
- * The agent session the run happened in, or null where it never reached one.
2988
- *
2989
- * A run that would not start or exited before it said anything has no session,
2990
- * and the run is still recorded: a failure is recorded as what it is.
2991
- */
2992
- sessionId: Schema.NullOr(Schema.String),
2993
- ranAt: Schema.DateTimeUtcFromString,
2994
- outcome: Outcome
2995
- });
2996
- /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2997
- const short = (head) => head.slice(0, 7);
2884
+ const comments = Command.make("comments", {
2885
+ pr: prArgument,
2886
+ all: allFlag$1
2887
+ }, Effect.fn("comments")(function* ({ all, pr }) {
2888
+ const { number, repo } = yield* forPr(pr);
2889
+ const facts = yield* swept(repo, number);
2890
+ const paint = yield* Paint;
2891
+ const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2892
+ since: later(facts.myLastCommentAt, facts.myLastCommitAt),
2893
+ all
2894
+ });
2895
+ if (view.people.length === 0 && view.bots.length === 0) {
2896
+ yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2897
+ return;
2898
+ }
2899
+ yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
2900
+ yield* Console.log("");
2901
+ yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2902
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
2903
+ //#endregion
2904
+ //#region src/cli/findings.ts
2905
+ /** The findings as the JSON the schema defines, rather than as this file spells it. */
2906
+ const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
2907
+ const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
2908
+ /** What a run's findings come to in one line, against the bar that blocks. */
2909
+ const summary = (found, blocksOn) => {
2910
+ if (found.findings.length === 0) return "clean, nothing to fix";
2911
+ const blocked = blocking(found.findings, blocksOn).length;
2912
+ return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
2913
+ };
2914
+ /** Which run these findings are, and what they come to: the line above the list. */
2915
+ const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
2998
2916
  /**
2999
- * Where a run is kept: one key per head, so a run and the code it read cannot
3000
- * drift apart, and a re-review replaces the run before it.
2917
+ * The findings one to a line, in the order the run reported them, ruled so the
2918
+ * three columns read apart.
3001
2919
  */
3002
- const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
3003
- /** Where the run's report is kept: beside the run, as the Markdown it is. */
3004
- const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2920
+ const lines$1 = (found) => table(found.findings.map((finding) => [
2921
+ `${finding.file}:${finding.line}`,
2922
+ finding.severity,
2923
+ finding.summary
2924
+ ]), rule);
3005
2925
  /**
3006
- * Which head a pull request was last reviewed at: an index beside `runKey` and
3007
- * `reportKey` rather than a thing the glossary names.
2926
+ * What the run reported, or the sentence saying it reported nothing at all.
3008
2927
  *
3009
- * A run is kept under the head it read, which answers the question a sweep asks
3010
- * of one head. The re-run rule and `dw-mc findings` ask the other one - which
3011
- * head the last run was at - and this is where they read it, so neither has to
3012
- * ask GitHub what is current before it can look anything up.
2928
+ * A run that failed is not a clean one: a pipe must never be handed "no
2929
+ * findings" when what happened is that nothing could be read.
3013
2930
  */
3014
- const LastReviewed = Schema.Struct({ head: Schema.String });
3015
- /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
3016
- const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2931
+ const whatItFound = (run) => {
2932
+ const found = reportedBy(run);
2933
+ return found === null ? Effect.fail(new CliError.UserError({ cause: `The review run on ${short(run.head)} reported no findings: ${run.outcome._tag === "failed" ? run.outcome.detail : ""}\nRun dw-mc review ${run.number} --force to run it again.` })) : Effect.succeed(found);
2934
+ };
3017
2935
  /**
3018
- * The run at one head, or none where nothing has reviewed it.
2936
+ * What the current review run found, as a table or as the JSON it is kept in.
3019
2937
  *
3020
- * A head is where the question is asked - the stamp, the bucket and `dw-mc
3021
- * findings` all ask about one commit - and one read off the disk answers it
3022
- * without an index to keep in step.
2938
+ * `--json` is the whole point of the command: it prints the findings and
2939
+ * nothing else, so I can pipe them anywhere, and a fix session inside an open
2940
+ * agent reads exactly what the tool recorded rather than a retelling of it.
3023
2941
  *
3024
- * A run this version cannot read is a run another version of this record wrote,
3025
- * and the state directory is a cache of work that can be done again: forgetting
3026
- * it costs one review, where failing here would cost me the command I asked for.
2942
+ * A run that failed prints no findings and fails: a review run that could not
2943
+ * report has found nothing, which is not the same as having found nothing
2944
+ * wrong, and a pipe must never be handed the second when the first is true.
3027
2945
  */
3028
- const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
3029
- const runs = yield* storeFor("runs", ReviewRun);
3030
- return yield* Effect.orElseSucceed(runs.get(runKey(repo, number, head)), () => Option.none());
3031
- });
3032
- /** The last review run on a pull request, or none where it has had none. */
3033
- const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
3034
- const heads = yield* storeFor("runs", LastReviewed);
3035
- const at = yield* Effect.orElseSucceed(heads.get(latestKey(repo, number)), () => Option.none());
3036
- return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
3037
- });
2946
+ const findings = Command.make("findings", {
2947
+ pr: prArgument,
2948
+ json: jsonFlag
2949
+ }, Effect.fn("findings")(function* ({ json, pr }) {
2950
+ const { number, repo, settings } = yield* forPr(pr);
2951
+ const run = yield* currentRun(repo, number);
2952
+ const found = yield* whatItFound(run);
2953
+ if (json) {
2954
+ yield* Console.log(yield* asJson$2(found));
2955
+ return;
2956
+ }
2957
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
2958
+ for (const line of lines$1(found)) yield* Console.log(` ${line}`);
2959
+ }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
2960
+ //#endregion
2961
+ //#region src/adapters/agent.ts
3038
2962
  /**
3039
- * What a run reported, or null where it reported nothing at all.
2963
+ * How one turn of Claude Code is spawned and given up on, and what a turn that
2964
+ * answers against a schema comes back with.
3040
2965
  *
3041
- * A failure is not a clean verdict: a run that could not report has found
3042
- * nothing, which is not the same as having found nothing wrong. Everything that
3043
- * reads a run's findings reads them through here, so the distinction is drawn
3044
- * once rather than at every caller that might forget it.
2966
+ * Every turn is reached this way, so the spawn, the patience and the one failure
2967
+ * they can end in live here rather than once per turn.
3045
2968
  */
3046
- const reportedBy = (run) => run.outcome._tag === "reported" ? {
3047
- verdict: run.outcome.verdict,
3048
- findings: run.outcome.findings
3049
- } : null;
2969
+ /** A review run that would not start, would not finish, or finished badly. */
2970
+ var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
2971
+ /** The program that was spawned, which is what a search for it has to name. */
2972
+ program: Schema.String,
2973
+ detail: Schema.String
2974
+ }) {
2975
+ get message() {
2976
+ return `The ${this.program} review run failed: ${this.detail}`;
2977
+ }
2978
+ };
3050
2979
  /**
3051
- * Why a run reported nothing, or null where it reported.
2980
+ * Failures in the name of the program that was spawned.
3052
2981
  *
3053
- * The sibling of `reportedBy`, and here for the same reason: the two halves of
3054
- * an outcome are read through one place each rather than re-narrowed at every
3055
- * caller.
2982
+ * Where the launcher starts `claude` through another program, it is that
2983
+ * program that would not start or exited badly, and saying `claude` sends the
2984
+ * search to the wrong process.
3056
2985
  */
3057
- const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2986
+ const failedBy = (program) => (detail) => new AgentFailed({
2987
+ program,
2988
+ detail
2989
+ });
3058
2990
  /**
3059
- * Whether the files changed since the last run are worth paying for another.
2991
+ * How long each turn gets before it is given up on.
3060
2992
  *
3061
- * The question is deliberately about what changed rather than how much: one
3062
- * line outside the `docs_only` globs is code nobody has reviewed, and a
3063
- * thousand lines inside them are still prose.
3064
- */
3065
- const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
3066
- /**
3067
- * The re-run rule: the head this run is skipped against, or null where it runs.
2993
+ * The review is the turn that thinks, and a high-effort one that fans out to
2994
+ * subagents takes real minutes, so its limit is there to catch a run that has
2995
+ * stopped rather than one that is slow. The second turn reads no code and
2996
+ * decides nothing - the review it reports on is already in the session it
2997
+ * resumes - and every run of it by hand came back in seconds.
3068
2998
  *
3069
- * A review costs real money and minutes of my attention, and a typo fix is not
3070
- * worth either. Four things are never skipped, because the rule is here to save
3071
- * me a review and not to stand between me and one I asked for: a pull request
3072
- * with no run behind it, a run that reported nothing, a comparison GitHub would
3073
- * not answer, and anything that changed outside the globs. A head that has
3074
- * already had a run changed nothing at all, which is the one case that needs no
3075
- * comparison to decide.
2999
+ * Either way, a command that hangs forever is worse than one that says it
3000
+ * failed: a review I walked away from is one I need to be able to come back to.
3076
3001
  */
3077
- const skippedSince = (asked, docsOnly) => {
3078
- if (asked.last === null || reportedBy(asked.last) === null) return null;
3079
- const changed = asked.last.head === asked.head ? [] : asked.changed;
3080
- return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
3002
+ const patience = {
3003
+ reviewing: Duration.minutes(45),
3004
+ reporting: Duration.minutes(5)
3081
3005
  };
3082
- /** What a run was opened on, as the report says it. */
3083
- const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
3084
3006
  /**
3085
- * The report as it is written down: what it is of, then what the run said.
3007
+ * One turn of the launcher in `directory`, with `read` over its standard output.
3086
3008
  *
3087
- * The heading is the whole point of writing it rather than storing the prose
3088
- * alone - a file found months later says which pull request, which commit and
3089
- * what the run was asked, without anything else having to be open.
3009
+ * The launcher's own arguments go in front of the turn's, because they are what
3010
+ * gets `claude` started at all. The two output streams are drained together,
3011
+ * because draining one to the end first can block a run that is still writing to
3012
+ * the other. Every way a turn can fail to finish comes back from here as a
3013
+ * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3014
+ * to translate - a turn that never comes back included.
3090
3015
  */
3091
- const reportDocument = (run, title, prose) => [
3092
- `# ${run.repo}#${run.number} ${title}`,
3093
- "",
3094
- `- head: ${run.head}`,
3095
- `- run: ${askedOf$1(run)}`,
3096
- `- ran: ${DateTime.formatIso(run.ranAt)}`,
3097
- "",
3098
- prose.trim(),
3099
- ""
3100
- ].join("\n");
3016
+ const turn = Effect.fnUntraced(function* (options) {
3017
+ const [program, ...prefix] = options.command;
3018
+ const failed = failedBy(program);
3019
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3020
+ const running = Effect.gen(function* () {
3021
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3022
+ cwd: options.directory,
3023
+ stdin: "pipe"
3024
+ })), (error) => failed(error.message));
3025
+ const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3026
+ const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3027
+ if (exitCode !== 0) return yield* failed(stderr.trim() === "" ? `${program} exited ${exitCode}` : stderr.trim());
3028
+ return got;
3029
+ });
3030
+ return yield* Effect.timeoutOrElse(running, {
3031
+ duration: options.patience.duration,
3032
+ orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
3033
+ });
3034
+ });
3035
+ //#endregion
3036
+ //#region src/adapters/claude.ts
3101
3037
  /**
3102
- * Whether `head` has the review it needs.
3103
- *
3104
- * A run that reported nothing does not count, which is the same rule
3105
- * `reportedBy` draws everywhere else: a failure has found nothing, not found
3106
- * nothing wrong.
3038
+ * Claude Code: a review on a slash command, a review on the tool's own prompt,
3039
+ * and the sessions I steer.
3107
3040
  */
3108
- const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
3109
- /** The findings at one head that withhold the stamp. */
3110
- const blockingIn = (run, blocksOn) => {
3111
- const found = run === null ? null : reportedBy(run);
3112
- return found === null ? [] : blocking(found.findings, blocksOn);
3113
- };
3114
3041
  /**
3115
- * What the review runs on `head` say about it, for the stamp to rest on.
3116
- *
3117
- * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
3118
- * recorded against one head, and a head with no run of its own has not been
3119
- * reviewed however many sweeps have seen the pull request. A run that could not
3120
- * report findings does not count either: its verdict is what takes a pull
3121
- * request out of Needs review run, and it reached none.
3122
- *
3123
- * It is one function because the two callers are a sweep and `dw-mc merge`, and
3124
- * the second exists to land what the first only describes: two spellings of
3125
- * this would be two answers to whether a head has been reviewed.
3042
+ * The two events of a stream-json run this reads, as the runner really writes
3043
+ * them. Every other field of both, and every other event, is ignored: a
3044
+ * transcript carries hooks, rate limits, thinking and tool results, and a
3045
+ * version that adds another must not stop a run from being read.
3126
3046
  */
3127
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
3128
- const run = Option.getOrNull(yield* runAt(repo, number, head));
3129
- return {
3130
- reviewRunHead: reviewedBy(run) ? head : null,
3131
- blockingFindings: blockingIn(run, blocksOn).length
3132
- };
3047
+ const Working = Schema.Struct({
3048
+ type: Schema.Literal("assistant"),
3049
+ message: Schema.Struct({ content: Schema.Array(Schema.Struct({
3050
+ type: Schema.String,
3051
+ name: Schema.optionalKey(Schema.String),
3052
+ text: Schema.optionalKey(Schema.String)
3053
+ })) })
3133
3054
  });
3134
- //#endregion
3135
- //#region src/cli/sweep.ts
3136
- const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
3137
- const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
3055
+ const Ended = Schema.Struct({
3056
+ type: Schema.Literal("result"),
3057
+ subtype: Schema.String,
3058
+ is_error: Schema.Boolean,
3059
+ session_id: Schema.String,
3060
+ result: Schema.optionalKey(Schema.String),
3061
+ /** What a turn given a JSON schema validated, which this hands on unread. */
3062
+ structured_output: Schema.optionalKey(Schema.Unknown)
3063
+ });
3064
+ const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3065
+ const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3066
+ const heardIn = (line) => {
3067
+ const blocks = Option.match(asWorking(line), {
3068
+ onNone: () => [],
3069
+ onSome: (event) => event.message.content
3070
+ });
3071
+ return {
3072
+ tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
3073
+ said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
3074
+ };
3075
+ };
3138
3076
  /**
3139
- * The facts about one tracked PR, read from GitHub and kept on disk.
3077
+ * The result a turn ended on, or the failure it really was.
3140
3078
  *
3141
- * The cheap reads happen every time, because they are what says whether the PR
3142
- * moved. The commits are asked for only when it did: `gh` returns every commit
3143
- * message in full, and on a PR that is where the last sweep left it that whole
3144
- * read buys a timestamp the state directory already has.
3079
+ * A turn that said nothing this can read and a turn Claude Code itself calls an
3080
+ * error are both failures: `subtype` is where a run that hit its turn limit or
3081
+ * lost its connection says so, and its `result` is the only word on why.
3145
3082
  */
3146
- const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, settings) {
3147
- const view = yield* prView(found.repo, found.number);
3148
- const [onThePr, inReviews] = yield* Effect.all([prComments(found.repo, found.number), prReviews(found.repo, found.number)], { concurrency: 2 });
3149
- const comments = [...onThePr, ...inReviews];
3150
- const checks = rollupState(view.statusCheckRollup, settings.ci.ignore);
3151
- const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
3152
- const key = prKey(found.repo, found.number);
3153
- const previous = Option.getOrUndefined(yield* Effect.orElseSucceed(store.get(key), () => Option.none()));
3154
- const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
3155
- const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
3156
- head: view.headRefOid,
3157
- checks,
3158
- newestHumanCommentAt
3159
- }) ? previous : void 0;
3160
- const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
3161
- const ciFlaky = checks !== "red" ? null : quiet !== void 0 ? quiet.ciFlaky : yield* flakyReason(found.repo, found.number, view.statusCheckRollup, settings.ci.ignore, settings.ci.flaky_patterns);
3162
- const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
3163
- const facts = {
3164
- repo: found.repo,
3165
- number: found.number,
3166
- title: view.title,
3167
- url: view.url,
3168
- draft: view.isDraft,
3169
- head: view.headRefOid,
3170
- mergeable: mergeabilityOf(view.mergeable),
3171
- reviewDecision: reviewDecisionOf(view.reviewDecision),
3172
- checks,
3173
- ciFlaky,
3174
- rebaseConflictAt,
3175
- newestHumanCommentAt,
3176
- myLastCommentAt: newest(writtenBy(comments, me)),
3177
- myLastCommitAt,
3178
- ...reviewed
3083
+ const ended = (program, result) => {
3084
+ const failed = failedBy(program);
3085
+ if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
3086
+ const { is_error, result: lastWord, subtype } = result.value;
3087
+ return is_error || subtype !== "success" ? Effect.fail(failed(`${subtype}: ${lastWord ?? "nothing else was said"}`)) : Effect.succeed(result.value);
3088
+ };
3089
+ /**
3090
+ * A Claude Code `stream-json` turn, read as it arrives: what it reached for goes
3091
+ * to `onTool` while the run is still going, and what it said and how it ended
3092
+ * are what comes back.
3093
+ *
3094
+ * Both shapes of review read a turn the same way, so the fold is here rather
3095
+ * than once per shape.
3096
+ */
3097
+ const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3098
+ const heard = heardIn(line);
3099
+ return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
3100
+ line,
3101
+ heard
3102
+ });
3103
+ }), Stream.runFold(() => ({
3104
+ said: [],
3105
+ result: Option.none()
3106
+ }), (soFar, { heard, line }) => ({
3107
+ said: [...soFar.said, ...heard.said],
3108
+ result: Option.orElse(asResult(line), () => soFar.result)
3109
+ })));
3110
+ /**
3111
+ * One review run on a slash command, headless, in `directory`.
3112
+ *
3113
+ * The run is in the foreground and says what it is doing as it does it, which
3114
+ * is what `onTool` is for: a review takes minutes, and a terminal that prints
3115
+ * nothing for minutes is one I stop trusting.
3116
+ *
3117
+ * `--json-schema` is never passed here: verified by running it, the flag beside
3118
+ * `/code-review` breaks the run, which is why a slash command costs a second
3119
+ * turn that resumes the session and asks for the findings. My own instructions
3120
+ * ride on `--append-system-prompt` rather than on the command's own line,
3121
+ * because what a slash command does with its arguments is its business and not
3122
+ * this tool's.
3123
+ *
3124
+ * `--comment` is the flag that makes the built-in review post on the pull
3125
+ * request, and it is never passed either (ADR 0002). The report is everything
3126
+ * the run said on its own turns rather than the `result` alone: verified by
3127
+ * running it, a repository whose review command fans out to subagents can end on
3128
+ * a remark about them, and the report is the turn before that.
3129
+ */
3130
+ const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3131
+ const [program] = options.launcher.command;
3132
+ const run = yield* turn({
3133
+ command: options.launcher.command,
3134
+ directory: options.directory,
3135
+ args: [
3136
+ "-p",
3137
+ options.line,
3138
+ "--output-format",
3139
+ "stream-json",
3140
+ "--verbose",
3141
+ ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3142
+ ...options.model === null ? [] : ["--model", options.model]
3143
+ ],
3144
+ patience: {
3145
+ turn: "the review",
3146
+ duration: patience.reviewing
3147
+ },
3148
+ read: transcript(options.onTool)
3149
+ });
3150
+ const { result: lastWord, session_id } = yield* ended(program, run.result);
3151
+ const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
3152
+ if (report === "") return yield* failedBy(program)("the run came back with an empty report");
3153
+ return {
3154
+ report,
3155
+ sessionId: session_id
3156
+ };
3157
+ }, Effect.scoped);
3158
+ /**
3159
+ * What the second turn asks for.
3160
+ *
3161
+ * It asks for a report of what was already said rather than for another look:
3162
+ * the prose is the review, and this turn is only what makes it machine
3163
+ * readable. The shape it must answer in arrives as a JSON schema beside it, so
3164
+ * the prompt does not describe the schema twice.
3165
+ */
3166
+ const reportFindings = [
3167
+ "Report the findings of the review you just gave as structured output.",
3168
+ "Every finding carries the file it is in as a repository path, the line it is at,",
3169
+ "its severity and a one-sentence summary.",
3170
+ "The verdict is clean when there is nothing to report and findings otherwise.",
3171
+ "Report nothing you did not already say."
3172
+ ].join(" ");
3173
+ /**
3174
+ * The second turn of a review run: the prose the first one wrote, back as
3175
+ * findings that validate.
3176
+ *
3177
+ * It resumes the first turn's session rather than reading the diff again, which
3178
+ * is what makes it cheap and what makes it accurate - verified by running it,
3179
+ * the line numbers it reports beat the ones the prose gives. The output is
3180
+ * handed on as it arrived: what the findings must look like belongs to the
3181
+ * domain, and the schema the run is held to comes in from there too.
3182
+ *
3183
+ * Every way this can end badly ends as an `AgentFailed`, because a review run
3184
+ * that could not report is a failure and never a clean verdict.
3185
+ */
3186
+ const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3187
+ const [program] = options.launcher.command;
3188
+ const printed = yield* turn({
3189
+ command: options.launcher.command,
3190
+ directory: options.directory,
3191
+ patience: {
3192
+ turn: "the findings turn",
3193
+ duration: patience.reporting
3194
+ },
3195
+ args: [
3196
+ "-p",
3197
+ "--resume",
3198
+ options.sessionId,
3199
+ reportFindings,
3200
+ "--output-format",
3201
+ "json",
3202
+ "--json-schema",
3203
+ options.jsonSchema
3204
+ ],
3205
+ read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
3206
+ });
3207
+ const { structured_output } = yield* ended(program, asResult(printed.trim()));
3208
+ if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
3209
+ return structured_output;
3210
+ }, Effect.scoped);
3211
+ /**
3212
+ * One review run of the tool's own review prompt, in `directory`.
3213
+ *
3214
+ * It is one turn rather than two: verified by running it, `--json-schema` beside
3215
+ * an ordinary prompt gives both the prose the run wrote and the
3216
+ * `structured_output` it validated, where the same flag on a slash command
3217
+ * breaks the run. The schema arrives as inline JSON and never as a path - a path
3218
+ * is where Claude Code reports `--json-schema is not valid JSON`.
3219
+ */
3220
+ const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3221
+ const [program] = options.launcher.command;
3222
+ const run = yield* turn({
3223
+ command: options.launcher.command,
3224
+ directory: options.directory,
3225
+ patience: {
3226
+ turn: "the review",
3227
+ duration: patience.reviewing
3228
+ },
3229
+ args: [
3230
+ "-p",
3231
+ options.prompt,
3232
+ "--output-format",
3233
+ "stream-json",
3234
+ "--verbose",
3235
+ "--json-schema",
3236
+ options.jsonSchema,
3237
+ ...options.model === null ? [] : ["--model", options.model]
3238
+ ],
3239
+ read: transcript(options.onTool)
3240
+ });
3241
+ const { session_id, structured_output } = yield* ended(program, run.result);
3242
+ if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
3243
+ const prose = run.said.join("\n\n").trim();
3244
+ return {
3245
+ findings: structured_output,
3246
+ sessionId: session_id,
3247
+ prose: prose === "" ? null : prose
3248
+ };
3249
+ }, Effect.scoped);
3250
+ /**
3251
+ * One review run, in whichever shape it was configured in.
3252
+ *
3253
+ * A slash command takes two turns and the tool's own prompt takes one, which is
3254
+ * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3255
+ * the same answer back either way.
3256
+ *
3257
+ * The second turn's failure is kept beside the first turn's prose rather than
3258
+ * replacing it. A review that ran and could not report is still worth reading,
3259
+ * and it is recorded as the failure it is.
3260
+ */
3261
+ const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3262
+ const { directory, jsonSchema, launcher, model, onTool } = options;
3263
+ if (options.turn._tag === "prompt") {
3264
+ const run = yield* promptReview({
3265
+ launcher,
3266
+ directory,
3267
+ prompt: options.turn.text,
3268
+ model,
3269
+ jsonSchema,
3270
+ onTool
3271
+ });
3272
+ return {
3273
+ sessionId: run.sessionId,
3274
+ prose: run.prose,
3275
+ findings: Result.succeed(run.findings)
3276
+ };
3277
+ }
3278
+ const run = yield* commandReview({
3279
+ launcher,
3280
+ directory,
3281
+ line: options.turn.line,
3282
+ instructions: options.turn.instructions,
3283
+ model,
3284
+ onTool
3285
+ });
3286
+ const findings = yield* Effect.result(findingsTurn({
3287
+ launcher,
3288
+ directory,
3289
+ sessionId: run.sessionId,
3290
+ jsonSchema
3291
+ }));
3292
+ return {
3293
+ sessionId: run.sessionId,
3294
+ prose: run.report,
3295
+ findings
3179
3296
  };
3180
- yield* store.set(key, facts);
3181
- return facts;
3182
3297
  });
3183
- /** A read that came back, or the trouble it came back with instead. */
3184
- const attempt = (where, read) => read.pipe(Effect.map((got) => ({
3185
- got,
3186
- troubles: []
3187
- })), Effect.catch((error) => Effect.succeed({
3188
- got: [],
3189
- troubles: [{
3190
- where,
3191
- detail: error.message
3192
- }]
3193
- })));
3194
- const gather = (attempts) => ({
3195
- got: attempts.flatMap((it) => it.got),
3196
- troubles: attempts.flatMap((it) => it.troubles)
3298
+ /**
3299
+ * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3300
+ * handed straight to it.
3301
+ *
3302
+ * The launcher's `fix_args` go here and nowhere else: they are the flags of
3303
+ * every session I steer - the one on findings and the one on a conflict - which
3304
+ * no headless review turn wants. They sit in front of the
3305
+ * prompt, because `claude` takes its flags before its positional argument.
3306
+ *
3307
+ * This is the one place a run is not read: the three streams are inherited,
3308
+ * so what is on the screen is the session itself and not a transcript of it,
3309
+ * and what I type reaches it. The child is not detached for the same reason -
3310
+ * a detached child sits outside the terminal's foreground process group, where
3311
+ * neither my keystrokes nor Ctrl-C would reach it.
3312
+ *
3313
+ * There is no patience here either. A session I steer lasts as long as I am in
3314
+ * it, and a timeout would be the tool closing a session I was still working in.
3315
+ *
3316
+ * What comes back is the code the session ended on. A session I left with
3317
+ * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3318
+ * than failing on it; only a `claude` that would not start at all is a failure.
3319
+ */
3320
+ const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3321
+ const [program, ...prefix] = options.launcher.command;
3322
+ const failed = failedBy(program);
3323
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3324
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [
3325
+ ...prefix,
3326
+ ...options.launcher.fix_args,
3327
+ options.prompt
3328
+ ], {
3329
+ cwd: options.directory,
3330
+ stdin: "inherit",
3331
+ stdout: "inherit",
3332
+ stderr: "inherit",
3333
+ detached: false
3334
+ })), (error) => failed(error.message));
3335
+ return yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3336
+ }, Effect.scoped);
3337
+ //#endregion
3338
+ //#region src/domain/fix.ts
3339
+ /** One finding I chose to act on, carrying what I think about it. */
3340
+ const Chosen = Schema.Struct({
3341
+ ...Finding.fields,
3342
+ note: Schema.optionalKey(Schema.String)
3197
3343
  });
3198
- /** How many reads of GitHub are in flight at once. */
3199
- const concurrency = 4;
3200
- /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
3201
- const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
3202
- /** How the heartbeat of a sweep reads, wherever a command turns one. */
3203
- const saying$1 = (swept) => (since) => [
3204
- "sweeping",
3205
- swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
3206
- since
3207
- ].join(" · ");
3208
3344
  /**
3209
- * One pass over every tracked PR, and nothing else: a sweep only ever reads.
3345
+ * What a fix session is handed: the findings I picked, and the review run they
3346
+ * came from.
3347
+ *
3348
+ * The head is in it because a fix session opens on the commit that was
3349
+ * reviewed, and a finding's line means nothing away from it.
3350
+ */
3351
+ const Selection = Schema.Struct({
3352
+ repo: Schema.String,
3353
+ number: Schema.Int,
3354
+ head: Schema.String,
3355
+ findings: Schema.Array(Chosen)
3356
+ });
3357
+ /** The selection as the JSON the schema defines, rather than as this file spells it. */
3358
+ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3359
+ /**
3360
+ * The prompt a fix session opens on: what these findings are, and the findings
3361
+ * themselves as JSON.
3362
+ *
3363
+ * The findings go in verbatim rather than described, because a re-description
3364
+ * is where a file, a line or my own note quietly changes. A note outranks the
3365
+ * finding it is on: the finding is what the review thought, the note is what I
3366
+ * think, and I am the one who picked it.
3367
+ *
3368
+ * Pushing is mine either way, and `commits` says whether committing is too.
3369
+ * The tool itself never commits and never pushes; what the session may do
3370
+ * inside the worktree is my call, made once in `fix.commits` or for one session
3371
+ * with the flag.
3372
+ */
3373
+ const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3374
+ `These are the findings I picked from a dw-mc review run on ${selection.repo}#${selection.number}, at ${short(selection.head)}, the commit their lines are counted from.`,
3375
+ "Work through them one at a time. Where a finding carries a note, the note is mine and outranks the finding's own summary; where it carries none, the summary is the whole brief.",
3376
+ commits ? `Commit what you change, one logical change to a commit. Do not push: I read the commits and push them myself.` : `Do not commit and do not push: I do both myself when I have read what you changed.`,
3377
+ json
3378
+ ].join("\n\n"));
3379
+ /**
3380
+ * Why these findings cannot be fixed where the pull request now is, or nothing
3381
+ * where they can.
3382
+ *
3383
+ * A pull request that moved since its last review run has findings at lines
3384
+ * that may no longer be there, and a worktree cut at the new head would carry
3385
+ * them into code they were never about. Reviewing again is cheap next to fixing
3386
+ * the wrong thing.
3387
+ */
3388
+ const staleAt = (number, run, now) => run === now ? null : `The findings are from ${short(run)} and the pull request is now at ${short(now)}. Run dw-mc review ${number} again to review the head you would be fixing.`;
3389
+ //#endregion
3390
+ //#region src/cli/fix.ts
3391
+ const printFlag$1 = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
3392
+ const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this session commit what it changes, over what the repository configured"), Flag.optional);
3393
+ /**
3394
+ * The findings to pick from, each on the line `dw-mc findings` gives it.
3210
3395
  *
3211
- * Every repository and every pull request is read on its own, so one of them
3212
- * failing costs me its rows and leaves the rest of the table standing. What
3213
- * failed comes back beside the facts rather than instead of them.
3396
+ * The rows come from there rather than being built again here, so the list I
3397
+ * pick from and the list I read are the same list. A row that does not fit the
3398
+ * screen is cut: a prompt draws its own frame around the row, and a row that
3399
+ * wraps takes the whole list's alignment with it.
3400
+ */
3401
+ const choicesOf$1 = (found, screen) => {
3402
+ const rows = lines$1(found);
3403
+ const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3404
+ return found.findings.map((finding, index) => ({
3405
+ title: truncate(rows[index] ?? finding.summary, room),
3406
+ value: finding
3407
+ }));
3408
+ };
3409
+ /**
3410
+ * Each picked finding with whatever I have to say about it.
3214
3411
  *
3215
- * `report` is told how far the pass has got, every time it gets further. What
3216
- * that is worth saying is the caller's, which is why it is handed a count and
3217
- * not a sentence.
3412
+ * The note is asked for one finding at a time, in the order I see them, and
3413
+ * having nothing to say is the ordinary answer rather than a step I have to get
3414
+ * past.
3218
3415
  */
3219
- const sweep = Effect.fn("sweep")(function* (report) {
3220
- const file = Option.getOrElse(yield* read, () => ({}));
3221
- const repos = Object.keys(file.repos ?? {}).toSorted();
3222
- if (repos.length === 0) return {
3223
- repos,
3224
- facts: [],
3225
- troubles: []
3226
- };
3227
- const store = yield* storeFor("prs", Facts);
3228
- const me = yield* viewer;
3229
- let searched = 0;
3230
- yield* report({
3231
- _tag: "searching",
3232
- done: 0,
3233
- of: repos.length
3234
- });
3235
- const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
3236
- searched = searched + 1;
3237
- return report({
3238
- _tag: "searching",
3239
- done: searched,
3240
- of: repos.length
3241
- });
3242
- }), { concurrency }));
3243
- let read$1 = 0;
3244
- yield* report({
3245
- _tag: "reading",
3246
- done: 0,
3247
- of: found.got.length
3248
- });
3249
- const swept = gather(yield* Effect.forEach(found.got, (pr) => Effect.tap(attempt(`${pr.repo}#${pr.number}`, Effect.map(sweepPr(store, me, pr, settingsFor(file, pr.repo)), (facts) => [facts])), () => {
3250
- read$1 = read$1 + 1;
3251
- return report({
3252
- _tag: "reading",
3253
- done: read$1,
3254
- of: found.got.length
3255
- });
3256
- }), { concurrency }));
3257
- return {
3258
- repos,
3259
- facts: swept.got,
3260
- troubles: [...found.troubles, ...swept.troubles]
3261
- };
3416
+ const noted = Effect.fn("fix.noted")(function* (picked) {
3417
+ const chosen = [];
3418
+ for (const finding of picked) {
3419
+ const said = yield* note(`Note on ${finding.file}:${finding.line}, or nothing`);
3420
+ chosen.push(Option.match(said, {
3421
+ onNone: () => finding,
3422
+ onSome: (text) => ({
3423
+ ...finding,
3424
+ note: text
3425
+ })
3426
+ }));
3427
+ }
3428
+ return chosen;
3262
3429
  });
3263
3430
  /**
3264
- * A sweep under its heartbeat, which is how every command that sweeps runs one.
3431
+ * A fix session: the findings I picked, in an agent session I steer.
3265
3432
  *
3266
- * The three of them want the same line, so they say it once here rather than
3267
- * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
3268
- * prints exactly what it printed before there was a heartbeat at all.
3433
+ * The tool fixes nothing. It picks the findings apart with me, cuts a worktree
3434
+ * on a branch of its own that tracks the pull request's, and hands the session
3435
+ * what I chose as JSON; then it is out of the way. I steer and I push. Nothing
3436
+ * here writes to GitHub, and the tool itself commits nothing: whether the
3437
+ * session may commit inside the worktree is `fix.commits`, or `--commit` for
3438
+ * one session.
3439
+ *
3440
+ * The worktree is left standing when the session ends, because the work in it
3441
+ * is mine and an unpushed commit lives nowhere else. Re-reviewing the result is
3442
+ * a new review run against the new head, never a continuation of the run that
3443
+ * produced these findings, so what was reviewed at which commit stays honest.
3269
3444
  */
3270
- const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
3445
+ const fix = Command.make("fix", {
3446
+ pr: prArgument,
3447
+ commit: commitFlag,
3448
+ print: printFlag$1
3449
+ }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3450
+ const { number, repo, settings, launcher } = yield* forPr(pr);
3451
+ const run = yield* currentRun(repo, number);
3452
+ const found = yield* whatItFound(run);
3453
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3454
+ if (found.findings.length === 0) return;
3455
+ const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3456
+ yield* refuse(staleAt(number, run.head, view.headRefOid));
3457
+ const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3458
+ const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
3459
+ if (chosen.length === 0) {
3460
+ yield* Console.log("Nothing picked, so no session was opened.");
3461
+ return;
3462
+ }
3463
+ const commits = Option.getOrElse(commit, () => settings.fix.commits);
3464
+ if (print) {
3465
+ yield* Console.log(yield* promptFor$1({
3466
+ repo,
3467
+ number,
3468
+ head: run.head,
3469
+ findings: chosen
3470
+ }, commits));
3471
+ return;
3472
+ }
3473
+ const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3474
+ yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3475
+ yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
3476
+ const ended = yield* steeredSession({
3477
+ launcher,
3478
+ directory: worktree.directory,
3479
+ prompt: yield* promptFor$1({
3480
+ repo,
3481
+ number,
3482
+ head: worktree.head,
3483
+ findings: chosen
3484
+ }, commits)
3485
+ });
3486
+ yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3487
+ yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3488
+ yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3489
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3490
+ //#endregion
3491
+ //#region src/cli/init.ts
3492
+ const effortFlag$1 = Flag.Literals("effort", [
3493
+ "low",
3494
+ "medium",
3495
+ "high",
3496
+ "xhigh",
3497
+ "max"
3498
+ ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
3499
+ const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
3500
+ /** The settings the flags asked for, and only those. */
3501
+ const asked = (base, effort) => ({
3502
+ ...Option.isSome(base) ? { base: base.value } : {},
3503
+ ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
3504
+ });
3505
+ /** What a review will open on, as the setup prints it back. */
3506
+ const opening = (defaults) => {
3507
+ const review = {
3508
+ ...builtIn.review,
3509
+ ...defaults.review
3510
+ };
3511
+ return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
3512
+ };
3513
+ const row = (label, value) => `${label.padEnd(12)}${value}`;
3271
3514
  /**
3272
- * The failures a sweep can hit before it has a single row, which are the ones
3273
- * worth a sentence: a machine or a file that needs fixing says what to fix
3274
- * instead of printing a stack.
3515
+ * Both the machine setup and the repository registration: there is deliberately
3516
+ * no separate `setup` command.
3517
+ *
3518
+ * The first run on a machine checks `gh` and spells the defaults out in the
3519
+ * configuration file. Run inside a repository, it also registers that
3520
+ * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
3521
+ * changes what the flags name, keeps every other setting the file already had,
3522
+ * and leaves the file untouched where nothing was decided differently.
3523
+ *
3524
+ * It asks nothing. Reviews run on Claude Code, and what a run opens on is
3525
+ * `review.command` and `review.prompt` - a line and a paragraph that belong in
3526
+ * the file rather than in a terminal prompt.
3527
+ *
3528
+ * `--effort` and `--base` are about one repository, so they land on the
3529
+ * repository this ran in, or in the defaults when it ran outside one.
3275
3530
  */
3276
- const userFacing = [
3531
+ const init = Command.make("init", {
3532
+ effort: effortFlag$1,
3533
+ base: baseFlag
3534
+ }, Effect.fn("init")(function* ({ base, effort }) {
3535
+ yield* requireAuth;
3536
+ const config = yield* ConfigStore;
3537
+ const before = yield* read;
3538
+ const file = Option.getOrElse(before, () => ({}));
3539
+ const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
3540
+ const state = yield* stateDirectory;
3541
+ const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
3542
+ const overrides = asked(base, effort);
3543
+ const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3544
+ if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3545
+ yield* Console.log(row("review", opening(written.defaults ?? {})));
3546
+ yield* Console.log(row("config", config.path));
3547
+ yield* Console.log(row("state", state));
3548
+ yield* Console.log(Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`));
3549
+ }, Effect.catchTag([
3277
3550
  "ConfigMalformed",
3551
+ "GhUnauthenticated",
3278
3552
  "GhUnavailable",
3279
- "GhReadFailed",
3280
3553
  "GhUnreadable"
3281
- ];
3282
- /** Turns one of those into the sentence the CLI prints. */
3283
- const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
3284
- /** What a sweep could not read, under a heading, so the table above it stands alone. */
3285
- const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
3286
- if (troubles.length === 0) return;
3287
- yield* Console.log("");
3288
- yield* Console.log("Could not load");
3289
- for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
3290
- });
3554
+ ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
3555
+ //#endregion
3556
+ //#region src/adapters/ci.ts
3291
3557
  /**
3292
- * Refreshes what mission control knows about every tracked PR.
3293
- *
3294
- * `dw-mc status` does this too, so this command is for the pass on its own:
3295
- * warming the state directory, or seeing what GitHub would not answer.
3558
+ * What GitHub says about a pull request's checks, and the evidence a red one
3559
+ * is classified on. Every read here goes through the same `gh` the rest of the
3560
+ * tool does; what it owns is the checks, not the boundary.
3296
3561
  */
3297
- const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
3298
- const report = yield* sweeping;
3299
- yield* Console.log(report.repos.length === 0 ? "No repositories registered. Run dw-mc init inside a repository to register it." : `Swept ${count(report.facts.length, "pull request")} across ${report.repos.length === 1 ? "1 repository" : `${report.repos.length} repositories`}`);
3300
- yield* printTroubles(report.troubles);
3301
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
3302
- //#endregion
3303
- //#region src/domain/comments.ts
3562
+ const failing = /* @__PURE__ */ new Set([
3563
+ "FAILURE",
3564
+ "TIMED_OUT",
3565
+ "CANCELLED",
3566
+ "STARTUP_FAILURE",
3567
+ "ACTION_REQUIRED",
3568
+ "ERROR"
3569
+ ]);
3570
+ const running = /* @__PURE__ */ new Set([
3571
+ "QUEUED",
3572
+ "IN_PROGRESS",
3573
+ "WAITING",
3574
+ "PENDING",
3575
+ "REQUESTED",
3576
+ "EXPECTED"
3577
+ ]);
3578
+ const nameOf = (entry) => entry.name ?? entry.context ?? "";
3579
+ const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
3580
+ const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
3304
3581
  /**
3305
- * One thread's share of a strand, cut to what is worth reading.
3306
- *
3307
- * A review thread is answered as a whole, so a single comment newer than my
3308
- * last activity brings the whole thread with it: the follow-up on its own is a
3309
- * line answering something the screen does not show, which is what sends me to
3310
- * the browser.
3582
+ * What the rollup comes to: red when anything failed, pending only while
3583
+ * nothing has failed yet, green when every check that counts has passed.
3311
3584
  *
3312
- * The pull request's own comments are not a thread but a stream, and there is
3313
- * no reply to lose the question of, so they are cut comment by comment.
3585
+ * `ci.ignore` names the checks that do not count towards green, so a check I
3586
+ * have decided to live with cannot hold a PR out of Ready.
3314
3587
  */
3315
- const only = (thread, keep, since, all) => {
3316
- const strand = thread.comments.filter((it) => keep(it.bot));
3317
- const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
3318
- return comments.length === 0 ? [] : [{
3319
- ...thread,
3320
- comments
3321
- }];
3588
+ const rollupState = (entries, ignore) => {
3589
+ const checks = checksThatCount(entries, ignore);
3590
+ if (checks.length === 0) return "none";
3591
+ if (checks.some(hasFailed)) return "red";
3592
+ if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
3593
+ return "green";
3322
3594
  };
3323
3595
  /**
3324
- * The threads worth putting on screen, given what I have already done.
3596
+ * The checks that failed and count, which are the ones there is a log to read.
3325
3597
  *
3326
- * `since` is my last activity on the pull request - the later of my last
3327
- * comment and my last commit - which is the same moment the bucket rule
3328
- * measures a comment against. Showing exactly what is newer than it means the
3329
- * command answers the question the bucket asked.
3598
+ * `ci.ignore` is applied here as well as in the rollup: a check that cannot
3599
+ * hold a PR out of Ready is not one the classifier should be explaining either.
3600
+ */
3601
+ const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
3602
+ /**
3603
+ * What a check reports on, out of the URL it reports at.
3330
3604
  *
3331
- * A thread somebody resolved and one against code that is gone are left out:
3332
- * neither is something to answer, and both are still there to read under
3333
- * `--all`, which asks for the whole conversation and so measures nothing
3334
- * against anything.
3605
+ * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
3606
+ * what the logs endpoint takes and the run id is what `gh run rerun` takes, so
3607
+ * the two ids the tool needs are the two halves of one URL and are read
3608
+ * together. A commit status points somewhere else entirely, which is null:
3609
+ * there is no log of ours to read and no run of ours to re-run.
3335
3610
  */
3336
- const shown = (threads, options) => {
3337
- const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
3338
- return {
3339
- people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
3340
- bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
3611
+ const reportedAt = (detailsUrl) => {
3612
+ const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
3613
+ return found?.[1] === void 0 || found[2] === void 0 ? null : {
3614
+ run: found[1],
3615
+ job: found[2]
3341
3616
  };
3342
3617
  };
3343
- //#endregion
3344
- //#region src/cli/comments.ts
3345
- const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
3346
- /** Where a thread hangs: a line of the diff, or the pull request itself. */
3347
- const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
3618
+ const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
3348
3619
  /**
3349
- * What is true of a thread beyond where it hangs.
3350
- *
3351
- * It is only ever printed under `--all`, which is the only way a settled thread
3352
- * reaches the screen at all, and it is there so that reading one is never
3353
- * reading it as something still open.
3620
+ * The branch a repository merges into, which is the one the first flaky signal
3621
+ * asks about. An empty repository has none, and `main` is the better guess than
3622
+ * failing the sweep over it.
3354
3623
  */
3355
- const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
3624
+ const defaultBranch = Effect.fnUntraced(function* (repo) {
3625
+ return (yield* readJson("repo view defaultBranchRef", "gh", [
3626
+ "repo",
3627
+ "view",
3628
+ repo,
3629
+ "--json",
3630
+ "defaultBranchRef"
3631
+ ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
3632
+ });
3633
+ const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
3634
+ /** How far back to look for a run that reached a verdict at all. */
3635
+ const recentRuns = 5;
3636
+ /** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
3637
+ const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
3638
+ /** A run that decided something. A skipped or cancelled run says nothing either way. */
3639
+ const verdicts = /* @__PURE__ */ new Set([
3640
+ "failure",
3641
+ "timed_out",
3642
+ "success"
3643
+ ]);
3356
3644
  /**
3357
- * One thread as a block: where it hangs, then everybody who said something in
3358
- * it, then what they said in full.
3645
+ * Whether `workflow` is red on `branch` right now.
3359
3646
  *
3360
- * In full because a review comment is usually a paragraph carrying a
3361
- * suggestion, and a first line is what sends me to the browser this command
3362
- * exists to replace. No diff hunk with it: the code is on this machine, under
3363
- * the path the heading already prints.
3647
+ * The newest run that reached a verdict is the whole answer: a workflow that
3648
+ * broke last week and was fixed since is not red, and excusing a pull request
3649
+ * for it would hide a failure that is real. A handful of runs are asked for
3650
+ * because the newest ones are often skipped by a path filter.
3364
3651
  */
3365
- const block$1 = (thread, paint) => [`${paint.bold(where$1(thread))}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, ...thread.comments.flatMap((comment) => [` ${paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`)}`, ...comment.body.split("\n").map((line) => ` ${line}`)])];
3366
- const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
3652
+ const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
3653
+ const newest = (yield* readJson("run list", "gh", [
3654
+ "run",
3655
+ "list",
3656
+ "--repo",
3657
+ repo,
3658
+ "--branch",
3659
+ branch,
3660
+ "--workflow",
3661
+ workflow,
3662
+ "--limit",
3663
+ String(recentRuns),
3664
+ "--json",
3665
+ "conclusion"
3666
+ ], Runs)).find((run) => verdicts.has(run.conclusion));
3667
+ return newest !== void 0 && failedRun.has(newest.conclusion);
3668
+ });
3669
+ const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
3670
+ /** The repository paths a pull request changes. */
3671
+ const prFiles = Effect.fnUntraced(function* (repo, number) {
3672
+ return (yield* readJson("pr view files", "gh", [
3673
+ "pr",
3674
+ "view",
3675
+ String(number),
3676
+ "--repo",
3677
+ repo,
3678
+ "--json",
3679
+ "files"
3680
+ ], PrFiles)).files.map((file) => file.path);
3681
+ });
3367
3682
  /**
3368
- * The conversation on screen: people first, then a rule, then the bots.
3369
- *
3370
- * The rule is there so the two are never read as one list. A bot's comment is
3371
- * observed and never answered, and the bucket rules ignore bots for exactly
3372
- * this reason.
3683
+ * How much of a failing job's log is kept.
3373
3684
  *
3374
- * A bot is cut at the same moment I am measured against, because the window is
3375
- * what has happened since I last acted rather than what is owed an answer. A
3376
- * verdict older than my last push is one I have already had the chance to read,
3377
- * and `--all` is where it still is.
3685
+ * A job that failed prints what went wrong at the end, so the tail is the part
3686
+ * worth classifying, and a build that logged a whole dependency tree is not
3687
+ * worth holding in memory beyond it.
3378
3688
  */
3379
- const lines$2 = (view, paint) => {
3380
- const people = view.people.map((thread) => block$1(thread, paint));
3381
- const bots = view.bots.map((thread) => block$1(thread, paint));
3382
- return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
3383
- };
3384
- /** What to say where there is nothing to print, which depends on why there is not. */
3385
- const nothing = (facts, all) => {
3386
- const pr = `${facts.repo}#${facts.number}`;
3387
- if (all) return [`Nothing has been said on ${pr}.`];
3388
- const placement = place(facts);
3389
- const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
3390
- return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
3391
- "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
3392
- `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
3393
- rest
3394
- ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
3395
- };
3689
+ const logTailBytes = 65536;
3396
3690
  /**
3397
- * The conversation on one tracked pull request, and nothing else.
3398
- *
3399
- * What it shows by default is what the bucket rule measures: the comments newer
3400
- * than the later of my last comment and my last commit, which are the ones that
3401
- * put the pull request in Needs me. Reading it answers the question the table
3402
- * asked.
3403
- *
3404
- * The cutoff is read off the last sweep rather than worked out again here, so
3405
- * the command shows exactly what `dw-mc status` counted rather than a second
3406
- * opinion about it.
3691
+ * What one failing job printed, from the end.
3407
3692
  *
3408
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
3409
- * (ADR 0002). Reading is the whole command.
3693
+ * `gh api` refuses a response carrying terminal escape sequences unless it is
3694
+ * told otherwise, and a runner log is full of them. Verified by running it: the
3695
+ * endpoint answers with the plain log once the flag is passed.
3410
3696
  */
3411
- const comments = Command.make("comments", {
3412
- pr: prArgument,
3413
- all: allFlag
3414
- }, Effect.fn("comments")(function* ({ all, pr }) {
3415
- const file = Option.getOrElse(yield* read, () => ({}));
3416
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3417
- const facts = yield* swept(repo, number);
3418
- const paint = yield* Paint;
3419
- const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
3420
- since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3421
- all
3422
- });
3423
- if (view.people.length === 0 && view.bots.length === 0) {
3424
- yield* Effect.forEach(nothing(facts, all), (line) => Console.log(line));
3425
- return;
3426
- }
3427
- yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
3428
- yield* Console.log("");
3429
- yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
3430
- }, Effect.catchTag(["ConfigMalformed", ...userFacing], asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
3431
- //#endregion
3432
- //#region src/cli/findings.ts
3433
- /** The findings as the JSON the schema defines, rather than as this file spells it. */
3434
- const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
3435
- const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
3436
- /** What a run's findings come to in one line, against the bar that blocks. */
3437
- const summary = (found, blocksOn) => {
3438
- if (found.findings.length === 0) return "clean, nothing to fix";
3439
- const blocked = blocking(found.findings, blocksOn).length;
3440
- return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
3441
- };
3442
- /** Which run these findings are, and what they come to: the line above the list. */
3443
- const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
3697
+ const jobLog = Effect.fnUntraced(function* (repo, jobId) {
3698
+ const log = yield* capture("gh", [
3699
+ "api",
3700
+ `repos/${repo}/actions/jobs/${jobId}/logs`,
3701
+ "--allow-escape-sequences"
3702
+ ]).pipe(Effect.catchTags({
3703
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3704
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3705
+ command: "api job logs",
3706
+ detail: error.stderr
3707
+ }))
3708
+ }));
3709
+ return log.length <= logTailBytes ? log : log.slice(-65536);
3710
+ });
3444
3711
  /**
3445
- * The findings one to a line, in the order the run reported them, ruled so the
3446
- * three columns read apart.
3712
+ * The workflow runs behind the failing checks that count, each named once.
3713
+ *
3714
+ * One broken run usually fails several jobs, and re-running it once per failing
3715
+ * job would start the same run over and over.
3716
+ *
3717
+ * `ci.ignore` decides which checks get a run into this list, and no more than
3718
+ * that: a run is re-run whole, so an ignored job sharing a run with a counted
3719
+ * one is re-run beside it. What the setting buys is that an ignored check is
3720
+ * never on its own a reason to spend CI minutes.
3447
3721
  */
3448
- const lines$1 = (found) => table(found.findings.map((finding) => [
3449
- `${finding.file}:${finding.line}`,
3450
- finding.severity,
3451
- finding.summary
3452
- ]), " │ ");
3722
+ const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
3723
+ const reported = reportedAt(check.detailsUrl);
3724
+ return reported === null ? [] : [reported.run];
3725
+ }))];
3453
3726
  /**
3454
- * The review run whose findings are the current ones, or the sentence saying
3455
- * there are none.
3727
+ * Asks GitHub to run one workflow run's failed jobs again.
3456
3728
  *
3457
- * The last run on the pull request is what "current" means here, and it is read
3458
- * off the state directory rather than worked out from GitHub: this command is
3459
- * one I run inside a fix session, where another round trip to GitHub buys
3460
- * nothing the run it is about to fix does not already say.
3729
+ * `--failed` is what makes this cheap: the jobs that passed are not run a
3730
+ * second time, so a flaky job costs the minutes it costs and no more. This is a
3731
+ * write to GitHub, and it is one of the three ADR 0002 allows.
3461
3732
  */
3462
- const currentRun = Effect.fn("findings.currentRun")(function* (repo, number) {
3463
- const run = yield* lastRun(repo, number);
3464
- return Option.isSome(run) ? run.value : yield* asUserError(`No review run on ${repo}#${number}. Run dw-mc review ${number} first.`);
3733
+ const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
3734
+ yield* capture("gh", [
3735
+ "run",
3736
+ "rerun",
3737
+ runId,
3738
+ "--repo",
3739
+ repo,
3740
+ "--failed"
3741
+ ]).pipe(Effect.catchTags({
3742
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3743
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3744
+ command: "run rerun",
3745
+ detail: error.stderr
3746
+ }))
3747
+ }));
3465
3748
  });
3749
+ //#endregion
3750
+ //#region src/domain/stamp.ts
3466
3751
  /**
3467
- * What the run reported, or the sentence saying it reported nothing at all.
3752
+ * A stamp I took off a pull request by hand, and the head I took it off at.
3468
3753
  *
3469
- * A run that failed is not a clean one: a pipe must never be handed "no
3470
- * findings" when what happened is that nothing could be read.
3754
+ * The head is the whole record: a withdrawal is my overruling the computation
3755
+ * on code I have read, so it lasts exactly as long as that code is what the
3756
+ * pull request is.
3471
3757
  */
3472
- const whatItFound = (run) => {
3473
- const found = reportedBy(run);
3474
- return found === null ? Effect.fail(new CliError.UserError({ cause: `The review run on ${short(run.head)} reported no findings: ${run.outcome._tag === "failed" ? run.outcome.detail : ""}\nRun dw-mc review ${run.number} --force to run it again.` })) : Effect.succeed(found);
3758
+ const Withdrawal = Schema.Struct({ head: Schema.String });
3759
+ /** The stamp a pull request has not earned, and the first reason it has not. */
3760
+ const withheld = (reason) => ({
3761
+ stamped: false,
3762
+ reason
3763
+ });
3764
+ /** What CI has to say before the stamp will rest on it, which is green and nothing else. */
3765
+ const whyNotGreen = {
3766
+ green: null,
3767
+ red: "CI is red",
3768
+ pending: "CI is still running",
3769
+ none: "no CI ran on this head"
3770
+ };
3771
+ /** What GitHub has to say about merging, which is that it would. */
3772
+ const whyNotMergeable = {
3773
+ mergeable: null,
3774
+ conflicting: "merge conflict",
3775
+ unknown: "GitHub has not said whether it merges"
3475
3776
  };
3476
3777
  /**
3477
- * What the current review run found, as a table or as the JSON it is kept in.
3778
+ * The stamp of one tracked PR: whether it has passed my bar, and why.
3478
3779
  *
3479
- * `--json` is the whole point of the command: it prints the findings and
3480
- * nothing else, so I can pipe them anywhere, and a fix session inside an open
3481
- * agent reads exactly what the tool recorded rather than a retelling of it.
3780
+ * The mark is computed rather than clicked, so it means the same thing every
3781
+ * time: a review run on this head that found nothing blocking, CI green as the
3782
+ * repository's `ci.ignore` defines green, and a pull request GitHub would
3783
+ * merge. A red CI the flaky classifier excused is still not green here: an
3784
+ * excuse is a reason not to fix a check, not a reason to land code behind one,
3785
+ * and this mark is what clears `dw-mc merge` (ADR 0008).
3482
3786
  *
3483
- * A run that failed prints no findings and fails: a review run that could not
3484
- * report has found nothing, which is not the same as having found nothing
3485
- * wrong, and a pipe must never be handed the second when the first is true.
3486
- */
3487
- const findings = Command.make("findings", {
3488
- pr: prArgument,
3489
- json: jsonFlag
3490
- }, Effect.fn("findings")(function* ({ json, pr }) {
3491
- const file = Option.getOrElse(yield* read, () => ({}));
3492
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
3493
- const settings = settingsFor(file, repo);
3494
- const run = yield* currentRun(repo, number);
3495
- const found = yield* whatItFound(run);
3496
- if (json) {
3497
- yield* Console.log(yield* asJson$2(found));
3498
- return;
3499
- }
3500
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3501
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
3502
- }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
3503
- //#endregion
3504
- //#region src/adapters/agent.ts
3505
- /**
3506
- * How one turn of Claude Code is spawned and given up on, and what a turn that
3507
- * answers against a schema comes back with.
3787
+ * Nothing about this rests on a previous stamp, which is what makes a head
3788
+ * change clear it: facts are about one head, and a run is recorded against one.
3508
3789
  *
3509
- * Every turn is reached this way, so the spawn, the patience and the one failure
3510
- * they can end in live here rather than once per turn.
3790
+ * A withdrawal comes first, because it is the one thing here I decided rather
3791
+ * than computed.
3511
3792
  */
3512
- /** A review run that would not start, would not finish, or finished badly. */
3513
- var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
3514
- /** The program that was spawned, which is what a search for it has to name. */
3515
- program: Schema.String,
3516
- detail: Schema.String
3517
- }) {
3518
- get message() {
3519
- return `The ${this.program} review run failed: ${this.detail}`;
3520
- }
3793
+ const stampFor = (facts, withdrawnAt) => {
3794
+ if (withdrawnAt === facts.head) return withheld("withdrawn by hand");
3795
+ if (facts.reviewRunHead !== facts.head) return withheld("no review run on this head");
3796
+ if (facts.blockingFindings > 0) return withheld(blockedBy(facts.blockingFindings));
3797
+ const ci = whyNotGreen[facts.checks];
3798
+ if (ci !== null) return withheld(ci);
3799
+ const merge = whyNotMergeable[facts.mergeable];
3800
+ if (merge !== null) return withheld(merge);
3801
+ return {
3802
+ stamped: true,
3803
+ reason: "a clean review run on this head, green CI, mergeable"
3804
+ };
3521
3805
  };
3522
3806
  /**
3523
- * Failures in the name of the program that was spawned.
3807
+ * The head a stamp was withdrawn at, or null where none was.
3524
3808
  *
3525
- * Where the launcher starts `claude` through another program, it is that
3526
- * program that would not start or exited badly, and saying `claude` sends the
3527
- * search to the wrong process.
3809
+ * Forgetting a withdrawal hands the pull request back to the computation, which
3810
+ * is what every other input to a stamp already is.
3528
3811
  */
3529
- const failedBy = (program) => (detail) => new AgentFailed({
3530
- program,
3531
- detail
3812
+ const withdrawnAt = Effect.fn("stamp.withdrawnAt")(function* (repo, number) {
3813
+ const store = yield* storeFor("stamps", Withdrawal);
3814
+ const withdrawal = yield* remembered(store.get(prKey(repo, number)));
3815
+ return Option.match(withdrawal, {
3816
+ onNone: () => null,
3817
+ onSome: (it) => it.head
3818
+ });
3819
+ });
3820
+ /** Takes the stamp off a pull request at `head`, which is the only head it stays off. */
3821
+ const withdraw = Effect.fn("stamp.withdraw")(function* (repo, number, head) {
3822
+ yield* (yield* storeFor("stamps", Withdrawal)).set(prKey(repo, number), { head });
3823
+ });
3824
+ /** The stamp of one tracked PR, with the withdrawal this machine holds against it. */
3825
+ const stampOf = Effect.fn("stamp.stampOf")(function* (facts) {
3826
+ return stampFor(facts, yield* withdrawnAt(facts.repo, facts.number));
3532
3827
  });
3533
3828
  /**
3534
- * How long each turn gets before it is given up on.
3535
- *
3536
- * The review is the turn that thinks, and a high-effort one that fans out to
3537
- * subagents takes real minutes, so its limit is there to catch a run that has
3538
- * stopped rather than one that is slow. The second turn reads no code and
3539
- * decides nothing - the review it reports on is already in the session it
3540
- * resumes - and every run of it by hand came back in seconds.
3541
- *
3542
- * Either way, a command that hangs forever is worse than one that says it
3543
- * failed: a review I walked away from is one I need to be able to come back to.
3544
- */
3545
- const patience = {
3546
- reviewing: Duration.minutes(45),
3547
- reporting: Duration.minutes(5)
3548
- };
3549
- /**
3550
- * One turn of the launcher in `directory`, with `read` over its standard output.
3829
+ * Which of these tracked PRs carry a stamp, keyed the way their facts are.
3551
3830
  *
3552
- * The launcher's own arguments go in front of the turn's, because they are what
3553
- * gets `claude` started at all. The two output streams are drained together,
3554
- * because draining one to the end first can block a run that is still writing to
3555
- * the other. Every way a turn can fail to finish comes back from here as a
3556
- * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3557
- * to translate - a turn that never comes back included.
3831
+ * A table asks the question of every row at once, and the withdrawals are the
3832
+ * only thing here that has to be read off the disk.
3558
3833
  */
3559
- const turn = Effect.fnUntraced(function* (options) {
3560
- const [program, ...prefix] = options.command;
3561
- const failed = failedBy(program);
3562
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3563
- const running = Effect.gen(function* () {
3564
- const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3565
- cwd: options.directory,
3566
- stdin: "pipe"
3567
- })), (error) => failed(error.message));
3568
- const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3569
- const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3570
- if (exitCode !== 0) return yield* failed(stderr.trim() === "" ? `${program} exited ${exitCode}` : stderr.trim());
3571
- return got;
3572
- });
3573
- return yield* Effect.timeoutOrElse(running, {
3574
- duration: options.patience.duration,
3575
- orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
3576
- });
3834
+ const stampedAmong = Effect.fn("stamp.stampedAmong")(function* (facts) {
3835
+ const marks = yield* Effect.forEach(facts, (it) => Effect.map(stampOf(it), (stamp) => ({
3836
+ key: prKey(it.repo, it.number),
3837
+ stamped: stamp.stamped
3838
+ })));
3839
+ return new Set(marks.filter((mark) => mark.stamped).map((mark) => mark.key));
3577
3840
  });
3578
3841
  //#endregion
3579
- //#region src/adapters/claude.ts
3842
+ //#region src/domain/merge.ts
3580
3843
  /**
3581
- * Claude Code: a review on a slash command, a review on the tool's own prompt,
3582
- * and the sessions I steer.
3844
+ * Why GitHub would not call this pull request Ready, or null where it would.
3845
+ *
3846
+ * Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
3847
+ * A repository that requires no reviewer produces no approval, which is why
3848
+ * `none` passes and `review-required` does not - what holds a merge is somebody
3849
+ * having been asked and not yet answered.
3850
+ *
3851
+ * A red CI the flaky classifier excused is still red here. The excuse is a
3852
+ * reason not to fix a check; it is not a reason to land code behind one.
3583
3853
  */
3854
+ const whyNotReady = (situation) => {
3855
+ if (situation.reviewDecision === "changes-requested") return "changes are requested";
3856
+ if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
3857
+ return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
3858
+ };
3584
3859
  /**
3585
- * The two events of a stream-json run this reads, as the runner really writes
3586
- * them. Every other field of both, and every other event, is ignored: a
3587
- * transcript carries hooks, rate limits, thinking and tool results, and a
3588
- * version that adds another must not stop a run from being read.
3860
+ * What to do about a pull request that is Ready and carries no stamp.
3861
+ *
3862
+ * The stamp is withheld for one of three reasons and each has its own next
3863
+ * step, so the refusal names that step rather than leaving me to work out which
3864
+ * of the three it was. A withdrawal is the one with no command: I took the mark
3865
+ * off code I had read, and only that code changing puts it back.
3589
3866
  */
3590
- const Working = Schema.Struct({
3591
- type: Schema.Literal("assistant"),
3592
- message: Schema.Struct({ content: Schema.Array(Schema.Struct({
3593
- type: Schema.String,
3594
- name: Schema.optionalKey(Schema.String),
3595
- text: Schema.optionalKey(Schema.String)
3596
- })) })
3597
- });
3598
- const Ended = Schema.Struct({
3599
- type: Schema.Literal("result"),
3600
- subtype: Schema.String,
3601
- is_error: Schema.Boolean,
3602
- session_id: Schema.String,
3603
- result: Schema.optionalKey(Schema.String),
3604
- /** What a turn given a JSON schema validated, which this hands on unread. */
3605
- structured_output: Schema.optionalKey(Schema.Unknown)
3606
- });
3607
- const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3608
- const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3609
- const heardIn = (line) => {
3610
- const blocks = Option.match(asWorking(line), {
3611
- onNone: () => [],
3612
- onSome: (event) => event.message.content
3613
- });
3614
- return {
3615
- tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
3616
- said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
3867
+ const earnsIt = (situation) => {
3868
+ if (situation.withdrawnAt === situation.head) return "\n\nYou took it off at this head, and it stays off until the head changes.";
3869
+ const next = situation.reviewRunHead !== situation.head ? {
3870
+ command: `dw-mc review ${situation.number}`,
3871
+ says: "That reviews this head, and a run that finds nothing blocking stamps it."
3872
+ } : {
3873
+ command: `dw-mc fix ${situation.number}`,
3874
+ says: "That opens a session on the findings. The stamp is back once the head has moved and a review run has read it."
3617
3875
  };
3876
+ return `\n\n ${next.command}\n\n${next.says}`;
3618
3877
  };
3619
3878
  /**
3620
- * The result a turn ended on, or the failure it really was.
3879
+ * Why this pull request is not one to merge, or null where it is.
3621
3880
  *
3622
- * A turn that said nothing this can read and a turn Claude Code itself calls an
3623
- * error are both failures: `subtype` is where a run that hit its turn limit or
3624
- * lost its connection says so, and its `result` is the only word on why.
3881
+ * This is the single place the merge guards live, and they carry more than the
3882
+ * merge does: it is the one write the tool makes that no reflog of mine undoes
3883
+ * (ADR 0008). Two bars have to be clear, because each is blind to what the
3884
+ * other sees - GitHub does not know whether anything read the diff, and the
3885
+ * stamp does not know whether a reviewer asked for changes.
3886
+ *
3887
+ * Whose pull request it is comes first, as it does everywhere else: one
3888
+ * somebody else opened is none of this tool's business, whatever is true of it.
3889
+ * A draft is next, because a pull request I have not offered to anybody is not
3890
+ * one to land however green it is.
3891
+ *
3892
+ * Ready is asked before the stamp so that the refusal names the bar I am
3893
+ * actually under. The stamp insists on green CI and a mergeable pull request
3894
+ * too, so everything it can be withheld for here is mine rather than GitHub's.
3625
3895
  */
3626
- const ended = (program, result) => {
3627
- const failed = failedBy(program);
3628
- if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
3629
- const { is_error, result: lastWord, subtype } = result.value;
3630
- return is_error || subtype !== "success" ? Effect.fail(failed(`${subtype}: ${lastWord ?? "nothing else was said"}`)) : Effect.succeed(result.value);
3896
+ const decide$3 = (situation) => {
3897
+ const where = `${situation.repo}#${situation.number}`;
3898
+ if (!situation.mine) return `${where} is not mine. dw-mc merges pull requests I author and nothing else.`;
3899
+ if (situation.draft) return `${where} is a draft. Mark it ready for review before merging it.`;
3900
+ const ready = whyNotReady(situation);
3901
+ if (ready !== null) return `${where} is not Ready: ${ready}. dw-mc merges nothing GitHub would not merge itself.`;
3902
+ const stamp = stampFor(situation, situation.withdrawnAt);
3903
+ return stamp.stamped ? null : `${where} is Ready and carries no stamp: ${stamp.reason}.${earnsIt(situation)}`;
3631
3904
  };
3905
+ //#endregion
3906
+ //#region src/cli/merge.ts
3632
3907
  /**
3633
- * A Claude Code `stream-json` turn, read as it arrives: what it reached for goes
3634
- * to `onTool` while the run is still going, and what it said and how it ended
3635
- * are what comes back.
3908
+ * Lands one pull request of mine: squashed, with its branch deleted.
3636
3909
  *
3637
- * Both shapes of review read a turn the same way, so the fold is here rather
3638
- * than once per shape.
3910
+ * This is the write ADR 0008 is about, and the only one the tool makes that no
3911
+ * reflog of mine brings back. It is outside ADR 0002's three because it moves a
3912
+ * shared branch; everything 0002 bars - comment, reply, thread resolve, label,
3913
+ * review, approval, status - still holds here as it does everywhere.
3914
+ *
3915
+ * The threshold is two bars at one head: Ready, which is GitHub's opinion, and
3916
+ * my stamp, which is mine. Each is blind to what the other sees, so the write
3917
+ * that cannot be undone clears both.
3918
+ *
3919
+ * GitHub's half is read live from a fresh `pr view` rather than off the last
3920
+ * sweep, the way the rebase and re-run guards are. A stale verdict costs a
3921
+ * re-run some CI minutes; here it costs merging code nobody read. My half comes
3922
+ * from the state directory, because the review runs and the withdrawal live
3923
+ * there and are already scoped to the head this read just named.
3924
+ *
3925
+ * Typing the command is the confirmation, so it takes no flag. The picker,
3926
+ * where a keystroke is cheaper, asks before it dispatches.
3639
3927
  */
3640
- const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3641
- const heard = heardIn(line);
3642
- return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
3643
- line,
3644
- heard
3645
- });
3646
- }), Stream.runFold(() => ({
3647
- said: [],
3648
- result: Option.none()
3649
- }), (soFar, { heard, line }) => ({
3650
- said: [...soFar.said, ...heard.said],
3651
- result: Option.orElse(asResult(line), () => soFar.result)
3652
- })));
3928
+ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3929
+ const { number, repo, settings } = yield* forPr(pr);
3930
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
3931
+ const head = view.headRefOid;
3932
+ yield* refuse(decide$3({
3933
+ repo,
3934
+ number,
3935
+ head,
3936
+ mine: view.author?.login === me,
3937
+ draft: view.isDraft,
3938
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
3939
+ checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
3940
+ mergeable: mergeabilityOf(view.mergeable),
3941
+ ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
3942
+ withdrawnAt: yield* withdrawnAt(repo, number)
3943
+ }));
3944
+ yield* mergePr(repo, number);
3945
+ yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3946
+ yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
3947
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3948
+ //#endregion
3949
+ //#region src/domain/coverage.ts
3950
+ const one = (repo, registered) => ({
3951
+ _tag: "covers",
3952
+ repos: [repo],
3953
+ leftOut: registered.length - 1
3954
+ });
3653
3955
  /**
3654
- * One review run on a slash command, headless, in `directory`.
3655
- *
3656
- * The run is in the foreground and says what it is doing as it does it, which
3657
- * is what `onTool` is for: a review takes minutes, and a terminal that prints
3658
- * nothing for minutes is one I stop trusting.
3956
+ * The repositories a sweep covers.
3659
3957
  *
3660
- * `--json-schema` is never passed here: verified by running it, the flag beside
3661
- * `/code-review` breaks the run, which is why a slash command costs a second
3662
- * turn that resumes the session and asks for the findings. My own instructions
3663
- * ride on `--append-system-prompt` rather than on the command's own line,
3664
- * because what a slash command does with its arguments is its business and not
3665
- * this tool's.
3958
+ * Standing in a registered repository is asking for that one, because that is
3959
+ * the table I want while I work there. Standing anywhere else asks for nothing
3960
+ * in particular, so it gets every registered repository, as it would have
3961
+ * before a sweep could be narrowed at all.
3666
3962
  *
3667
- * `--comment` is the flag that makes the built-in review post on the pull
3668
- * request, and it is never passed either (ADR 0002). The report is everything
3669
- * the run said on its own turns rather than the `result` alone: verified by
3670
- * running it, a repository whose review command fans out to subagents can end on
3671
- * a remark about them, and the report is the turn before that.
3963
+ * `here` is the repository the working directory is in, and is only read when
3964
+ * neither flag decides.
3672
3965
  */
3673
- const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3674
- const [program] = options.launcher.command;
3675
- const run = yield* turn({
3676
- command: options.launcher.command,
3677
- directory: options.directory,
3678
- args: [
3679
- "-p",
3680
- options.line,
3681
- "--output-format",
3682
- "stream-json",
3683
- "--verbose",
3684
- ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3685
- ...options.model === null ? [] : ["--model", options.model]
3686
- ],
3687
- patience: {
3688
- turn: "the review",
3689
- duration: patience.reviewing
3690
- },
3691
- read: transcript(options.onTool)
3692
- });
3693
- const { result: lastWord, session_id } = yield* ended(program, run.result);
3694
- const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
3695
- if (report === "") return yield* failedBy(program)("the run came back with an empty report");
3966
+ const covered = (asked, here, registered) => {
3967
+ if (asked.repo !== void 0 && asked.all) return {
3968
+ _tag: "refused",
3969
+ why: "--repo names one repository and --all asks for every one. Pass one of them."
3970
+ };
3971
+ if (asked.repo !== void 0) {
3972
+ if (registered.includes(asked.repo)) return one(asked.repo, registered);
3973
+ return {
3974
+ _tag: "refused",
3975
+ why: registered.length === 0 ? `${asked.repo} is not registered. Run dw-mc init inside it to register it.` : `${asked.repo} is not registered. Name one of ${registered.join(", ")}, or run dw-mc init inside it.`
3976
+ };
3977
+ }
3978
+ if (!asked.all && here !== void 0 && registered.includes(here)) return one(here, registered);
3696
3979
  return {
3697
- report,
3698
- sessionId: session_id
3980
+ _tag: "covers",
3981
+ repos: registered,
3982
+ leftOut: 0
3699
3983
  };
3700
- }, Effect.scoped);
3984
+ };
3985
+ /** Whether the working directory decides the coverage, which is the one case worth asking `gh` where it is. */
3986
+ const asksWhereIAm = (asked, registered) => asked.repo === void 0 && !asked.all && registered.length > 0;
3987
+ //#endregion
3988
+ //#region src/domain/flaky.ts
3701
3989
  /**
3702
- * What the second turn asks for.
3990
+ * The failures that are flaky wherever they appear: a machine, a network or a
3991
+ * runner giving up, never a test disagreeing with the code.
3703
3992
  *
3704
- * It asks for a report of what was already said rather than for another look:
3705
- * the prose is the review, and this turn is only what makes it machine
3706
- * readable. The shape it must answer in arrives as a JSON schema beside it, so
3707
- * the prompt does not describe the schema twice.
3993
+ * `ci.flaky_patterns` adds to this list rather than replacing it, because the
3994
+ * failures a repository of mine produces are extra ones, not different ones.
3708
3995
  */
3709
- const reportFindings = [
3710
- "Report the findings of the review you just gave as structured output.",
3711
- "Every finding carries the file it is in as a repository path, the line it is at,",
3712
- "its severity and a one-sentence summary.",
3713
- "The verdict is clean when there is nothing to report and findings otherwise.",
3714
- "Report nothing you did not already say."
3715
- ].join(" ");
3996
+ const builtInPatterns = [
3997
+ "timed out",
3998
+ "deadline exceeded",
3999
+ "ETIMEDOUT",
4000
+ "ECONNRESET",
4001
+ "ECONNREFUSED",
4002
+ "connection refused",
4003
+ "socket hang up",
4004
+ "lock timeout",
4005
+ "could not obtain lock",
4006
+ "runner lost communication",
4007
+ "The runner has received a shutdown signal",
4008
+ "net/http: request canceled",
4009
+ "ResourceExhausted",
4010
+ "Too many open files",
4011
+ "no space left on device"
4012
+ ];
4013
+ const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
3716
4014
  /**
3717
- * The second turn of a review run: the prose the first one wrote, back as
3718
- * findings that validate.
4015
+ * The changed file the log names, preferring one it spells in full.
3719
4016
  *
3720
- * It resumes the first turn's session rather than reading the diff again, which
3721
- * is what makes it cheap and what makes it accurate - verified by running it,
3722
- * the line numbers it reports beat the ones the prose gives. The output is
3723
- * handed on as it arrived: what the findings must look like belongs to the
3724
- * domain, and the schema the run is held to comes in from there too.
4017
+ * A bare file name is worth matching - a stack trace often prints nothing else
4018
+ * - and it is worth matching second, because a name as ordinary as `index.ts`
4019
+ * belongs to more repositories than mine.
4020
+ */
4021
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4022
+ /**
4023
+ * Whether the log names a file called `base` rather than some longer name
4024
+ * ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
4025
+ * complaining about.
4026
+ */
4027
+ const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
4028
+ const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
4029
+ /**
4030
+ * The flaky pattern the log matches, mine before the built-in ones.
3725
4031
  *
3726
- * Every way this can end badly ends as an `AgentFailed`, because a review run
3727
- * that could not report is a failure and never a clean verdict.
4032
+ * A pattern is text and not a regular expression: it comes out of a
4033
+ * configuration file I edit by hand, where a stray `*` should cost me a missed
4034
+ * match and never a crash.
3728
4035
  */
3729
- const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3730
- const [program] = options.launcher.command;
3731
- const printed = yield* turn({
3732
- command: options.launcher.command,
3733
- directory: options.directory,
3734
- patience: {
3735
- turn: "the findings turn",
3736
- duration: patience.reporting
3737
- },
3738
- args: [
3739
- "-p",
3740
- "--resume",
3741
- options.sessionId,
3742
- reportFindings,
3743
- "--output-format",
3744
- "json",
3745
- "--json-schema",
3746
- options.jsonSchema
3747
- ],
3748
- read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
3749
- });
3750
- const { structured_output } = yield* ended(program, asResult(printed.trim()));
3751
- if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
3752
- return structured_output;
3753
- }, Effect.scoped);
4036
+ const matchedPattern = (log, patterns) => {
4037
+ const haystack = log.toLowerCase();
4038
+ return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
4039
+ };
3754
4040
  /**
3755
- * One review run of the tool's own review prompt, in `directory`.
4041
+ * Whether a red CI is mine to fix, and why.
3756
4042
  *
3757
- * It is one turn rather than two: verified by running it, `--json-schema` beside
3758
- * an ordinary prompt gives both the prose the run wrote and the
3759
- * `structured_output` it validated, where the same flag on a slash command
3760
- * breaks the run. The schema arrives as inline JSON and never as a path - a path
3761
- * is where Claude Code reports `--json-schema is not valid JSON`.
4043
+ * Two of the signals say flaky and one says legitimate, and the one outranks
4044
+ * the two: a log that names a file this pull request changes is the failure
4045
+ * pointing at my own work, and a workflow that is broken everywhere does not
4046
+ * stop it pointing there.
4047
+ *
4048
+ * Everything else that is unexplained is mine as well. The two mistakes do not
4049
+ * cost the same - a real failure called flaky is a broken pull request nobody
4050
+ * tells me about, while a flake called mine costs me one look - so the default
4051
+ * is the one I can recover from.
3762
4052
  */
3763
- const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3764
- const [program] = options.launcher.command;
3765
- const run = yield* turn({
3766
- command: options.launcher.command,
3767
- directory: options.directory,
3768
- patience: {
3769
- turn: "the review",
3770
- duration: patience.reviewing
3771
- },
3772
- args: [
3773
- "-p",
3774
- options.prompt,
3775
- "--output-format",
3776
- "stream-json",
3777
- "--verbose",
3778
- "--json-schema",
3779
- options.jsonSchema,
3780
- ...options.model === null ? [] : ["--model", options.model]
3781
- ],
3782
- read: transcript(options.onTool)
3783
- });
3784
- const { session_id, structured_output } = yield* ended(program, run.result);
3785
- if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
3786
- const prose = run.said.join("\n\n").trim();
3787
- return {
3788
- findings: structured_output,
3789
- sessionId: session_id,
3790
- prose: prose === "" ? null : prose
4053
+ const classify = (evidence, flakyPatterns) => {
4054
+ const named = namedChangedFile(evidence.log, evidence.changedFiles);
4055
+ if (named !== null) return {
4056
+ classification: "legitimate",
4057
+ reason: `the log names ${named}, which this PR changes`
4058
+ };
4059
+ const redOnDefaultBranch = evidence.alsoRedOnDefaultBranch[0];
4060
+ const pattern = matchedPattern(evidence.log, flakyPatterns);
4061
+ const excuses = [redOnDefaultBranch === void 0 ? null : `${redOnDefaultBranch} is red on the default branch too`, pattern === null ? null : `the log matches "${pattern}"`].filter((it) => it !== null);
4062
+ return excuses.length === 0 ? {
4063
+ classification: "legitimate",
4064
+ reason: "nothing explains the failure"
4065
+ } : {
4066
+ classification: "flaky",
4067
+ reason: excuses.join(", and ")
3791
4068
  };
3792
- }, Effect.scoped);
4069
+ };
3793
4070
  /**
3794
- * One review run, in whichever shape it was configured in.
4071
+ * How many failing jobs the log is read from.
3795
4072
  *
3796
- * A slash command takes two turns and the tool's own prompt takes one, which is
3797
- * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3798
- * the same answer back either way.
4073
+ * One broken workflow usually fails several jobs with the same cause, and the
4074
+ * logs are the one read here that is measured in megabytes.
4075
+ */
4076
+ const loggedJobs = 3;
4077
+ /** The values of `xs` that `f` has one for. */
4078
+ const filterMap = (xs, f) => xs.flatMap((x) => {
4079
+ const b = f(x);
4080
+ return b === null ? [] : [b];
4081
+ });
4082
+ /** No evidence at all, which is what an unreadable CI comes to. */
4083
+ const nothing = {
4084
+ alsoRedOnDefaultBranch: [],
4085
+ changedFiles: [],
4086
+ log: ""
4087
+ };
4088
+ /**
4089
+ * What a red CI looks like to the classifier.
3799
4090
  *
3800
- * The second turn's failure is kept beside the first turn's prose rather than
3801
- * replacing it. A review that ran and could not report is still worth reading,
3802
- * and it is recorded as the failure it is.
4091
+ * A read that fails costs its own signal and nothing else. GitHub drops an
4092
+ * Actions log after ninety days, so a pull request open that long would
4093
+ * otherwise lose its row over a log nobody can fetch any more - and a missing
4094
+ * signal only ever moves the verdict towards legitimate, which is the answer
4095
+ * that puts the pull request in front of me rather than hiding it.
3803
4096
  */
3804
- const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3805
- const { directory, jsonSchema, launcher, model, onTool } = options;
3806
- if (options.turn._tag === "prompt") {
3807
- const run = yield* promptReview({
3808
- launcher,
3809
- directory,
3810
- prompt: options.turn.text,
3811
- model,
3812
- jsonSchema,
3813
- onTool
3814
- });
3815
- return {
3816
- sessionId: run.sessionId,
3817
- prose: run.prose,
3818
- findings: Result.succeed(run.findings)
3819
- };
3820
- }
3821
- const run = yield* commandReview({
3822
- launcher,
3823
- directory,
3824
- line: options.turn.line,
3825
- instructions: options.turn.instructions,
3826
- model,
3827
- onTool
3828
- });
3829
- const findings = yield* Effect.result(findingsTurn({
3830
- launcher,
3831
- directory,
3832
- sessionId: run.sessionId,
3833
- jsonSchema
3834
- }));
4097
+ const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entries, ignore) {
4098
+ const failed = failedChecks(entries, ignore);
4099
+ const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
4100
+ const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
4101
+ const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
4102
+ if (branch === null) return nothing;
4103
+ const [alsoRed, changedFiles, logs] = yield* Effect.all([
4104
+ Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
4105
+ Effect.orElseSucceed(prFiles(repo, number), () => []),
4106
+ Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
4107
+ ], { concurrency: 3 });
3835
4108
  return {
3836
- sessionId: run.sessionId,
3837
- prose: run.report,
3838
- findings
4109
+ alsoRedOnDefaultBranch: alsoRed.flat(),
4110
+ changedFiles,
4111
+ log: logs.join("\n")
3839
4112
  };
3840
4113
  });
3841
4114
  /**
3842
- * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3843
- * handed straight to it.
3844
- *
3845
- * The launcher's `fix_args` go here and nowhere else: they are the flags of
3846
- * every session I steer - the one on findings and the one on a conflict - which
3847
- * no headless review turn wants. They sit in front of the
3848
- * prompt, because `claude` takes its flags before its positional argument.
3849
- *
3850
- * This is the one place a run is not read: the three streams are inherited,
3851
- * so what is on the screen is the session itself and not a transcript of it,
3852
- * and what I type reaches it. The child is not detached for the same reason -
3853
- * a detached child sits outside the terminal's foreground process group, where
3854
- * neither my keystrokes nor Ctrl-C would reach it.
3855
- *
3856
- * There is no patience here either. A session I steer lasts as long as I am in
3857
- * it, and a timeout would be the tool closing a session I was still working in.
4115
+ * Why a red CI is excused, or null where it is mine to fix.
3858
4116
  *
3859
- * What comes back is the code the session ended on. A session I left with
3860
- * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3861
- * than failing on it; only a `claude` that would not start at all is a failure.
4117
+ * Reading the evidence and classifying it is one act, so it is one function:
4118
+ * a sweep writes what it returns down as `ciFlaky`, and `dw-mc rerun` asks it
4119
+ * again live. Two callers asking the same question have to get the same answer,
4120
+ * which they cannot if each of them spells the question out.
3862
4121
  */
3863
- const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3864
- const [program, ...prefix] = options.launcher.command;
3865
- const failed = failedBy(program);
3866
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3867
- const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [
3868
- ...prefix,
3869
- ...options.launcher.fix_args,
3870
- options.prompt
3871
- ], {
3872
- cwd: options.directory,
3873
- stdin: "inherit",
3874
- stdout: "inherit",
3875
- stderr: "inherit",
3876
- detached: false
3877
- })), (error) => failed(error.message));
3878
- return yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3879
- }, Effect.scoped);
4122
+ const flakyReason = Effect.fn("flaky.flakyReason")(function* (repo, number, entries, ignore, patterns) {
4123
+ const verdict = classify(yield* evidenceFor(repo, number, entries, ignore), patterns);
4124
+ return verdict.classification === "flaky" ? verdict.reason : null;
4125
+ });
3880
4126
  //#endregion
3881
- //#region src/domain/fix.ts
3882
- /** One finding I chose to act on, carrying what I think about it. */
3883
- const Chosen = Schema.Struct({
3884
- ...Finding.fields,
3885
- note: Schema.optionalKey(Schema.String)
4127
+ //#region src/domain/quiet.ts
4128
+ /** The pulse of a PR a previous sweep recorded. */
4129
+ const pulseOf = (facts) => ({
4130
+ head: facts.head,
4131
+ checks: facts.checks,
4132
+ newestHumanCommentAt: facts.newestHumanCommentAt
3886
4133
  });
3887
4134
  /**
3888
- * What a fix session is handed: the findings I picked, and the review run they
3889
- * came from.
4135
+ * Whether a PR is where the last sweep left it.
3890
4136
  *
3891
- * The head is in it because a fix session opens on the commit that was
3892
- * reviewed, and a finding's line means nothing away from it.
4137
+ * A quiet PR keeps the facts it already had rather than being read out again,
4138
+ * so a sweep over many pull requests spends its time on the few that moved.
3893
4139
  */
3894
- const Selection = Schema.Struct({
3895
- repo: Schema.String,
3896
- number: Schema.Int,
3897
- head: Schema.String,
3898
- findings: Schema.Array(Chosen)
3899
- });
3900
- /** The selection as the JSON the schema defines, rather than as this file spells it. */
3901
- const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
4140
+ const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
4141
+ //#endregion
4142
+ //#region src/domain/rebase.ts
3902
4143
  /**
3903
- * The prompt a fix session opens on: what these findings are, and the findings
3904
- * themselves as JSON.
4144
+ * How many pull requests this one stands on.
3905
4145
  *
3906
- * The findings go in verbatim rather than described, because a re-description
3907
- * is where a file, a line or my own note quietly changes. A note outranks the
3908
- * finding it is on: the finding is what the review thought, the note is what I
3909
- * think, and I am the one who picked it.
4146
+ * A branch is walked to what it merges into and on from there, however deep the
4147
+ * stack goes. Every pull request the walk has already counted is left alone,
4148
+ * which is what keeps two branches that merge into each other from being walked
4149
+ * around forever.
4150
+ */
4151
+ const ancestorsOf = (pr, open, seen) => {
4152
+ let count = 0;
4153
+ let current = pr;
4154
+ for (;;) {
4155
+ const parent = open.find((it) => it.head === current.base && !seen.has(it.number));
4156
+ if (parent === void 0) return count;
4157
+ seen.add(parent.number);
4158
+ count += 1;
4159
+ current = parent;
4160
+ }
4161
+ };
4162
+ /**
4163
+ * How deep the stack goes above this pull request.
3910
4164
  *
3911
- * Pushing is mine either way, and `commits` says whether committing is too.
3912
- * The tool itself never commits and never pushes; what the session may do
3913
- * inside the worktree is my call, made once in `fix.commits` or for one session
3914
- * with the flag.
4165
+ * Two branches cut from the same one are not two stacks deep, they are two
4166
+ * branches, so what counts is the deepest single line of them rather than how
4167
+ * many pull requests stand above it in total.
3915
4168
  */
3916
- const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3917
- `These are the findings I picked from a dw-mc review run on ${selection.repo}#${selection.number}, at ${short(selection.head)}, the commit their lines are counted from.`,
3918
- "Work through them one at a time. Where a finding carries a note, the note is mine and outranks the finding's own summary; where it carries none, the summary is the whole brief.",
3919
- commits ? `Commit what you change, one logical change to a commit. Do not push: I read the commits and push them myself.` : `Do not commit and do not push: I do both myself when I have read what you changed.`,
3920
- json
3921
- ].join("\n\n"));
4169
+ const descendantsOf = (pr, open, seen) => {
4170
+ let deepest = 0;
4171
+ for (const child of open.filter((it) => it.base === pr.head && !seen.has(it.number))) {
4172
+ seen.add(child.number);
4173
+ deepest = Math.max(deepest, 1 + descendantsOf(child, open, seen));
4174
+ }
4175
+ return deepest;
4176
+ };
3922
4177
  /**
3923
- * Why these findings cannot be fixed where the pull request now is, or nothing
3924
- * where they can.
4178
+ * Where a pull request sits in its stack, or null where it is in none.
3925
4179
  *
3926
- * A pull request that moved since its last review run has findings at lines
3927
- * that may no longer be there, and a worktree cut at the new head would carry
3928
- * them into code they were never about. Reviewing again is cheap next to fixing
3929
- * the wrong thing.
4180
+ * A stack is read off the branches alone: a pull request that merges into
4181
+ * another pull request's branch, or that another one merges into, is part of
4182
+ * one. The tool does not understand stacks and never drives them, so this
4183
+ * exists to recognise one and say where the pull request sits in it.
3930
4184
  */
3931
- const staleAt = (number, run, now) => run === now ? null : `The findings are from ${short(run)} and the pull request is now at ${short(now)}. Run dw-mc review ${number} again to review the head you would be fixing.`;
3932
- //#endregion
3933
- //#region src/cli/fix.ts
3934
- const printFlag$1 = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
3935
- const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this session commit what it changes, over what the repository configured"), Flag.optional);
4185
+ const stackOf = (number, open) => {
4186
+ const pr = open.find((it) => it.number === number);
4187
+ if (pr === void 0) return null;
4188
+ const seen = /* @__PURE__ */ new Set([number]);
4189
+ const below = ancestorsOf(pr, open, seen);
4190
+ const above = descendantsOf(pr, open, seen);
4191
+ return below === 0 && above === 0 ? null : {
4192
+ position: below + 1,
4193
+ length: below + above + 1
4194
+ };
4195
+ };
3936
4196
  /**
3937
- * The findings to pick from, each on the line `dw-mc findings` gives it.
4197
+ * Why this branch is nobody's to touch here, or null where it is mine.
3938
4198
  *
3939
- * The rows come from there rather than being built again here, so the list I
3940
- * pick from and the list I read are the same list. A row that does not fit the
3941
- * screen is cut: a prompt draws its own frame around the row, and a row that
3942
- * wraps takes the whole list's alignment with it.
4199
+ * These are the guards about the branch rather than about what is done to it,
4200
+ * which is why they are their own and why they say nothing about pushing: who
4201
+ * authored the pull request and where its branch lives is the boundary itself -
4202
+ * a branch somebody else authored and a branch in a fork are not mine to work
4203
+ * on, whatever else is true of them and whichever command asks. A stack comes
4204
+ * next, and a pull request the stack was not read from counts as one, because a
4205
+ * stack the tool cannot see is one it could drive: the tool does not understand
4206
+ * stacks, so the one thing it has to say about one is where the pull request
4207
+ * sits in it.
3943
4208
  */
3944
- const choicesOf$1 = (found, screen) => {
3945
- const rows = lines$1(found);
3946
- const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3947
- return found.findings.map((finding, index) => ({
3948
- title: truncate(rows[index] ?? finding.summary, room),
3949
- value: finding
3950
- }));
4209
+ const boundary = (branch) => {
4210
+ const where = `${branch.repo}#${branch.number}`;
4211
+ if (!branch.mine) return `${where} is not mine. dw-mc works on branches I author and on nothing else.`;
4212
+ if (branch.fromFork) return `${where} is opened from a fork, so its branch is not in ${branch.repo}. dw-mc works only on a branch in the repository it read.`;
4213
+ if (!branch.listed) return `${where} was not among the open pull requests of ${branch.repo}, so nothing here can say whether it is in a stack. Read it again before touching the branch.`;
4214
+ if (branch.stack !== null) return `${where} is ${branch.stack.position} of ${branch.stack.length} in a stack. dw-mc does not understand stacks and will not drive one; rebase it with whatever built the stack.`;
4215
+ return null;
3951
4216
  };
3952
4217
  /**
3953
- * Each picked finding with whatever I have to say about it.
4218
+ * Why this branch is not one to rebase, or null where it is.
3954
4219
  *
3955
- * The note is asked for one finding at a time, in the order I see them, and
3956
- * having nothing to say is the ordinary answer rather than a step I have to get
3957
- * past.
4220
+ * This is the single place the guards live, and they matter more than the
4221
+ * rebase itself: a force push is the one write the tool makes that can lose
4222
+ * work, and every rule here is about it never being a surprise.
4223
+ *
4224
+ * Being off is said first, because a repository that has not turned rebase on
4225
+ * has decided the question and nothing else about the pull request changes it.
4226
+ * The branch's own guards come next. CI is last and costs the most to get
4227
+ * wrong - rebasing while a run is in flight cancels the run I am waiting on,
4228
+ * and a red build is mine to fix where it is.
3958
4229
  */
3959
- const noted = Effect.fn("fix.noted")(function* (picked) {
3960
- const chosen = [];
3961
- for (const finding of picked) {
3962
- const said = yield* note(`Note on ${finding.file}:${finding.line}, or nothing`);
3963
- chosen.push(Option.match(said, {
3964
- onNone: () => finding,
3965
- onSome: (text) => ({
3966
- ...finding,
3967
- note: text
3968
- })
3969
- }));
3970
- }
3971
- return chosen;
3972
- });
3973
- /** The domain's word on a head that has moved, as the command's own failure. */
3974
- const fixable = (number, run, now) => {
3975
- const stale = staleAt(number, run, now);
3976
- return stale === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: stale }));
4230
+ const decide$2 = (situation) => {
4231
+ const where = `${situation.repo}#${situation.number}`;
4232
+ if (!situation.enabled) return `Rebase is off for ${situation.repo}. Set rebase.enabled: true for it in the config to turn it on, so a force push is never a surprise.`;
4233
+ const refused = boundary(situation);
4234
+ if (refused !== null) return refused;
4235
+ if (situation.checks === "pending") return `CI is still running on ${where}. A rebase now would cancel the run you are waiting on.`;
4236
+ if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
4237
+ return null;
3977
4238
  };
3978
4239
  /**
3979
- * A fix session: the findings I picked, in an agent session I steer.
4240
+ * A rebase that conflicted: the head it conflicted at and the files it stopped
4241
+ * on.
3980
4242
  *
3981
- * The tool fixes nothing. It picks the findings apart with me, cuts a worktree
3982
- * on a branch of its own that tracks the pull request's, and hands the session
3983
- * what I chose as JSON; then it is out of the way. I steer and I push. Nothing
3984
- * here writes to GitHub, and the tool itself commits nothing: whether the
3985
- * session may commit inside the worktree is `fix.commits`, or `--commit` for
3986
- * one session.
4243
+ * The head is what the record is scoped to, as it is for a withdrawn stamp: a
4244
+ * conflict is about the code the branch is at, so it lasts exactly as long as
4245
+ * that code is what the pull request is. A branch that moved is a branch
4246
+ * nothing here has tried to rebase yet.
3987
4247
  *
3988
- * The worktree is left standing when the session ends, because the work in it
3989
- * is mine and an unpushed commit lives nowhere else. Re-reviewing the result is
3990
- * a new review run against the new head, never a continuation of the run that
3991
- * produced these findings, so what was reviewed at which commit stays honest.
4248
+ * The paths are what makes the conflict something to open: `a rebase
4249
+ * conflicted` cannot tell a stale lockfile from half the pull request. They are
4250
+ * an optional key rather than a required one so a record an older version wrote
4251
+ * still reads, and a conflict with no paths still puts the pull request in
4252
+ * Needs me.
3992
4253
  */
3993
- const fix = Command.make("fix", {
3994
- pr: prArgument,
3995
- commit: commitFlag,
3996
- print: printFlag$1
3997
- }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3998
- const file = Option.getOrElse(yield* read, () => ({}));
3999
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4000
- const settings = settingsFor(file, repo);
4001
- const run = yield* currentRun(repo, number);
4002
- const found = yield* whatItFound(run);
4003
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
4004
- if (found.findings.length === 0) return;
4005
- const view = yield* reading(`${repo}#${number}`, prView(repo, number));
4006
- yield* fixable(number, run.head, view.headRefOid);
4007
- const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
4008
- const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
4009
- if (chosen.length === 0) {
4010
- yield* Console.log("Nothing picked, so no session was opened.");
4011
- return;
4012
- }
4013
- const commits = Option.getOrElse(commit, () => settings.fix.commits);
4014
- if (print) {
4015
- yield* Console.log(yield* promptFor$1({
4016
- repo,
4017
- number,
4018
- head: run.head,
4019
- findings: chosen
4020
- }, commits));
4021
- return;
4022
- }
4023
- const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
4024
- yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
4025
- yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
4026
- const ended = yield* steeredSession({
4027
- launcher: launcherOf(file),
4028
- directory: worktree.directory,
4029
- prompt: yield* promptFor$1({
4030
- repo,
4031
- number,
4032
- head: worktree.head,
4033
- findings: chosen
4034
- }, commits)
4035
- });
4036
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
4037
- yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
4038
- yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
4039
- }, Effect.catchTag([
4040
- ...userFacing,
4041
- "GitFailed",
4042
- "WorktreeHeld",
4043
- "AgentFailed"
4044
- ], asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
4045
- //#endregion
4046
- //#region src/cli/init.ts
4047
- const effortFlag$1 = Flag.Literals("effort", [
4048
- "low",
4049
- "medium",
4050
- "high",
4051
- "xhigh",
4052
- "max"
4053
- ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
4054
- const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
4055
- /** The settings the flags asked for, and only those. */
4056
- const asked = (base, effort) => ({
4057
- ...Option.isSome(base) ? { base: base.value } : {},
4058
- ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
4254
+ const Conflict = Schema.Struct({
4255
+ head: Schema.String,
4256
+ paths: Schema.optionalKey(Schema.Array(Schema.String))
4059
4257
  });
4060
- /** What a review will open on, as the setup prints it back. */
4061
- const opening = (defaults) => {
4062
- const review = {
4063
- ...builtIn.review,
4064
- ...defaults.review
4065
- };
4066
- return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
4067
- };
4068
- const row = (label, value) => `${label.padEnd(12)}${value}`;
4069
4258
  /**
4070
- * Both the machine setup and the repository registration: there is deliberately
4071
- * no separate `setup` command.
4072
- *
4073
- * The first run on a machine checks `gh` and spells the defaults out in the
4074
- * configuration file. Run inside a repository, it also registers that
4075
- * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
4076
- * changes what the flags name, keeps every other setting the file already had,
4077
- * and leaves the file untouched where nothing was decided differently.
4078
- *
4079
- * It asks nothing. Reviews run on Claude Code, and what a run opens on is
4080
- * `review.command` and `review.prompt` - a line and a paragraph that belong in
4081
- * the file rather than in a terminal prompt.
4259
+ * The conflict a rebase last left on this pull request, or null where it left
4260
+ * none.
4082
4261
  *
4083
- * `--effort` and `--base` are about one repository, so they land on the
4084
- * repository this ran in, or in the defaults when it ran outside one.
4262
+ * Forgetting one costs the pull request one reason to be in Needs me, where
4263
+ * failing here would cost me the whole table.
4085
4264
  */
4086
- const init = Command.make("init", {
4087
- effort: effortFlag$1,
4088
- base: baseFlag
4089
- }, Effect.fn("init")(function* ({ base, effort }) {
4090
- yield* requireAuth;
4091
- const config = yield* ConfigStore;
4092
- const before = yield* read;
4093
- const file = Option.getOrElse(before, () => ({}));
4094
- const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
4095
- const state = yield* stateDirectory;
4096
- const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
4097
- const overrides = asked(base, effort);
4098
- const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
4099
- if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
4100
- yield* Console.log(row("review", opening(written.defaults ?? {})));
4101
- yield* Console.log(row("config", config.path));
4102
- yield* Console.log(row("state", state));
4103
- yield* Console.log(Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`));
4104
- }, Effect.catchTag([
4105
- "ConfigMalformed",
4106
- "GhUnauthenticated",
4107
- "GhUnavailable",
4108
- "GhUnreadable"
4109
- ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
4265
+ const conflictFor = Effect.fn("rebase.conflictFor")(function* (repo, number) {
4266
+ const store = yield* storeFor("rebases", Conflict);
4267
+ const conflict = yield* remembered(store.get(prKey(repo, number)));
4268
+ return Option.getOrNull(conflict);
4269
+ });
4270
+ /** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
4271
+ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
4272
+ yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
4273
+ head,
4274
+ paths
4275
+ });
4276
+ });
4110
4277
  //#endregion
4111
- //#region src/domain/stamp.ts
4278
+ //#region src/cli/sweep.ts
4279
+ /** What the picker asks a sweep for: every registered repository, wherever I stand. */
4280
+ const everything = {
4281
+ repo: void 0,
4282
+ all: true
4283
+ };
4284
+ /** The repository I stand in, or none where the working directory is not one. */
4285
+ const whereIAm = currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone), Effect.map(Option.getOrUndefined));
4286
+ const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
4287
+ const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
4112
4288
  /**
4113
- * A stamp I took off a pull request by hand, and the head I took it off at.
4289
+ * The facts about one tracked PR, read from GitHub and kept on disk.
4114
4290
  *
4115
- * The head is the whole record: a withdrawal is my overruling the computation
4116
- * on code I have read, so it lasts exactly as long as that code is what the
4117
- * pull request is.
4291
+ * The cheap reads happen every time, because they are what says whether the PR
4292
+ * moved. The commits are asked for only when it did: `gh` returns every commit
4293
+ * message in full, and on a PR that is where the last sweep left it that whole
4294
+ * read buys a timestamp the state directory already has.
4118
4295
  */
4119
- const Withdrawal = Schema.Struct({ head: Schema.String });
4120
- /** The stamp a pull request has not earned, and the first reason it has not. */
4121
- const withheld = (reason) => ({
4122
- stamped: false,
4123
- reason
4296
+ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, settings) {
4297
+ const view = yield* prView(found.repo, found.number);
4298
+ const [onThePr, inReviews] = yield* Effect.all([prComments(found.repo, found.number), prReviews(found.repo, found.number)], { concurrency: 2 });
4299
+ const comments = [...onThePr, ...inReviews];
4300
+ const checks = rollupState(view.statusCheckRollup, settings.ci.ignore);
4301
+ const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
4302
+ const key = prKey(found.repo, found.number);
4303
+ const previous = Option.getOrUndefined(yield* remembered(store.get(key)));
4304
+ const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
4305
+ const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
4306
+ head: view.headRefOid,
4307
+ checks,
4308
+ newestHumanCommentAt
4309
+ }) ? previous : void 0;
4310
+ const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
4311
+ const ciFlaky = checks !== "red" ? null : quiet !== void 0 ? quiet.ciFlaky : yield* flakyReason(found.repo, found.number, view.statusCheckRollup, settings.ci.ignore, settings.ci.flaky_patterns);
4312
+ const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
4313
+ const facts = {
4314
+ repo: found.repo,
4315
+ number: found.number,
4316
+ title: view.title,
4317
+ url: view.url,
4318
+ draft: view.isDraft,
4319
+ head: view.headRefOid,
4320
+ mergeable: mergeabilityOf(view.mergeable),
4321
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
4322
+ checks,
4323
+ ciFlaky,
4324
+ rebaseConflictAt,
4325
+ newestHumanCommentAt,
4326
+ myLastCommentAt: newest(writtenBy(comments, me)),
4327
+ myLastCommitAt,
4328
+ ...reviewed
4329
+ };
4330
+ yield* store.set(key, facts);
4331
+ return facts;
4124
4332
  });
4125
- /** What CI has to say before the stamp will rest on it, which is green and nothing else. */
4126
- const whyNotGreen = {
4127
- green: null,
4128
- red: "CI is red",
4129
- pending: "CI is still running",
4130
- none: "no CI ran on this head"
4131
- };
4132
- /** What GitHub has to say about merging, which is that it would. */
4133
- const whyNotMergeable = {
4134
- mergeable: null,
4135
- conflicting: "merge conflict",
4136
- unknown: "GitHub has not said whether it merges"
4137
- };
4333
+ /** A read that came back, or the trouble it came back with instead. */
4334
+ const attempt = (where, read) => read.pipe(Effect.map((got) => ({
4335
+ got,
4336
+ troubles: []
4337
+ })), Effect.catch((error) => Effect.succeed({
4338
+ got: [],
4339
+ troubles: [{
4340
+ where,
4341
+ detail: error.message
4342
+ }]
4343
+ })));
4344
+ const gather = (attempts) => ({
4345
+ got: attempts.flatMap((it) => it.got),
4346
+ troubles: attempts.flatMap((it) => it.troubles)
4347
+ });
4348
+ /** How many reads of GitHub are in flight at once. */
4349
+ const concurrency = 4;
4350
+ /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
4351
+ const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
4352
+ /** How the heartbeat of a sweep reads, wherever a command turns one. */
4353
+ const saying$1 = (swept) => (since) => [
4354
+ "sweeping",
4355
+ swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
4356
+ since
4357
+ ].join(" · ");
4138
4358
  /**
4139
- * The stamp of one tracked PR: whether it has passed my bar, and why.
4140
- *
4141
- * The mark is computed rather than clicked, so it means the same thing every
4142
- * time: a review run on this head that found nothing blocking, CI green as the
4143
- * repository's `ci.ignore` defines green, and a pull request GitHub would
4144
- * merge. A red CI the flaky classifier excused is still not green here: an
4145
- * excuse is a reason not to fix a check, not a reason to land code behind one,
4146
- * and this mark is what clears `dw-mc merge` (ADR 0008).
4359
+ * One pass over the tracked PRs of the repositories `asked` covers, and nothing
4360
+ * else: a sweep only ever reads.
4147
4361
  *
4148
- * Nothing about this rests on a previous stamp, which is what makes a head
4149
- * change clear it: facts are about one head, and a run is recorded against one.
4362
+ * Every repository and every pull request is read on its own, so one of them
4363
+ * failing costs me its rows and leaves the rest of the table standing. What
4364
+ * failed comes back beside the facts rather than instead of them.
4150
4365
  *
4151
- * A withdrawal comes first, because it is the one thing here I decided rather
4152
- * than computed.
4366
+ * `report` is told how far the pass has got, every time it gets further. What
4367
+ * that is worth saying is the caller's, which is why it is handed a count and
4368
+ * not a sentence.
4153
4369
  */
4154
- const stampFor = (facts, withdrawnAt) => {
4155
- if (withdrawnAt === facts.head) return withheld("withdrawn by hand");
4156
- if (facts.reviewRunHead !== facts.head) return withheld("no review run on this head");
4157
- if (facts.blockingFindings > 0) return withheld(`${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`);
4158
- const ci = whyNotGreen[facts.checks];
4159
- if (ci !== null) return withheld(ci);
4160
- const merge = whyNotMergeable[facts.mergeable];
4161
- if (merge !== null) return withheld(merge);
4162
- return {
4163
- stamped: true,
4164
- reason: "a clean review run on this head, green CI, mergeable"
4370
+ const sweep = Effect.fn("sweep")(function* (asked, report) {
4371
+ const file = Option.getOrElse(yield* read, () => ({}));
4372
+ const registered = Object.keys(file.repos ?? {}).toSorted();
4373
+ const coverage = covered(asked, asksWhereIAm(asked, registered) ? yield* whereIAm : void 0, registered);
4374
+ if (coverage._tag === "refused") return yield* new CliError.UserError({ cause: coverage.why });
4375
+ const { repos, leftOut } = coverage;
4376
+ if (repos.length === 0) return {
4377
+ repos,
4378
+ leftOut,
4379
+ facts: [],
4380
+ troubles: []
4165
4381
  };
4166
- };
4167
- /**
4168
- * The head a stamp was withdrawn at, or null where none was.
4169
- *
4170
- * A withdrawal this version cannot read is one another version of this record
4171
- * wrote, and a stamp is computed from everything else: forgetting it hands the
4172
- * pull request back to the computation, where failing here would cost me the
4173
- * command I asked for.
4174
- */
4175
- const withdrawnAt = Effect.fn("stamp.withdrawnAt")(function* (repo, number) {
4176
- const store = yield* storeFor("stamps", Withdrawal);
4177
- const withdrawal = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
4178
- return Option.match(withdrawal, {
4179
- onNone: () => null,
4180
- onSome: (it) => it.head
4382
+ const store = yield* storeFor("prs", Facts);
4383
+ const me = yield* viewer;
4384
+ let searched = 0;
4385
+ yield* report({
4386
+ _tag: "searching",
4387
+ done: 0,
4388
+ of: repos.length
4181
4389
  });
4182
- });
4183
- /** Takes the stamp off a pull request at `head`, which is the only head it stays off. */
4184
- const withdraw = Effect.fn("stamp.withdraw")(function* (repo, number, head) {
4185
- yield* (yield* storeFor("stamps", Withdrawal)).set(prKey(repo, number), { head });
4186
- });
4187
- /** The stamp of one tracked PR, with the withdrawal this machine holds against it. */
4188
- const stampOf = Effect.fn("stamp.stampOf")(function* (facts) {
4189
- return stampFor(facts, yield* withdrawnAt(facts.repo, facts.number));
4390
+ const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
4391
+ searched = searched + 1;
4392
+ return report({
4393
+ _tag: "searching",
4394
+ done: searched,
4395
+ of: repos.length
4396
+ });
4397
+ }), { concurrency }));
4398
+ let read$1 = 0;
4399
+ yield* report({
4400
+ _tag: "reading",
4401
+ done: 0,
4402
+ of: found.got.length
4403
+ });
4404
+ const swept = gather(yield* Effect.forEach(found.got, (pr) => Effect.tap(attempt(`${pr.repo}#${pr.number}`, Effect.map(sweepPr(store, me, pr, settingsFor(file, pr.repo)), (facts) => [facts])), () => {
4405
+ read$1 = read$1 + 1;
4406
+ return report({
4407
+ _tag: "reading",
4408
+ done: read$1,
4409
+ of: found.got.length
4410
+ });
4411
+ }), { concurrency }));
4412
+ return {
4413
+ repos,
4414
+ leftOut,
4415
+ facts: swept.got,
4416
+ troubles: [...found.troubles, ...swept.troubles]
4417
+ };
4190
4418
  });
4191
4419
  /**
4192
- * Which of these tracked PRs carry a stamp, keyed the way their facts are.
4420
+ * A sweep under its heartbeat, which is how every command that sweeps runs one.
4193
4421
  *
4194
- * A table asks the question of every row at once, and the withdrawals are the
4195
- * only thing here that has to be read off the disk.
4422
+ * The three of them want the same line, so they say it once here rather than
4423
+ * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
4424
+ * prints exactly what it printed before there was a heartbeat at all.
4196
4425
  */
4197
- const stampedAmong = Effect.fn("stamp.stampedAmong")(function* (facts) {
4198
- const marks = yield* Effect.forEach(facts, (it) => Effect.map(stampOf(it), (stamp) => ({
4199
- key: prKey(it.repo, it.number),
4200
- stamped: stamp.stamped
4201
- })));
4202
- return new Set(marks.filter((mark) => mark.stamped).map((mark) => mark.key));
4426
+ const sweeping = (asked) => beating((since) => `sweeping · ${since}`, (says) => sweep(asked, (swept) => says(saying$1(swept))));
4427
+ /** `--repo`, for a command that sweeps: the one registered repository to cover. */
4428
+ const repoFlag = Flag.String("repo").pipe(Flag.withDescription("Cover this registered repository only, as owner/name, wherever I stand"), Flag.optional);
4429
+ /** `--all`, for a command that sweeps: every registered repository, even from inside one. */
4430
+ const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Cover every registered repository, and not only the one I stand in"));
4431
+ /** What the two flags ask a sweep to cover. */
4432
+ const askedOf$1 = (flags) => ({
4433
+ repo: Option.getOrUndefined(flags.repo),
4434
+ all: flags.all
4203
4435
  });
4204
- //#endregion
4205
- //#region src/domain/merge.ts
4206
- /**
4207
- * Why GitHub would not call this pull request Ready, or null where it would.
4208
- *
4209
- * Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
4210
- * A repository that requires no reviewer produces no approval, which is why
4211
- * `none` passes and `review-required` does not - what holds a merge is somebody
4212
- * having been asked and not yet answered.
4213
- *
4214
- * A red CI the flaky classifier excused is still red here. The excuse is a
4215
- * reason not to fix a check; it is not a reason to land code behind one.
4216
- */
4217
- const whyNotReady = (situation) => {
4218
- if (situation.reviewDecision === "changes-requested") return "changes are requested";
4219
- if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
4220
- return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
4221
- };
4222
- /**
4223
- * What to do about a pull request that is Ready and carries no stamp.
4224
- *
4225
- * The stamp is withheld for one of three reasons and each has its own next
4226
- * step, so the refusal names that step rather than leaving me to work out which
4227
- * of the three it was. A withdrawal is the one with no command: I took the mark
4228
- * off code I had read, and only that code changing puts it back.
4229
- */
4230
- const earnsIt = (situation) => {
4231
- if (situation.withdrawnAt === situation.head) return "\n\nYou took it off at this head, and it stays off until the head changes.";
4232
- const next = situation.reviewRunHead !== situation.head ? {
4233
- command: `dw-mc review ${situation.number}`,
4234
- says: "That reviews this head, and a run that finds nothing blocking stamps it."
4235
- } : {
4236
- command: `dw-mc fix ${situation.number}`,
4237
- says: "That opens a session on the findings. The stamp is back once the head has moved and a review run has read it."
4238
- };
4239
- return `\n\n ${next.command}\n\n${next.says}`;
4240
- };
4241
4436
  /**
4242
- * Why this pull request is not one to merge, or null where it is.
4243
- *
4244
- * This is the single place the merge guards live, and they carry more than the
4245
- * merge does: it is the one write the tool makes that no reflog of mine undoes
4246
- * (ADR 0008). Two bars have to be clear, because each is blind to what the
4247
- * other sees - GitHub does not know whether anything read the diff, and the
4248
- * stamp does not know whether a reviewer asked for changes.
4249
- *
4250
- * Whose pull request it is comes first, as it does everywhere else: one
4251
- * somebody else opened is none of this tool's business, whatever is true of it.
4252
- * A draft is next, because a pull request I have not offered to anybody is not
4253
- * one to land however green it is.
4254
- *
4255
- * Ready is asked before the stamp so that the refusal names the bar I am
4256
- * actually under. The stamp insists on green CI and a mergeable pull request
4257
- * too, so everything it can be withheld for here is mine rather than GitHub's.
4437
+ * The word a narrowed report owes me about what it left out, so a table of one
4438
+ * repository is not read as the whole picture.
4258
4439
  */
4259
- const decide$2 = (situation) => {
4260
- const where = `${situation.repo}#${situation.number}`;
4261
- if (!situation.mine) return `${where} is not mine. dw-mc merges pull requests I author and nothing else.`;
4262
- if (situation.draft) return `${where} is a draft. Mark it ready for review before merging it.`;
4263
- const ready = whyNotReady(situation);
4264
- if (ready !== null) return `${where} is not Ready: ${ready}. dw-mc merges nothing GitHub would not merge itself.`;
4265
- const stamp = stampFor(situation, situation.withdrawnAt);
4266
- return stamp.stamped ? null : `${where} is Ready and carries no stamp: ${stamp.reason}.${earnsIt(situation)}`;
4267
- };
4268
- //#endregion
4269
- //#region src/cli/merge.ts
4440
+ const printLeftOut = Effect.fn("sweep.printLeftOut")(function* (report) {
4441
+ if (report.leftOut === 0) return;
4442
+ yield* Console.log("");
4443
+ yield* Console.log(`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`);
4444
+ });
4445
+ /** What a sweep could not read, under a heading, so the table above it stands alone. */
4446
+ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4447
+ if (troubles.length === 0) return;
4448
+ yield* Console.log("");
4449
+ yield* Console.log("Could not load");
4450
+ for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
4451
+ });
4270
4452
  /**
4271
- * Lands one pull request of mine: squashed, with its branch deleted.
4453
+ * Refreshes what mission control knows about the tracked PRs it covers.
4272
4454
  *
4273
- * This is the write ADR 0008 is about, and the only one the tool makes that no
4274
- * reflog of mine brings back. It is outside ADR 0002's three because it moves a
4275
- * shared branch; everything 0002 bars - comment, reply, thread resolve, label,
4276
- * review, approval, status - still holds here as it does everywhere.
4277
- *
4278
- * The threshold is two bars at one head: Ready, which is GitHub's opinion, and
4279
- * my stamp, which is mine. Each is blind to what the other sees, so the write
4280
- * that cannot be undone clears both.
4281
- *
4282
- * GitHub's half is read live from a fresh `pr view` rather than off the last
4283
- * sweep, the way the rebase and re-run guards are. A stale verdict costs a
4284
- * re-run some CI minutes; here it costs merging code nobody read. My half comes
4285
- * from the state directory, because the review runs and the withdrawal live
4286
- * there and are already scoped to the head this read just named.
4287
- *
4288
- * Typing the command is the confirmation, so it takes no flag. The picker,
4289
- * where a keystroke is cheaper, asks before it dispatches.
4455
+ * `dw-mc status` does this too, so this command is for the pass on its own:
4456
+ * warming the state directory, or seeing what GitHub would not answer.
4290
4457
  */
4291
- const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
4292
- const file = Option.getOrElse(yield* read, () => ({}));
4293
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4294
- const settings = settingsFor(file, repo);
4295
- const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4296
- const head = view.headRefOid;
4297
- yield* refuse(decide$2({
4298
- repo,
4299
- number,
4300
- head,
4301
- mine: view.author?.login === me,
4302
- draft: view.isDraft,
4303
- reviewDecision: reviewDecisionOf(view.reviewDecision),
4304
- checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
4305
- mergeable: mergeabilityOf(view.mergeable),
4306
- ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
4307
- withdrawnAt: yield* withdrawnAt(repo, number)
4308
- }));
4309
- yield* mergePr(repo, number);
4310
- yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
4311
- yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
4312
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
4458
+ const sweepCommand = Command.make("sweep", {
4459
+ repo: repoFlag,
4460
+ all: allFlag
4461
+ }, Effect.fn("sweep.command")(function* (flags) {
4462
+ const report = yield* sweeping(askedOf$1(flags));
4463
+ yield* Console.log(report.repos.length === 0 ? "No repositories registered. Run dw-mc init inside a repository to register it." : `Swept ${count(report.facts.length, "pull request")} across ${repositories(report.repos.length)}`);
4464
+ yield* printLeftOut(report);
4465
+ yield* printTroubles(report.troubles);
4466
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about the tracked pull requests of the repository I stand in, or of every one"));
4313
4467
  //#endregion
4314
4468
  //#region src/domain/pick.ts
4315
4469
  /**
@@ -4434,13 +4588,11 @@ const Rerun = Schema.Struct({ head: Schema.String });
4434
4588
  * The head a re-run was last asked for at on this pull request, or null where
4435
4589
  * none has been.
4436
4590
  *
4437
- * A record this version cannot read is one another version of it wrote. Reading
4438
- * it again as nothing costs a flaky pull request one extra re-run, where failing
4439
- * here would cost the command outright.
4591
+ * Forgetting one costs a flaky pull request one extra re-run.
4440
4592
  */
4441
4593
  const rerunFor = Effect.fn("rerun.rerunFor")(function* (repo, number) {
4442
4594
  const store = yield* storeFor("reruns", Rerun);
4443
- const rerun = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
4595
+ const rerun = yield* remembered(store.get(prKey(repo, number)));
4444
4596
  return Option.getOrNull(rerun)?.head ?? null;
4445
4597
  });
4446
4598
  /** Writes down that a re-run was asked for at `head`, which is the only head it caps. */
@@ -4529,7 +4681,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
4529
4681
  * and a merge must never cost only that (ADR 0008).
4530
4682
  */
4531
4683
  const picker = (dispatch) => Effect.fn("pick")(function* () {
4532
- const report = yield* sweeping;
4684
+ const report = yield* sweeping(everything);
4533
4685
  if (report.repos.length === 0) {
4534
4686
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
4535
4687
  return;
@@ -4592,15 +4744,13 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4592
4744
  * so what it has to say about one is where the pull request sits in it.
4593
4745
  */
4594
4746
  const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(function* ({ pr }) {
4595
- const file = Option.getOrElse(yield* read, () => ({}));
4596
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4597
- const settings = settingsFor(file, repo);
4747
+ const { number, repo, settings } = yield* forPr(pr);
4598
4748
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4599
4749
  prView(repo, number),
4600
4750
  openPrs(repo),
4601
4751
  viewer
4602
4752
  ]));
4603
- yield* refuse(decide$3({
4753
+ yield* refuse(decide$2({
4604
4754
  repo,
4605
4755
  number,
4606
4756
  base: view.baseRefName,
@@ -4633,7 +4783,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4633
4783
  return;
4634
4784
  }
4635
4785
  yield* Console.log(`${where} ${short(done.before)} → ${short(done.after)} rebased ${count(done.behind, "commit")} of ${view.baseRefName} and pushed with a lease`);
4636
- }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4786
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4637
4787
  //#endregion
4638
4788
  //#region src/cli/rerun.ts
4639
4789
  /**
@@ -4655,9 +4805,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4655
4805
  * it is a pull request re-running itself until the minutes run out.
4656
4806
  */
4657
4807
  const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(function* ({ pr }) {
4658
- const file = Option.getOrElse(yield* read, () => ({}));
4659
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4660
- const settings = settingsFor(file, repo);
4808
+ const { number, repo, settings } = yield* forPr(pr);
4661
4809
  const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4662
4810
  const unclassified = {
4663
4811
  repo,
@@ -4745,11 +4893,6 @@ const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
4745
4893
  //#endregion
4746
4894
  //#region src/cli/resolve.ts
4747
4895
  const printFlag = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
4748
- /** The domain's word on a conflict that is not one to open, as the command's own failure. */
4749
- const allowed = (situation) => {
4750
- const refused = decide(situation);
4751
- return refused === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: refused }));
4752
- };
4753
4896
  /**
4754
4897
  * A session on the conflict that stopped a rebase, in a worktree that is mine.
4755
4898
  *
@@ -4774,15 +4917,14 @@ const resolve = Command.make("resolve", {
4774
4917
  pr: prArgument,
4775
4918
  print: printFlag
4776
4919
  }, Effect.fn("resolve")(function* ({ pr, print }) {
4777
- const file = Option.getOrElse(yield* read, () => ({}));
4778
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
4920
+ const { number, repo, launcher } = yield* forPr(pr);
4779
4921
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4780
4922
  prView(repo, number),
4781
4923
  openPrs(repo),
4782
4924
  viewer
4783
4925
  ]));
4784
4926
  const conflict = yield* conflictFor(repo, number);
4785
- yield* allowed({
4927
+ yield* refuse(decide({
4786
4928
  repo,
4787
4929
  number,
4788
4930
  mine: view.author?.login === me,
@@ -4791,7 +4933,7 @@ const resolve = Command.make("resolve", {
4791
4933
  stack: stackOf(number, open),
4792
4934
  head: view.headRefOid,
4793
4935
  conflictAt: conflict === null ? null : conflict.head
4794
- });
4936
+ }));
4795
4937
  /** The conflict as the prompt takes it, around whichever paths are known by then. */
4796
4938
  const conflicted = (paths) => ({
4797
4939
  repo,
@@ -4824,7 +4966,7 @@ const resolve = Command.make("resolve", {
4824
4966
  yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
4825
4967
  yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
4826
4968
  const ended = yield* steeredSession({
4827
- launcher: launcherOf(file),
4969
+ launcher,
4828
4970
  directory: worktree.directory,
4829
4971
  prompt: yield* promptFor(conflicted(stopped.paths))
4830
4972
  });
@@ -4838,12 +4980,7 @@ const resolve = Command.make("resolve", {
4838
4980
  ``
4839
4981
  ], (line) => Console.log(line));
4840
4982
  yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
4841
- }, Effect.catchTag([
4842
- ...userFacing,
4843
- "GitFailed",
4844
- "WorktreeHeld",
4845
- "AgentFailed"
4846
- ], asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4983
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4847
4984
  //#endregion
4848
4985
  //#region src/adapters/notify.ts
4849
4986
  /** A string as AppleScript spells one, so a quotation mark cannot end it early. */
@@ -5119,10 +5256,7 @@ const review = Command.make("review", {
5119
5256
  commandOnly: commandOnlyFlag,
5120
5257
  force: forceFlag$1
5121
5258
  }, Effect.fn("review")(function* ({ command, commandOnly, effort, force, model, pr, prompt, promptOnly }) {
5122
- const file = Option.getOrElse(yield* read, () => ({}));
5123
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5124
- const settings = settingsFor(file, repo);
5125
- const launcher = launcherOf(file);
5259
+ const { number, repo, settings, launcher } = yield* forPr(pr);
5126
5260
  const asked = yield* asking({
5127
5261
  settings,
5128
5262
  command,
@@ -5196,7 +5330,7 @@ const review = Command.make("review", {
5196
5330
  yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5197
5331
  yield* unreported(run, number);
5198
5332
  }).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
5199
- }, Effect.catchTag([...userFacing, "GitFailed"], asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5333
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5200
5334
  //#endregion
5201
5335
  //#region src/cli/stamp.ts
5202
5336
  const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
@@ -5221,8 +5355,7 @@ const stampCommand = Command.make("stamp", {
5221
5355
  pr: prArgument,
5222
5356
  withdraw: withdrawFlag
5223
5357
  }, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
5224
- const file = Option.getOrElse(yield* read, () => ({}));
5225
- const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
5358
+ const { number, repo } = yield* forPr(pr);
5226
5359
  const facts = yield* swept(repo, number);
5227
5360
  const where = `${repo}#${number} ${short(facts.head)}`;
5228
5361
  if (byHand) {
@@ -5261,8 +5394,11 @@ const lines = (grouped, stamped, paint) => {
5261
5394
  *
5262
5395
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5263
5396
  */
5264
- const status = Command.make("status", {}, Effect.fn("status")(function* () {
5265
- const report = yield* sweeping;
5397
+ const status = Command.make("status", {
5398
+ repo: repoFlag,
5399
+ all: allFlag
5400
+ }, Effect.fn("status")(function* (flags) {
5401
+ const report = yield* sweeping(askedOf$1(flags));
5266
5402
  if (report.repos.length === 0) {
5267
5403
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
5268
5404
  return;
@@ -5270,8 +5406,9 @@ const status = Command.make("status", {}, Effect.fn("status")(function* () {
5270
5406
  const grouped = group(report.facts);
5271
5407
  if (grouped.length === 0) yield* Console.log("No open pull requests.");
5272
5408
  for (const line of lines(grouped, yield* stampedAmong(report.facts), yield* Paint)) yield* Console.log(line);
5409
+ yield* printLeftOut(report);
5273
5410
  yield* printTroubles(report.troubles);
5274
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Show which bucket every tracked pull request sits in, and which ones I have stamped"));
5411
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Show which bucket every tracked pull request of the repository I stand in, or of every one, sits in, and which ones I have stamped"));
5275
5412
  //#endregion
5276
5413
  //#region src/cli/uninstall.ts
5277
5414
  const configFlag = Flag.Boolean("config").pipe(Flag.withDefault(false), Flag.withDescription("Take the configuration file too, and not only the state"));
@@ -5327,7 +5464,7 @@ const uninstall = Command.make("uninstall", {
5327
5464
  }] : [])).pipe(Effect.map((found_) => found_.flat()));
5328
5465
  yield* Effect.forEach(removes({
5329
5466
  directory: found.directory,
5330
- size: weight(everything(found))
5467
+ size: weight(everything$1(found))
5331
5468
  }, alsoConfig && configured ? file : void 0, paint), (line) => Console.log(line));
5332
5469
  if (holds.length > 0) {
5333
5470
  yield* Effect.forEach(held(holds, paint), (line) => Console.log(line));
@@ -5354,7 +5491,7 @@ const uninstall = Command.make("uninstall", {
5354
5491
  * Running from source leaves the constant undeclared rather than undefined, so
5355
5492
  * the check has to be `typeof` and the fallback is what a test reads.
5356
5493
  */
5357
- const version = "0.5.0";
5494
+ const version = "0.6.0";
5358
5495
  /** Where the project lives, printed beside the version in the header. */
5359
5496
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5360
5497
  const subcommands = [