dw-mc 0.5.0 → 0.5.1

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 +1894 -1839
  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"));
@@ -787,6 +827,14 @@ const git = (args) => capture("git", args).pipe(Effect.catchTags({
787
827
  detail: error.stderr
788
828
  }))
789
829
  }));
830
+ /**
831
+ * `n` commits, which is the one thing this file counts out loud.
832
+ *
833
+ * It is said twice - before a session is cut and before one is taken away -
834
+ * and about the same commits both times: the ones the pull request's head does
835
+ * not have.
836
+ */
837
+ const commits = (n) => `${n} commit${n === 1 ? "" : "s"}`;
790
838
  /** A fix worktree that still holds work of mine, which nothing may cut away. */
791
839
  var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
792
840
  directory: Schema.String,
@@ -824,16 +872,14 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
824
872
  */
825
873
  const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
826
874
  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`);
875
+ const clone = yield* cloneAt(repo);
830
876
  const bare = yield* Effect.orElseSucceed(git([
831
877
  "-C",
832
878
  clone,
833
879
  "rev-parse",
834
880
  "--is-bare-repository"
835
881
  ]), () => "");
836
- const pullRef = `refs/dw-mc/pr/${number}`;
882
+ const ref = pullRef(number);
837
883
  return {
838
884
  clone,
839
885
  head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
@@ -854,17 +900,17 @@ const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
854
900
  "--no-tags",
855
901
  "--force",
856
902
  "origin",
857
- `+refs/pull/${number}/head:${pullRef}`,
903
+ `+refs/pull/${number}/head:${ref}`,
858
904
  "+refs/heads/*:refs/heads/*"
859
905
  ]);
860
906
  return yield* git([
861
907
  "-C",
862
908
  clone,
863
909
  "rev-parse",
864
- pullRef
910
+ ref
865
911
  ]);
866
912
  })),
867
- directory: path.join(state, cut, repo, String(number))
913
+ directory: yield* cutAt(cut, repo, number)
868
914
  };
869
915
  });
870
916
  /**
@@ -1025,11 +1071,11 @@ const reuseResolutions = Effect.fn("git.reuseResolutions")(function* (clone) {
1025
1071
  */
1026
1072
  const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, number, prBranch, session) {
1027
1073
  const { clone, directory, head } = yield* whereToCut(repo, number, under[session]);
1028
- const branch = `dw-mc/${session}/${number}`;
1074
+ const branch = sessionBranch(session, number);
1029
1075
  const ahead = yield* aheadOf(clone, branch, head);
1030
1076
  if (ahead > 0) return yield* new WorktreeHeld({
1031
1077
  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.`
1078
+ 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
1079
  });
1034
1080
  if ((yield* worktreesOf(clone)).includes(directory)) yield* git([
1035
1081
  "-C",
@@ -1271,11 +1317,9 @@ const clear = { _tag: "clear" };
1271
1317
  * pruned or moved by hand still leaves the branch holding the commits.
1272
1318
  */
1273
1319
  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}`;
1320
+ const clone = yield* cloneAt(repo);
1321
+ const directory = yield* cutAt(under[session], repo, number);
1322
+ const branch = sessionBranch(session, number);
1279
1323
  if ((yield* Effect.orElseSucceed(git([
1280
1324
  "-C",
1281
1325
  directory,
@@ -1298,7 +1342,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1298
1342
  "-C",
1299
1343
  clone,
1300
1344
  "rev-parse",
1301
- `refs/dw-mc/pr/${number}`
1345
+ pullRef(number)
1302
1346
  ]), () => "");
1303
1347
  if (head.trim() === "") return {
1304
1348
  _tag: "held",
@@ -1307,7 +1351,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
1307
1351
  const ahead = yield* aheadOf(clone, branch, head.trim());
1308
1352
  return ahead === 0 ? clear : {
1309
1353
  _tag: "held",
1310
- detail: `${ahead} commit${ahead === 1 ? "" : "s"} that the pull request's head does not have`
1354
+ detail: `${commits(ahead)} that the pull request's head does not have`
1311
1355
  };
1312
1356
  });
1313
1357
  /**
@@ -2004,6 +2048,40 @@ const prConversation = Effect.fnUntraced(function* (repo, number) {
2004
2048
  }], ...threads];
2005
2049
  });
2006
2050
  //#endregion
2051
+ //#region src/cli/exit.ts
2052
+ /**
2053
+ * The failures a command owes me a sentence for rather than a stack.
2054
+ *
2055
+ * Every one of them is a machine or a file that needs fixing, and the message
2056
+ * says what to fix. Anything not named here is a fault of the tool's own, and a
2057
+ * stack is what I want to see for those.
2058
+ */
2059
+ const userFacing = [
2060
+ "ConfigMalformed",
2061
+ "GhUnavailable",
2062
+ "GhReadFailed",
2063
+ "GhUnreadable"
2064
+ ];
2065
+ /** The same, for a command that also runs `git` against the tool's own clone. */
2066
+ const userFacingAndGit = [...userFacing, "GitFailed"];
2067
+ /**
2068
+ * The same, for a command that cuts a standing worktree and opens an agent
2069
+ * session in it.
2070
+ *
2071
+ * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2072
+ * because they do the same thing to different findings: a worktree that holds
2073
+ * work of mine and an agent that would not run are the session's failures, not
2074
+ * either command's.
2075
+ */
2076
+ const userFacingAndSession = [
2077
+ ...userFacing,
2078
+ "GitFailed",
2079
+ "WorktreeHeld",
2080
+ "AgentFailed"
2081
+ ];
2082
+ /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2083
+ const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2084
+ //#endregion
2007
2085
  //#region src/domain/moment.ts
2008
2086
  const isLater = Order.isGreaterThan(DateTime.Order);
2009
2087
  /** Whether `self` happened after `other`, counting never as before anything. */
@@ -2102,6 +2180,15 @@ const order = [
2102
2180
  */
2103
2181
  const unanswered = "a comment I have not answered";
2104
2182
  /**
2183
+ * Why a PR is mine to move when a review run found something that withholds
2184
+ * the stamp.
2185
+ *
2186
+ * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2187
+ * sentence about this same number, and two spellings of it would be two
2188
+ * answers to what a blocking finding is worth.
2189
+ */
2190
+ const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2191
+ /**
2105
2192
  * The first of the rules that makes a PR mine to move, or null when none
2106
2193
  * does. The order is the order I would fix them in: a conflict makes every
2107
2194
  * other signal on the PR stale, and a red build is worth more than a comment.
@@ -2111,7 +2198,7 @@ const needsMe = (facts) => {
2111
2198
  if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2112
2199
  if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2113
2200
  if (facts.reviewDecision === "changes-requested") return "changes requested";
2114
- if (facts.blockingFindings > 0) return `${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`;
2201
+ if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2115
2202
  if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2116
2203
  return null;
2117
2204
  };
@@ -2221,2095 +2308,2084 @@ const resolve$1 = (text, registered) => {
2221
2308
  };
2222
2309
  };
2223
2310
  //#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}.`;
2311
+ //#region src/domain/findings.ts
2312
+ /** Whether a review run found anything at all. */
2313
+ const Verdict = Schema.Literals(["clean", "findings"]);
2314
+ /**
2315
+ * Every severity word a review may answer with.
2316
+ *
2317
+ * The first three are ours, and the only ones a run is asked for. The rest are
2318
+ * the persona a run with no slash command carries, which grades in its own
2319
+ * words: a turn that comes back in them is worth reading rather than throwing
2320
+ * away.
2321
+ */
2322
+ const Spelling = Schema.Literals([
2323
+ "error",
2324
+ "warning",
2325
+ "info",
2326
+ "Critical",
2327
+ "Required",
2328
+ "Optional",
2329
+ "Nit",
2330
+ "FYI"
2331
+ ]);
2332
+ /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2333
+ const severityOf = {
2334
+ error: "error",
2335
+ warning: "warning",
2336
+ info: "info",
2337
+ Critical: "error",
2338
+ Required: "error",
2339
+ Optional: "warning",
2340
+ Nit: "info",
2341
+ FYI: "info"
2232
2342
  };
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) }));
2343
+ const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2344
+ decode: (word) => severityOf[word],
2345
+ encode: (severity) => severity
2346
+ })));
2347
+ /** The fields both spellings of a finding share. Only the severity differs. */
2348
+ const shared = {
2349
+ file: Schema.String,
2350
+ line: Schema.Int,
2351
+ summary: Schema.String
2237
2352
  };
2353
+ /** One problem a review run reports, at a file and line. */
2354
+ const Finding = Schema.Struct({
2355
+ ...shared,
2356
+ severity: Severity
2357
+ });
2238
2358
  /**
2239
- * A domain guard's word, as the command's own failure.
2240
- *
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.
2359
+ * What a review run found: the shape the tool keeps, and the one a fix session
2360
+ * is later handed.
2244
2361
  */
2245
- const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2362
+ const Findings = Schema.Struct({
2363
+ verdict: Verdict,
2364
+ findings: Schema.Array(Finding)
2365
+ });
2246
2366
  /**
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.
2367
+ * The same findings as a runner may spell them, which is what the second turn's
2368
+ * output is read with.
2254
2369
  *
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.
2370
+ * A word nothing maps fails here, and a failed read is a failure of the run:
2371
+ * findings the tool cannot weigh are not findings it can act on.
2257
2372
  */
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;
2373
+ const Reported = Schema.Struct({
2374
+ verdict: Verdict,
2375
+ findings: Schema.Array(Schema.Struct({
2376
+ ...shared,
2377
+ severity: Weighed
2378
+ }))
2263
2379
  });
2264
2380
  /**
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.
2381
+ * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2271
2382
  *
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.
2383
+ * It is derived from the schema the findings are kept under rather than written
2384
+ * out beside it, so a runner is asked for exactly the shape that is persisted.
2385
+ * `Reported` is wider on purpose and only on the severity: what a runner is
2386
+ * asked for is our three words, and a persona's five are read where they arrive
2387
+ * anyway rather than being asked for.
2276
2388
  */
2277
- const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2278
- //#endregion
2279
- //#region src/cli/row.ts
2389
+ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2280
2390
  /**
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.
2391
+ * The findings as the Markdown a report is written in.
2291
2392
  *
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.
2393
+ * It is what a schema-held run's report says: with a schema in force a run
2394
+ * answers in findings and not in prose, so the report kept beside it is written
2395
+ * from the findings themselves rather than left empty.
2296
2396
  */
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"
2397
+ 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");
2398
+ /** Where each severity sits against the others, so the bar can be compared with it. */
2399
+ const rank = {
2400
+ info: 0,
2401
+ warning: 1,
2402
+ error: 2
2303
2403
  };
2304
2404
  /**
2305
- * The mark that says which bucket a row is in without being read.
2405
+ * The findings that withhold the stamp: everything at `blocksOn` or above it.
2306
2406
  *
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.
2407
+ * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2408
+ * warnings I do not want to merge past is configured rather than coded. An
2409
+ * error blocks wherever the bar is, because nothing weighs more than one.
2312
2410
  */
2313
- const marker = {
2314
- "needs-me": "●",
2315
- "needs-review-run": "◐",
2316
- "waiting-on-others": "○",
2317
- ready: "◆"
2318
- };
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 = " │ ";
2411
+ const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2412
+ //#endregion
2413
+ //#region src/domain/review.ts
2328
2414
  /**
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.
2415
+ * What a review run came to, which is what its second turn reported.
2334
2416
  *
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.
2417
+ * A failure is recorded as one and is never a clean verdict: a turn that exited
2418
+ * badly, ran out of patience or answered in a shape that does not validate has
2419
+ * found nothing, which is not the same as having found nothing wrong.
2420
+ */
2421
+ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2422
+ verdict: Verdict,
2423
+ findings: Schema.Array(Finding)
2424
+ }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2425
+ /**
2426
+ * One review run against a tracked PR at a specific head commit.
2339
2427
  *
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.
2428
+ * It is a schema because a review run outlives the command that started it: the
2429
+ * state directory is where the next sweep learns that this head has been
2430
+ * reviewed, and where a fix session finds what there is to fix.
2347
2431
  */
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
- };
2366
- //#endregion
2367
- //#region src/adapters/ci.ts
2432
+ const ReviewRun = Schema.Struct({
2433
+ repo: Schema.String,
2434
+ number: Schema.Int,
2435
+ /** The head the run covers. A run never vouches for code it did not see. */
2436
+ head: Schema.String,
2437
+ /**
2438
+ * The slash command line the run opened on, or null where it opened on the
2439
+ * tool's own prompt. A report found months later says what it was asked, and a
2440
+ * record an earlier version wrote carries no such field and is forgotten.
2441
+ */
2442
+ command: Schema.NullOr(Schema.String),
2443
+ effort: Schema.NullOr(Effort),
2444
+ /**
2445
+ * The agent session the run happened in, or null where it never reached one.
2446
+ *
2447
+ * A run that would not start or exited before it said anything has no session,
2448
+ * and the run is still recorded: a failure is recorded as what it is.
2449
+ */
2450
+ sessionId: Schema.NullOr(Schema.String),
2451
+ ranAt: Schema.DateTimeUtcFromString,
2452
+ outcome: Outcome
2453
+ });
2454
+ /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2455
+ const short = (head) => head.slice(0, 7);
2368
2456
  /**
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.
2457
+ * Where a run is kept: one key per head, so a run and the code it read cannot
2458
+ * drift apart, and a re-review replaces the run before it.
2372
2459
  */
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 ?? "");
2460
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2461
+ /** Where the run's report is kept: beside the run, as the Markdown it is. */
2462
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2392
2463
  /**
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.
2464
+ * Which head a pull request was last reviewed at: an index beside `runKey` and
2465
+ * `reportKey` rather than a thing the glossary names.
2395
2466
  *
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.
2467
+ * A run is kept under the head it read, which answers the question a sweep asks
2468
+ * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2469
+ * head the last run was at - and this is where they read it, so neither has to
2470
+ * ask GitHub what is current before it can look anything up.
2398
2471
  */
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
- };
2472
+ const LastReviewed = Schema.Struct({ head: Schema.String });
2473
+ /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2474
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2406
2475
  /**
2407
- * The checks that failed and count, which are the ones there is a log to read.
2476
+ * The run at one head, or none where nothing has reviewed it.
2408
2477
  *
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.
2411
- */
2412
- const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
2413
- /**
2414
- * What a check reports on, out of the URL it reports at.
2478
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
2479
+ * findings` all ask about one commit - and one read off the disk answers it
2480
+ * without an index to keep in step.
2415
2481
  *
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.
2482
+ * Forgetting a run costs one review.
2434
2483
  */
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";
2484
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2485
+ const runs = yield* storeFor("runs", ReviewRun);
2486
+ return yield* remembered(runs.get(runKey(repo, number, head)));
2487
+ });
2488
+ /** The last review run on a pull request, or none where it has had none. */
2489
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2490
+ const heads = yield* storeFor("runs", LastReviewed);
2491
+ const at = yield* remembered(heads.get(latestKey(repo, number)));
2492
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2443
2493
  });
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
- ]);
2455
2494
  /**
2456
- * Whether `workflow` is red on `branch` right now.
2495
+ * What a run reported, or null where it reported nothing at all.
2457
2496
  *
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.
2497
+ * A failure is not a clean verdict: a run that could not report has found
2498
+ * nothing, which is not the same as having found nothing wrong. Everything that
2499
+ * reads a run's findings reads them through here, so the distinction is drawn
2500
+ * once rather than at every caller that might forget it.
2462
2501
  */
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);
2479
- });
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
- });
2502
+ const reportedBy = (run) => run.outcome._tag === "reported" ? {
2503
+ verdict: run.outcome.verdict,
2504
+ findings: run.outcome.findings
2505
+ } : null;
2493
2506
  /**
2494
- * How much of a failing job's log is kept.
2507
+ * Why a run reported nothing, or null where it reported.
2495
2508
  *
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.
2509
+ * The sibling of `reportedBy`, and here for the same reason: the two halves of
2510
+ * an outcome are read through one place each rather than re-narrowed at every
2511
+ * caller.
2499
2512
  */
2500
- const logTailBytes = 65536;
2513
+ const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2501
2514
  /**
2502
- * What one failing job printed, from the end.
2515
+ * Whether the files changed since the last run are worth paying for another.
2503
2516
  *
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.
2517
+ * The question is deliberately about what changed rather than how much: one
2518
+ * line outside the `docs_only` globs is code nobody has reviewed, and a
2519
+ * thousand lines inside them are still prose.
2507
2520
  */
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
- });
2521
+ const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
2522
2522
  /**
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.
2523
+ * The re-run rule: the head this run is skipped against, or null where it runs.
2527
2524
  *
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.
2525
+ * A review costs real money and minutes of my attention, and a typo fix is not
2526
+ * worth either. Four things are never skipped, because the rule is here to save
2527
+ * me a review and not to stand between me and one I asked for: a pull request
2528
+ * with no run behind it, a run that reported nothing, a comparison GitHub would
2529
+ * not answer, and anything that changed outside the globs. A head that has
2530
+ * already had a run changed nothing at all, which is the one case that needs no
2531
+ * comparison to decide.
2532
2532
  */
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
- }))];
2533
+ const skippedSince = (asked, docsOnly) => {
2534
+ if (asked.last === null || reportedBy(asked.last) === null) return null;
2535
+ const changed = asked.last.head === asked.head ? [] : asked.changed;
2536
+ return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2537
+ };
2538
+ /** What a run was opened on, as the report says it. */
2539
+ const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2537
2540
  /**
2538
- * Asks GitHub to run one workflow run's failed jobs again.
2541
+ * The report as it is written down: what it is of, then what the run said.
2539
2542
  *
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.
2543
+ * The heading is the whole point of writing it rather than storing the prose
2544
+ * alone - a file found months later says which pull request, which commit and
2545
+ * what the run was asked, without anything else having to be open.
2543
2546
  */
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
2562
- /**
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.
2565
- *
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.
2568
- */
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);
2587
- /**
2588
- * The changed file the log names, preferring one it spells in full.
2589
- *
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.
2599
- */
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;
2547
+ const reportDocument = (run, title, prose) => [
2548
+ `# ${run.repo}#${run.number} ${title}`,
2549
+ "",
2550
+ `- head: ${run.head}`,
2551
+ `- run: ${askedOf$1(run)}`,
2552
+ `- ran: ${DateTime.formatIso(run.ranAt)}`,
2553
+ "",
2554
+ prose.trim(),
2555
+ ""
2556
+ ].join("\n");
2602
2557
  /**
2603
- * The flaky pattern the log matches, mine before the built-in ones.
2558
+ * Whether `head` has the review it needs.
2604
2559
  *
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.
2560
+ * A run that reported nothing does not count, which is the same rule
2561
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
2562
+ * nothing wrong.
2608
2563
  */
2609
- const matchedPattern = (log, patterns) => {
2610
- const haystack = log.toLowerCase();
2611
- return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
2564
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2565
+ /** The findings at one head that withhold the stamp. */
2566
+ const blockingIn = (run, blocksOn) => {
2567
+ const found = run === null ? null : reportedBy(run);
2568
+ return found === null ? [] : blocking(found.findings, blocksOn);
2612
2569
  };
2613
2570
  /**
2614
- * Whether a red CI is mine to fix, and why.
2571
+ * What the review runs on `head` say about it, for the stamp to rest on.
2615
2572
  *
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.
2573
+ * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
2574
+ * recorded against one head, and a head with no run of its own has not been
2575
+ * reviewed however many sweeps have seen the pull request. A run that could not
2576
+ * report findings does not count either: its verdict is what takes a pull
2577
+ * request out of Needs review run, and it reached none.
2620
2578
  *
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.
2579
+ * It is one function because the two callers are a sweep and `dw-mc merge`, and
2580
+ * the second exists to land what the first only describes: two spellings of
2581
+ * this would be two answers to whether a head has been reviewed.
2625
2582
  */
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 ")
2583
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
2584
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
2585
+ return {
2586
+ reviewRunHead: reviewedBy(run) ? head : null,
2587
+ blockingFindings: blockingIn(run, blocksOn).length
2641
2588
  };
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
2589
  });
2655
- /** No evidence at all, which is what an unreadable CI comes to. */
2656
- const nothing$1 = {
2657
- alsoRedOnDefaultBranch: [],
2658
- changedFiles: [],
2659
- log: ""
2590
+ //#endregion
2591
+ //#region src/cli/pr.ts
2592
+ /** The pull request a command acts on, named the way I actually type it. */
2593
+ const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
2594
+ /** What to say about a reference that named no one pull request. */
2595
+ const whyNothingNamed = (reference) => {
2596
+ if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
2597
+ const example = `${reference.repos[0] ?? "owner/name"}#28`;
2598
+ 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}.`;
2599
+ };
2600
+ /** The pull request the argument names, or the sentence saying why it names none. */
2601
+ const named = (pr, registered) => {
2602
+ const reference = resolve$1(pr, registered);
2603
+ return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
2660
2604
  };
2661
2605
  /**
2662
- * What a red CI looks like to the classifier.
2606
+ * What a command that acts on one pull request opens with: which pull request
2607
+ * it is, and what the configuration says about its repository.
2663
2608
  *
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.
2609
+ * Nine commands ask the file the same three questions before they do anything
2610
+ * else, and asking them here is what keeps the answers the same: which
2611
+ * repositories are registered decides what a bare `28` may name, and a command
2612
+ * that read the file its own way would resolve a different pull request from
2613
+ * the one beside it.
2614
+ *
2615
+ * `settings` and `launcher` come back whether or not this command wants them,
2616
+ * because both are a merge of records already in hand and neither reads
2617
+ * anything. The file itself does not, so nothing downstream keeps a copy of it.
2669
2618
  */
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 });
2619
+ const forPr = Effect.fn("pr.forPr")(function* (pr) {
2620
+ const file = Option.getOrElse(yield* read, () => ({}));
2621
+ const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
2681
2622
  return {
2682
- alsoRedOnDefaultBranch: alsoRed.flat(),
2683
- changedFiles,
2684
- log: logs.join("\n")
2623
+ repo,
2624
+ number,
2625
+ settings: settingsFor(file, repo),
2626
+ launcher: launcherOf(file)
2685
2627
  };
2686
2628
  });
2687
2629
  /**
2688
- * Why a red CI is excused, or null where it is mine to fix.
2630
+ * A domain guard's word, as the command's own failure.
2689
2631
  *
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.
2632
+ * Every guard in the tool answers the same shape - the sentence saying why not,
2633
+ * or null - so turning that answer into a refusal is spelled once here rather
2634
+ * than beside each command that asks one.
2694
2635
  */
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
- });
2636
+ const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
2707
2637
  /**
2708
- * Whether a PR is where the last sweep left it.
2638
+ * What the last sweep learned about one pull request, or the sentence sending
2639
+ * me to a sweep.
2709
2640
  *
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.
2641
+ * A command that reads these rather than GitHub says what the table said: the
2642
+ * stamp and the cutoff a conversation is measured against are both computed
2643
+ * from the facts a sweep wrote down, and asking GitHub again would make them a
2644
+ * different answer from the one `dw-mc status` printed.
2645
+ *
2646
+ * Facts that are missing and facts this version cannot read come to the same
2647
+ * sentence, because a sweep can write them again either way.
2712
2648
  */
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
2649
+ const swept = Effect.fn("pr.swept")(function* (repo, number) {
2650
+ const store = yield* storeFor("prs", Facts);
2651
+ const facts = yield* remembered(store.get(prKey(repo, number)));
2652
+ if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
2653
+ return facts.value;
2654
+ });
2716
2655
  /**
2717
- * How many pull requests this one stands on.
2656
+ * The review run whose findings are the current ones, or the sentence saying
2657
+ * there are none.
2718
2658
  *
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.
2659
+ * The last run on the pull request is what "current" means here, and it is read
2660
+ * off the state directory rather than worked out from GitHub: the commands that
2661
+ * ask are ones I run inside a fix session, where another round trip to GitHub
2662
+ * buys nothing the run it is about to fix does not already say.
2723
2663
  */
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
- };
2664
+ const currentRun = Effect.fn("pr.currentRun")(function* (repo, number) {
2665
+ const run = yield* lastRun(repo, number);
2666
+ if (Option.isNone(run)) return yield* new CliError.UserError({ cause: `No review run on ${repo}#${number}. Run dw-mc review ${number} first.` });
2667
+ return run.value;
2668
+ });
2735
2669
  /**
2736
- * How deep the stack goes above this pull request.
2670
+ * The guard reads of one command, under a heartbeat.
2737
2671
  *
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.
2672
+ * Every command that acts on a pull request reads its guards live rather than
2673
+ * off the last sweep, because each of them is about the pull request as it is
2674
+ * now. That read is a second or two against GitHub before a word can be
2675
+ * printed, and it used to be spent on a blank screen.
2676
+ *
2677
+ * There is nothing to count here - two or three calls, and a number counting to
2678
+ * three says less than the words do - so the line is what is being read and how
2679
+ * long it has taken. It gives the heartbeat no aside, so a piped command prints
2680
+ * what it always printed.
2741
2681
  */
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
- };
2682
+ const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
2683
+ //#endregion
2684
+ //#region src/cli/row.ts
2750
2685
  /**
2751
- * Where a pull request sits in its stack, or null where it is in none.
2686
+ * How one tracked PR is written down, wherever it is written down.
2752
2687
  *
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.
2688
+ * The table `dw-mc status` prints and the list the picker asks me to choose
2689
+ * from are the same rows, so a pull request reads the same in both and neither
2690
+ * command owns how the other draws it.
2691
+ *
2692
+ * Colour here says one thing: which bucket the pull request is in, and so what
2693
+ * it waits on. Everything else on the row is either `dim`, because it is
2694
+ * context rather than state, or left alone. A row read with no colour at all
2695
+ * says the same, which is what the marker is for.
2696
+ *
2697
+ * On a table, the pull request opens itself: the reference carries the URL for
2698
+ * the terminal to follow, and nothing else on the row does. What it leads to is
2699
+ * where the row already says it is, so a row read where no link can be followed
2700
+ * - a pipe, a paste, a terminal that ignores the sequence - loses nothing.
2757
2701
  */
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
- };
2702
+ /** The glossary's name for each bucket, which is what the heading says. */
2703
+ const heading = {
2704
+ "needs-me": "Needs me",
2705
+ "needs-review-run": "Needs review run",
2706
+ "waiting-on-others": "Waiting on others",
2707
+ ready: "Ready"
2768
2708
  };
2769
2709
  /**
2770
- * Why this branch is nobody's to touch here, or null where it is mine.
2710
+ * The mark that says which bucket a row is in without being read.
2771
2711
  *
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.
2712
+ * One character apiece, from the part of Unicode a terminal font has: the
2713
+ * padding is counted in characters, and a glyph a terminal draws double width
2714
+ * takes a column the count never gave it. How full the mark looks tracks how
2715
+ * much of the pull request is done, so the column reads at a glance even where
2716
+ * the colour is off.
2781
2717
  */
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;
2718
+ const marker = {
2719
+ "needs-me": "●",
2720
+ "needs-review-run": "◐",
2721
+ "waiting-on-others": "○",
2722
+ ready: "◆"
2789
2723
  };
2724
+ /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2725
+ const tint = (paint, bucket) => ({
2726
+ "needs-me": paint.red,
2727
+ "needs-review-run": paint.yellow,
2728
+ "waiting-on-others": paint.dim,
2729
+ ready: paint.green
2730
+ })[bucket];
2731
+ /** What sits between two columns: three columns of prose run into one another without a rule. */
2732
+ const rule = " │ ";
2790
2733
  /**
2791
- * Why this branch is not one to rebase, or null where it is.
2734
+ * One row: which pull request, what it is, and what it waits on.
2792
2735
  *
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.
2736
+ * A stamp is a mark beside the pull request rather than a column of its own, so
2737
+ * a table where nothing is stamped is exactly the table it was before: the
2738
+ * stamp is a thing I look for, not a thing I read every row of.
2796
2739
  *
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.
2740
+ * The title is the only cell with give in it, so how much room it gets is the
2741
+ * caller's to say: a table printed down the screen can afford a whole commit
2742
+ * subject, and a row inside a prompt has a column more to carry and a frame
2743
+ * around it.
2744
+ *
2745
+ * A named lead carries the colour for the whole row. It is the one place a
2746
+ * prompt's row is coloured, and it carries no link at all: a prompt counts the
2747
+ * lines it has to erase from the length of what it drew, escape sequences and
2748
+ * all, so every colour on a row costs the title characters it could have shown,
2749
+ * and a link costs it the whole URL. The table has no such arithmetic to keep
2750
+ * straight, so its rows say it in more than one place and open the pull request
2751
+ * besides.
2802
2752
  */
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;
2753
+ const cells = (placed, stamped, room, paint, lead) => {
2754
+ const { facts } = placed;
2755
+ const { bucket } = placed.placement;
2756
+ const say = tint(paint, bucket);
2757
+ const reference = `${facts.repo}#${facts.number}`;
2758
+ const named = lead === "named";
2759
+ const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2760
+ return named ? [
2761
+ say(`${marker[bucket]} ${heading[bucket]}`),
2762
+ pr,
2763
+ truncate(facts.title, room),
2764
+ placed.placement.reason
2765
+ ] : [
2766
+ `${say(marker[bucket])} ${pr}`,
2767
+ paint.dim(truncate(facts.title, room)),
2768
+ say(placed.placement.reason)
2769
+ ];
2811
2770
  };
2771
+ //#endregion
2772
+ //#region src/domain/comments.ts
2812
2773
  /**
2813
- * A rebase that conflicted: the head it conflicted at and the files it stopped
2814
- * on.
2774
+ * One thread's share of a strand, cut to what is worth reading.
2815
2775
  *
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.
2776
+ * A review thread is answered as a whole, so a single comment newer than my
2777
+ * last activity brings the whole thread with it: the follow-up on its own is a
2778
+ * line answering something the screen does not show, which is what sends me to
2779
+ * the browser.
2820
2780
  *
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.
2781
+ * The pull request's own comments are not a thread but a stream, and there is
2782
+ * no reply to lose the question of, so they are cut comment by comment.
2826
2783
  */
2827
- const Conflict = Schema.Struct({
2828
- head: Schema.String,
2829
- paths: Schema.optionalKey(Schema.Array(Schema.String))
2830
- });
2784
+ const only = (thread, keep, since, all) => {
2785
+ const strand = thread.comments.filter((it) => keep(it.bot));
2786
+ const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
2787
+ return comments.length === 0 ? [] : [{
2788
+ ...thread,
2789
+ comments
2790
+ }];
2791
+ };
2831
2792
  /**
2832
- * The conflict a rebase last left on this pull request, or null where it left
2833
- * none.
2793
+ * The threads worth putting on screen, given what I have already done.
2794
+ *
2795
+ * `since` is my last activity on the pull request - the later of my last
2796
+ * comment and my last commit - which is the same moment the bucket rule
2797
+ * measures a comment against. Showing exactly what is newer than it means the
2798
+ * command answers the question the bucket asked.
2834
2799
  *
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.
2800
+ * A thread somebody resolved and one against code that is gone are left out:
2801
+ * neither is something to answer, and both are still there to read under
2802
+ * `--all`, which asks for the whole conversation and so measures nothing
2803
+ * against anything.
2839
2804
  */
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
- });
2805
+ const shown = (threads, options) => {
2806
+ const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
2807
+ return {
2808
+ people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
2809
+ bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2810
+ };
2811
+ };
2852
2812
  //#endregion
2853
- //#region src/domain/findings.ts
2854
- /** Whether a review run found anything at all. */
2855
- const Verdict = Schema.Literals(["clean", "findings"]);
2813
+ //#region src/cli/comments.ts
2814
+ const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2815
+ /** Where a thread hangs: a line of the diff, or the pull request itself. */
2816
+ const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2856
2817
  /**
2857
- * Every severity word a review may answer with.
2818
+ * What is true of a thread beyond where it hangs.
2858
2819
  *
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.
2820
+ * It is only ever printed under `--all`, which is the only way a settled thread
2821
+ * reaches the screen at all, and it is there so that reading one is never
2822
+ * reading it as something still open.
2863
2823
  */
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
2894
- };
2895
- /** One problem a review run reports, at a file and line. */
2896
- const Finding = Schema.Struct({
2897
- ...shared,
2898
- severity: Severity
2899
- });
2824
+ const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
2900
2825
  /**
2901
- * What a review run found: the shape the tool keeps, and the one a fix session
2902
- * is later handed.
2826
+ * One thread as a block: where it hangs, then everybody who said something in
2827
+ * it, then what they said in full.
2828
+ *
2829
+ * In full because a review comment is usually a paragraph carrying a
2830
+ * suggestion, and a first line is what sends me to the browser this command
2831
+ * exists to replace. No diff hunk with it: the code is on this machine, under
2832
+ * the path the heading already prints.
2903
2833
  */
2904
- const Findings = Schema.Struct({
2905
- verdict: Verdict,
2906
- findings: Schema.Array(Finding)
2907
- });
2834
+ 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}`)])];
2835
+ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
2908
2836
  /**
2909
- * The same findings as a runner may spell them, which is what the second turn's
2910
- * output is read with.
2837
+ * The conversation on screen: people first, then a rule, then the bots.
2911
2838
  *
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.
2914
- */
2915
- const Reported = Schema.Struct({
2916
- verdict: Verdict,
2917
- findings: Schema.Array(Schema.Struct({
2918
- ...shared,
2919
- severity: Weighed
2920
- }))
2921
- });
2922
- /**
2923
- * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2924
- *
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.
2930
- */
2931
- const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2932
- /**
2933
- * The findings as the Markdown a report is written in.
2839
+ * The rule is there so the two are never read as one list. A bot's comment is
2840
+ * observed and never answered, and the bucket rules ignore bots for exactly
2841
+ * this reason.
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
+ * A bot is cut at the same moment I am measured against, because the window is
2844
+ * what has happened since I last acted rather than what is owed an answer. A
2845
+ * verdict older than my last push is one I have already had the chance to read,
2846
+ * and `--all` is where it still is.
2938
2847
  */
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
2848
+ const lines$2 = (view, paint) => {
2849
+ const people = view.people.map((thread) => block$1(thread, paint));
2850
+ const bots = view.bots.map((thread) => block$1(thread, paint));
2851
+ return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
2852
+ };
2853
+ /** What to say where there is nothing to print, which depends on why there is not. */
2854
+ const nothing$1 = (facts, all) => {
2855
+ const pr = `${facts.repo}#${facts.number}`;
2856
+ if (all) return [`Nothing has been said on ${pr}.`];
2857
+ const placement = place(facts);
2858
+ const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2859
+ return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
2860
+ "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
2861
+ `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
2862
+ rest
2863
+ ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
2945
2864
  };
2946
2865
  /**
2947
- * The findings that withhold the stamp: everything at `blocksOn` or above it.
2866
+ * The conversation on one tracked pull request, and nothing else.
2948
2867
  *
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.
2868
+ * What it shows by default is what the bucket rule measures: the comments newer
2869
+ * than the later of my last comment and my last commit, which are the ones that
2870
+ * put the pull request in Needs me. Reading it answers the question the table
2871
+ * asked.
2958
2872
  *
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.
2873
+ * The cutoff is read off the last sweep rather than worked out again here, so
2874
+ * the command shows exactly what `dw-mc status` counted rather than a second
2875
+ * opinion about it.
2969
2876
  *
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.
2877
+ * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2878
+ * (ADR 0002). Reading is the whole command.
2973
2879
  */
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);
2880
+ const comments = Command.make("comments", {
2881
+ pr: prArgument,
2882
+ all: allFlag
2883
+ }, Effect.fn("comments")(function* ({ all, pr }) {
2884
+ const { number, repo } = yield* forPr(pr);
2885
+ const facts = yield* swept(repo, number);
2886
+ const paint = yield* Paint;
2887
+ const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2888
+ since: later(facts.myLastCommentAt, facts.myLastCommitAt),
2889
+ all
2890
+ });
2891
+ if (view.people.length === 0 && view.bots.length === 0) {
2892
+ yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2893
+ return;
2894
+ }
2895
+ yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
2896
+ yield* Console.log("");
2897
+ yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2898
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
2899
+ //#endregion
2900
+ //#region src/cli/findings.ts
2901
+ /** The findings as the JSON the schema defines, rather than as this file spells it. */
2902
+ const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
2903
+ const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
2904
+ /** What a run's findings come to in one line, against the bar that blocks. */
2905
+ const summary = (found, blocksOn) => {
2906
+ if (found.findings.length === 0) return "clean, nothing to fix";
2907
+ const blocked = blocking(found.findings, blocksOn).length;
2908
+ return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
2909
+ };
2910
+ /** Which run these findings are, and what they come to: the line above the list. */
2911
+ const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
2998
2912
  /**
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.
2913
+ * The findings one to a line, in the order the run reported them, ruled so the
2914
+ * three columns read apart.
3001
2915
  */
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`;
2916
+ const lines$1 = (found) => table(found.findings.map((finding) => [
2917
+ `${finding.file}:${finding.line}`,
2918
+ finding.severity,
2919
+ finding.summary
2920
+ ]), rule);
3005
2921
  /**
3006
- * Which head a pull request was last reviewed at: an index beside `runKey` and
3007
- * `reportKey` rather than a thing the glossary names.
2922
+ * What the run reported, or the sentence saying it reported nothing at all.
3008
2923
  *
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.
2924
+ * A run that failed is not a clean one: a pipe must never be handed "no
2925
+ * findings" when what happened is that nothing could be read.
3013
2926
  */
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`;
2927
+ const whatItFound = (run) => {
2928
+ const found = reportedBy(run);
2929
+ 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);
2930
+ };
3017
2931
  /**
3018
- * The run at one head, or none where nothing has reviewed it.
2932
+ * What the current review run found, as a table or as the JSON it is kept in.
3019
2933
  *
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.
2934
+ * `--json` is the whole point of the command: it prints the findings and
2935
+ * nothing else, so I can pipe them anywhere, and a fix session inside an open
2936
+ * agent reads exactly what the tool recorded rather than a retelling of it.
3023
2937
  *
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.
2938
+ * A run that failed prints no findings and fails: a review run that could not
2939
+ * report has found nothing, which is not the same as having found nothing
2940
+ * wrong, and a pipe must never be handed the second when the first is true.
3027
2941
  */
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
- });
2942
+ const findings = Command.make("findings", {
2943
+ pr: prArgument,
2944
+ json: jsonFlag
2945
+ }, Effect.fn("findings")(function* ({ json, pr }) {
2946
+ const { number, repo, settings } = yield* forPr(pr);
2947
+ const run = yield* currentRun(repo, number);
2948
+ const found = yield* whatItFound(run);
2949
+ if (json) {
2950
+ yield* Console.log(yield* asJson$2(found));
2951
+ return;
2952
+ }
2953
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
2954
+ for (const line of lines$1(found)) yield* Console.log(` ${line}`);
2955
+ }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
2956
+ //#endregion
2957
+ //#region src/adapters/agent.ts
3038
2958
  /**
3039
- * What a run reported, or null where it reported nothing at all.
2959
+ * How one turn of Claude Code is spawned and given up on, and what a turn that
2960
+ * answers against a schema comes back with.
3040
2961
  *
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.
2962
+ * Every turn is reached this way, so the spawn, the patience and the one failure
2963
+ * they can end in live here rather than once per turn.
3045
2964
  */
3046
- const reportedBy = (run) => run.outcome._tag === "reported" ? {
3047
- verdict: run.outcome.verdict,
3048
- findings: run.outcome.findings
3049
- } : null;
2965
+ /** A review run that would not start, would not finish, or finished badly. */
2966
+ var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
2967
+ /** The program that was spawned, which is what a search for it has to name. */
2968
+ program: Schema.String,
2969
+ detail: Schema.String
2970
+ }) {
2971
+ get message() {
2972
+ return `The ${this.program} review run failed: ${this.detail}`;
2973
+ }
2974
+ };
3050
2975
  /**
3051
- * Why a run reported nothing, or null where it reported.
2976
+ * Failures in the name of the program that was spawned.
3052
2977
  *
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.
2978
+ * Where the launcher starts `claude` through another program, it is that
2979
+ * program that would not start or exited badly, and saying `claude` sends the
2980
+ * search to the wrong process.
3056
2981
  */
3057
- const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2982
+ const failedBy = (program) => (detail) => new AgentFailed({
2983
+ program,
2984
+ detail
2985
+ });
3058
2986
  /**
3059
- * Whether the files changed since the last run are worth paying for another.
2987
+ * How long each turn gets before it is given up on.
3060
2988
  *
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.
2989
+ * The review is the turn that thinks, and a high-effort one that fans out to
2990
+ * subagents takes real minutes, so its limit is there to catch a run that has
2991
+ * stopped rather than one that is slow. The second turn reads no code and
2992
+ * decides nothing - the review it reports on is already in the session it
2993
+ * resumes - and every run of it by hand came back in seconds.
2994
+ *
2995
+ * Either way, a command that hangs forever is worse than one that says it
2996
+ * failed: a review I walked away from is one I need to be able to come back to.
3064
2997
  */
3065
- const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
2998
+ const patience = {
2999
+ reviewing: Duration.minutes(45),
3000
+ reporting: Duration.minutes(5)
3001
+ };
3066
3002
  /**
3067
- * The re-run rule: the head this run is skipped against, or null where it runs.
3003
+ * One turn of the launcher in `directory`, with `read` over its standard output.
3068
3004
  *
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.
3005
+ * The launcher's own arguments go in front of the turn's, because they are what
3006
+ * gets `claude` started at all. The two output streams are drained together,
3007
+ * because draining one to the end first can block a run that is still writing to
3008
+ * the other. Every way a turn can fail to finish comes back from here as a
3009
+ * `AgentFailed`, so a caller is left with the turn's own answer and nothing else
3010
+ * to translate - a turn that never comes back included.
3076
3011
  */
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;
3012
+ const turn = Effect.fnUntraced(function* (options) {
3013
+ const [program, ...prefix] = options.command;
3014
+ const failed = failedBy(program);
3015
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3016
+ const running = Effect.gen(function* () {
3017
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [...prefix, ...options.args], {
3018
+ cwd: options.directory,
3019
+ stdin: "pipe"
3020
+ })), (error) => failed(error.message));
3021
+ const [got, stderr] = yield* Effect.mapError(Effect.all([options.read(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }), (error) => failed(error.message));
3022
+ const exitCode = yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3023
+ if (exitCode !== 0) return yield* failed(stderr.trim() === "" ? `${program} exited ${exitCode}` : stderr.trim());
3024
+ return got;
3025
+ });
3026
+ return yield* Effect.timeoutOrElse(running, {
3027
+ duration: options.patience.duration,
3028
+ orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
3029
+ });
3030
+ });
3031
+ //#endregion
3032
+ //#region src/adapters/claude.ts
3033
+ /**
3034
+ * Claude Code: a review on a slash command, a review on the tool's own prompt,
3035
+ * and the sessions I steer.
3036
+ */
3037
+ /**
3038
+ * The two events of a stream-json run this reads, as the runner really writes
3039
+ * them. Every other field of both, and every other event, is ignored: a
3040
+ * transcript carries hooks, rate limits, thinking and tool results, and a
3041
+ * version that adds another must not stop a run from being read.
3042
+ */
3043
+ const Working = Schema.Struct({
3044
+ type: Schema.Literal("assistant"),
3045
+ message: Schema.Struct({ content: Schema.Array(Schema.Struct({
3046
+ type: Schema.String,
3047
+ name: Schema.optionalKey(Schema.String),
3048
+ text: Schema.optionalKey(Schema.String)
3049
+ })) })
3050
+ });
3051
+ const Ended = Schema.Struct({
3052
+ type: Schema.Literal("result"),
3053
+ subtype: Schema.String,
3054
+ is_error: Schema.Boolean,
3055
+ session_id: Schema.String,
3056
+ result: Schema.optionalKey(Schema.String),
3057
+ /** What a turn given a JSON schema validated, which this hands on unread. */
3058
+ structured_output: Schema.optionalKey(Schema.Unknown)
3059
+ });
3060
+ const asWorking = Schema.decodeUnknownOption(Schema.fromJsonString(Working));
3061
+ const asResult = Schema.decodeUnknownOption(Schema.fromJsonString(Ended));
3062
+ const heardIn = (line) => {
3063
+ const blocks = Option.match(asWorking(line), {
3064
+ onNone: () => [],
3065
+ onSome: (event) => event.message.content
3066
+ });
3067
+ return {
3068
+ tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
3069
+ said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
3070
+ };
3081
3071
  };
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
3072
  /**
3085
- * The report as it is written down: what it is of, then what the run said.
3073
+ * The result a turn ended on, or the failure it really was.
3086
3074
  *
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.
3075
+ * A turn that said nothing this can read and a turn Claude Code itself calls an
3076
+ * error are both failures: `subtype` is where a run that hit its turn limit or
3077
+ * lost its connection says so, and its `result` is the only word on why.
3090
3078
  */
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");
3079
+ const ended = (program, result) => {
3080
+ const failed = failedBy(program);
3081
+ if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
3082
+ const { is_error, result: lastWord, subtype } = result.value;
3083
+ return is_error || subtype !== "success" ? Effect.fail(failed(`${subtype}: ${lastWord ?? "nothing else was said"}`)) : Effect.succeed(result.value);
3084
+ };
3101
3085
  /**
3102
- * Whether `head` has the review it needs.
3086
+ * A Claude Code `stream-json` turn, read as it arrives: what it reached for goes
3087
+ * to `onTool` while the run is still going, and what it said and how it ended
3088
+ * are what comes back.
3103
3089
  *
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.
3090
+ * Both shapes of review read a turn the same way, so the fold is here rather
3091
+ * than once per shape.
3107
3092
  */
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
- };
3093
+ const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
3094
+ const heard = heardIn(line);
3095
+ return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
3096
+ line,
3097
+ heard
3098
+ });
3099
+ }), Stream.runFold(() => ({
3100
+ said: [],
3101
+ result: Option.none()
3102
+ }), (soFar, { heard, line }) => ({
3103
+ said: [...soFar.said, ...heard.said],
3104
+ result: Option.orElse(asResult(line), () => soFar.result)
3105
+ })));
3114
3106
  /**
3115
- * What the review runs on `head` say about it, for the stamp to rest on.
3107
+ * One review run on a slash command, headless, in `directory`.
3116
3108
  *
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.
3109
+ * The run is in the foreground and says what it is doing as it does it, which
3110
+ * is what `onTool` is for: a review takes minutes, and a terminal that prints
3111
+ * nothing for minutes is one I stop trusting.
3122
3112
  *
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.
3113
+ * `--json-schema` is never passed here: verified by running it, the flag beside
3114
+ * `/code-review` breaks the run, which is why a slash command costs a second
3115
+ * turn that resumes the session and asks for the findings. My own instructions
3116
+ * ride on `--append-system-prompt` rather than on the command's own line,
3117
+ * because what a slash command does with its arguments is its business and not
3118
+ * this tool's.
3119
+ *
3120
+ * `--comment` is the flag that makes the built-in review post on the pull
3121
+ * request, and it is never passed either (ADR 0002). The report is everything
3122
+ * the run said on its own turns rather than the `result` alone: verified by
3123
+ * running it, a repository whose review command fans out to subagents can end on
3124
+ * a remark about them, and the report is the turn before that.
3126
3125
  */
3127
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
3128
- const run = Option.getOrNull(yield* runAt(repo, number, head));
3126
+ const commandReview = Effect.fn("claude.commandReview")(function* (options) {
3127
+ const [program] = options.launcher.command;
3128
+ const run = yield* turn({
3129
+ command: options.launcher.command,
3130
+ directory: options.directory,
3131
+ args: [
3132
+ "-p",
3133
+ options.line,
3134
+ "--output-format",
3135
+ "stream-json",
3136
+ "--verbose",
3137
+ ...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
3138
+ ...options.model === null ? [] : ["--model", options.model]
3139
+ ],
3140
+ patience: {
3141
+ turn: "the review",
3142
+ duration: patience.reviewing
3143
+ },
3144
+ read: transcript(options.onTool)
3145
+ });
3146
+ const { result: lastWord, session_id } = yield* ended(program, run.result);
3147
+ const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
3148
+ if (report === "") return yield* failedBy(program)("the run came back with an empty report");
3129
3149
  return {
3130
- reviewRunHead: reviewedBy(run) ? head : null,
3131
- blockingFindings: blockingIn(run, blocksOn).length
3150
+ report,
3151
+ sessionId: session_id
3132
3152
  };
3133
- });
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);
3153
+ }, Effect.scoped);
3138
3154
  /**
3139
- * The facts about one tracked PR, read from GitHub and kept on disk.
3155
+ * What the second turn asks for.
3140
3156
  *
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.
3157
+ * It asks for a report of what was already said rather than for another look:
3158
+ * the prose is the review, and this turn is only what makes it machine
3159
+ * readable. The shape it must answer in arrives as a JSON schema beside it, so
3160
+ * the prompt does not describe the schema twice.
3145
3161
  */
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
3179
- };
3180
- yield* store.set(key, facts);
3181
- return facts;
3182
- });
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)
3197
- });
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(" · ");
3162
+ const reportFindings = [
3163
+ "Report the findings of the review you just gave as structured output.",
3164
+ "Every finding carries the file it is in as a repository path, the line it is at,",
3165
+ "its severity and a one-sentence summary.",
3166
+ "The verdict is clean when there is nothing to report and findings otherwise.",
3167
+ "Report nothing you did not already say."
3168
+ ].join(" ");
3208
3169
  /**
3209
- * One pass over every tracked PR, and nothing else: a sweep only ever reads.
3170
+ * The second turn of a review run: the prose the first one wrote, back as
3171
+ * findings that validate.
3210
3172
  *
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.
3173
+ * It resumes the first turn's session rather than reading the diff again, which
3174
+ * is what makes it cheap and what makes it accurate - verified by running it,
3175
+ * the line numbers it reports beat the ones the prose gives. The output is
3176
+ * handed on as it arrived: what the findings must look like belongs to the
3177
+ * domain, and the schema the run is held to comes in from there too.
3214
3178
  *
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.
3179
+ * Every way this can end badly ends as an `AgentFailed`, because a review run
3180
+ * that could not report is a failure and never a clean verdict.
3218
3181
  */
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
3182
+ const findingsTurn = Effect.fn("claude.findingsTurn")(function* (options) {
3183
+ const [program] = options.launcher.command;
3184
+ const printed = yield* turn({
3185
+ command: options.launcher.command,
3186
+ directory: options.directory,
3187
+ patience: {
3188
+ turn: "the findings turn",
3189
+ duration: patience.reporting
3190
+ },
3191
+ args: [
3192
+ "-p",
3193
+ "--resume",
3194
+ options.sessionId,
3195
+ reportFindings,
3196
+ "--output-format",
3197
+ "json",
3198
+ "--json-schema",
3199
+ options.jsonSchema
3200
+ ],
3201
+ read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
3234
3202
  });
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
3203
+ const { structured_output } = yield* ended(program, asResult(printed.trim()));
3204
+ if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
3205
+ return structured_output;
3206
+ }, Effect.scoped);
3207
+ /**
3208
+ * One review run of the tool's own review prompt, in `directory`.
3209
+ *
3210
+ * It is one turn rather than two: verified by running it, `--json-schema` beside
3211
+ * an ordinary prompt gives both the prose the run wrote and the
3212
+ * `structured_output` it validated, where the same flag on a slash command
3213
+ * breaks the run. The schema arrives as inline JSON and never as a path - a path
3214
+ * is where Claude Code reports `--json-schema is not valid JSON`.
3215
+ */
3216
+ const promptReview = Effect.fn("claude.promptReview")(function* (options) {
3217
+ const [program] = options.launcher.command;
3218
+ const run = yield* turn({
3219
+ command: options.launcher.command,
3220
+ directory: options.directory,
3221
+ patience: {
3222
+ turn: "the review",
3223
+ duration: patience.reviewing
3224
+ },
3225
+ args: [
3226
+ "-p",
3227
+ options.prompt,
3228
+ "--output-format",
3229
+ "stream-json",
3230
+ "--verbose",
3231
+ "--json-schema",
3232
+ options.jsonSchema,
3233
+ ...options.model === null ? [] : ["--model", options.model]
3234
+ ],
3235
+ read: transcript(options.onTool)
3248
3236
  });
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 }));
3237
+ const { session_id, structured_output } = yield* ended(program, run.result);
3238
+ if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
3239
+ const prose = run.said.join("\n\n").trim();
3257
3240
  return {
3258
- repos,
3259
- facts: swept.got,
3260
- troubles: [...found.troubles, ...swept.troubles]
3241
+ findings: structured_output,
3242
+ sessionId: session_id,
3243
+ prose: prose === "" ? null : prose
3261
3244
  };
3262
- });
3245
+ }, Effect.scoped);
3263
3246
  /**
3264
- * A sweep under its heartbeat, which is how every command that sweeps runs one.
3247
+ * One review run, in whichever shape it was configured in.
3265
3248
  *
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.
3249
+ * A slash command takes two turns and the tool's own prompt takes one, which is
3250
+ * Claude Code's doing and nobody else's: a caller hands over the turn and gets
3251
+ * the same answer back either way.
3252
+ *
3253
+ * The second turn's failure is kept beside the first turn's prose rather than
3254
+ * replacing it. A review that ran and could not report is still worth reading,
3255
+ * and it is recorded as the failure it is.
3269
3256
  */
3270
- const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
3257
+ const reviewTurns = Effect.fn("claude.reviewTurns")(function* (options) {
3258
+ const { directory, jsonSchema, launcher, model, onTool } = options;
3259
+ if (options.turn._tag === "prompt") {
3260
+ const run = yield* promptReview({
3261
+ launcher,
3262
+ directory,
3263
+ prompt: options.turn.text,
3264
+ model,
3265
+ jsonSchema,
3266
+ onTool
3267
+ });
3268
+ return {
3269
+ sessionId: run.sessionId,
3270
+ prose: run.prose,
3271
+ findings: Result.succeed(run.findings)
3272
+ };
3273
+ }
3274
+ const run = yield* commandReview({
3275
+ launcher,
3276
+ directory,
3277
+ line: options.turn.line,
3278
+ instructions: options.turn.instructions,
3279
+ model,
3280
+ onTool
3281
+ });
3282
+ const findings = yield* Effect.result(findingsTurn({
3283
+ launcher,
3284
+ directory,
3285
+ sessionId: run.sessionId,
3286
+ jsonSchema
3287
+ }));
3288
+ return {
3289
+ sessionId: run.sessionId,
3290
+ prose: run.report,
3291
+ findings
3292
+ };
3293
+ });
3271
3294
  /**
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.
3295
+ * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3296
+ * handed straight to it.
3297
+ *
3298
+ * The launcher's `fix_args` go here and nowhere else: they are the flags of
3299
+ * every session I steer - the one on findings and the one on a conflict - which
3300
+ * no headless review turn wants. They sit in front of the
3301
+ * prompt, because `claude` takes its flags before its positional argument.
3302
+ *
3303
+ * This is the one place a run is not read: the three streams are inherited,
3304
+ * so what is on the screen is the session itself and not a transcript of it,
3305
+ * and what I type reaches it. The child is not detached for the same reason -
3306
+ * a detached child sits outside the terminal's foreground process group, where
3307
+ * neither my keystrokes nor Ctrl-C would reach it.
3308
+ *
3309
+ * There is no patience here either. A session I steer lasts as long as I am in
3310
+ * it, and a timeout would be the tool closing a session I was still working in.
3311
+ *
3312
+ * What comes back is the code the session ended on. A session I left with
3313
+ * Ctrl-C ended badly for `claude` and not for me, so this reports it rather
3314
+ * than failing on it; only a `claude` that would not start at all is a failure.
3275
3315
  */
3276
- const userFacing = [
3277
- "ConfigMalformed",
3278
- "GhUnavailable",
3279
- "GhReadFailed",
3280
- "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}`);
3316
+ const steeredSession = Effect.fn("claude.steeredSession")(function* (options) {
3317
+ const [program, ...prefix] = options.launcher.command;
3318
+ const failed = failedBy(program);
3319
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3320
+ const handle = yield* Effect.mapError(spawner.spawn(ChildProcess.make(program, [
3321
+ ...prefix,
3322
+ ...options.launcher.fix_args,
3323
+ options.prompt
3324
+ ], {
3325
+ cwd: options.directory,
3326
+ stdin: "inherit",
3327
+ stdout: "inherit",
3328
+ stderr: "inherit",
3329
+ detached: false
3330
+ })), (error) => failed(error.message));
3331
+ return yield* Effect.mapError(handle.exitCode, (error) => failed(error.message));
3332
+ }, Effect.scoped);
3333
+ //#endregion
3334
+ //#region src/domain/fix.ts
3335
+ /** One finding I chose to act on, carrying what I think about it. */
3336
+ const Chosen = Schema.Struct({
3337
+ ...Finding.fields,
3338
+ note: Schema.optionalKey(Schema.String)
3290
3339
  });
3291
3340
  /**
3292
- * Refreshes what mission control knows about every tracked PR.
3341
+ * What a fix session is handed: the findings I picked, and the review run they
3342
+ * came from.
3293
3343
  *
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.
3344
+ * The head is in it because a fix session opens on the commit that was
3345
+ * reviewed, and a finding's line means nothing away from it.
3296
3346
  */
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
3347
+ const Selection = Schema.Struct({
3348
+ repo: Schema.String,
3349
+ number: Schema.Int,
3350
+ head: Schema.String,
3351
+ findings: Schema.Array(Chosen)
3352
+ });
3353
+ /** The selection as the JSON the schema defines, rather than as this file spells it. */
3354
+ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3304
3355
  /**
3305
- * One thread's share of a strand, cut to what is worth reading.
3356
+ * The prompt a fix session opens on: what these findings are, and the findings
3357
+ * themselves as JSON.
3306
3358
  *
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.
3359
+ * The findings go in verbatim rather than described, because a re-description
3360
+ * is where a file, a line or my own note quietly changes. A note outranks the
3361
+ * finding it is on: the finding is what the review thought, the note is what I
3362
+ * think, and I am the one who picked it.
3311
3363
  *
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.
3364
+ * Pushing is mine either way, and `commits` says whether committing is too.
3365
+ * The tool itself never commits and never pushes; what the session may do
3366
+ * inside the worktree is my call, made once in `fix.commits` or for one session
3367
+ * with the flag.
3314
3368
  */
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
- }];
3322
- };
3369
+ const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3370
+ `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.`,
3371
+ "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.",
3372
+ 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.`,
3373
+ json
3374
+ ].join("\n\n"));
3323
3375
  /**
3324
- * The threads worth putting on screen, given what I have already done.
3325
- *
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.
3376
+ * Why these findings cannot be fixed where the pull request now is, or nothing
3377
+ * where they can.
3330
3378
  *
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.
3379
+ * A pull request that moved since its last review run has findings at lines
3380
+ * that may no longer be there, and a worktree cut at the new head would carry
3381
+ * them into code they were never about. Reviewing again is cheap next to fixing
3382
+ * the wrong thing.
3335
3383
  */
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))
3341
- };
3342
- };
3384
+ 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.`;
3343
3385
  //#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}`;
3386
+ //#region src/cli/fix.ts
3387
+ const printFlag$1 = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
3388
+ const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this session commit what it changes, over what the repository configured"), Flag.optional);
3348
3389
  /**
3349
- * What is true of a thread beyond where it hangs.
3390
+ * The findings to pick from, each on the line `dw-mc findings` gives it.
3350
3391
  *
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.
3392
+ * The rows come from there rather than being built again here, so the list I
3393
+ * pick from and the list I read are the same list. A row that does not fit the
3394
+ * screen is cut: a prompt draws its own frame around the row, and a row that
3395
+ * wraps takes the whole list's alignment with it.
3354
3396
  */
3355
- const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
3397
+ const choicesOf$1 = (found, screen) => {
3398
+ const rows = lines$1(found);
3399
+ const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3400
+ return found.findings.map((finding, index) => ({
3401
+ title: truncate(rows[index] ?? finding.summary, room),
3402
+ value: finding
3403
+ }));
3404
+ };
3356
3405
  /**
3357
- * One thread as a block: where it hangs, then everybody who said something in
3358
- * it, then what they said in full.
3406
+ * Each picked finding with whatever I have to say about it.
3359
3407
  *
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.
3408
+ * The note is asked for one finding at a time, in the order I see them, and
3409
+ * having nothing to say is the ordinary answer rather than a step I have to get
3410
+ * past.
3364
3411
  */
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]);
3412
+ const noted = Effect.fn("fix.noted")(function* (picked) {
3413
+ const chosen = [];
3414
+ for (const finding of picked) {
3415
+ const said = yield* note(`Note on ${finding.file}:${finding.line}, or nothing`);
3416
+ chosen.push(Option.match(said, {
3417
+ onNone: () => finding,
3418
+ onSome: (text) => ({
3419
+ ...finding,
3420
+ note: text
3421
+ })
3422
+ }));
3423
+ }
3424
+ return chosen;
3425
+ });
3367
3426
  /**
3368
- * The conversation on screen: people first, then a rule, then the bots.
3427
+ * A fix session: the findings I picked, in an agent session I steer.
3369
3428
  *
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.
3429
+ * The tool fixes nothing. It picks the findings apart with me, cuts a worktree
3430
+ * on a branch of its own that tracks the pull request's, and hands the session
3431
+ * what I chose as JSON; then it is out of the way. I steer and I push. Nothing
3432
+ * here writes to GitHub, and the tool itself commits nothing: whether the
3433
+ * session may commit inside the worktree is `fix.commits`, or `--commit` for
3434
+ * one session.
3373
3435
  *
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.
3436
+ * The worktree is left standing when the session ends, because the work in it
3437
+ * is mine and an unpushed commit lives nowhere else. Re-reviewing the result is
3438
+ * a new review run against the new head, never a continuation of the run that
3439
+ * produced these findings, so what was reviewed at which commit stays honest.
3378
3440
  */
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];
3441
+ const fix = Command.make("fix", {
3442
+ pr: prArgument,
3443
+ commit: commitFlag,
3444
+ print: printFlag$1
3445
+ }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3446
+ const { number, repo, settings, launcher } = yield* forPr(pr);
3447
+ const run = yield* currentRun(repo, number);
3448
+ const found = yield* whatItFound(run);
3449
+ yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3450
+ if (found.findings.length === 0) return;
3451
+ const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3452
+ yield* refuse(staleAt(number, run.head, view.headRefOid));
3453
+ const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
3454
+ const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
3455
+ if (chosen.length === 0) {
3456
+ yield* Console.log("Nothing picked, so no session was opened.");
3457
+ return;
3458
+ }
3459
+ const commits = Option.getOrElse(commit, () => settings.fix.commits);
3460
+ if (print) {
3461
+ yield* Console.log(yield* promptFor$1({
3462
+ repo,
3463
+ number,
3464
+ head: run.head,
3465
+ findings: chosen
3466
+ }, commits));
3467
+ return;
3468
+ }
3469
+ const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3470
+ yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3471
+ yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
3472
+ const ended = yield* steeredSession({
3473
+ launcher,
3474
+ directory: worktree.directory,
3475
+ prompt: yield* promptFor$1({
3476
+ repo,
3477
+ number,
3478
+ head: worktree.head,
3479
+ findings: chosen
3480
+ }, commits)
3481
+ });
3482
+ yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3483
+ yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3484
+ yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3485
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3486
+ //#endregion
3487
+ //#region src/cli/init.ts
3488
+ const effortFlag$1 = Flag.Literals("effort", [
3489
+ "low",
3490
+ "medium",
3491
+ "high",
3492
+ "xhigh",
3493
+ "max"
3494
+ ]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
3495
+ const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
3496
+ /** The settings the flags asked for, and only those. */
3497
+ const asked = (base, effort) => ({
3498
+ ...Option.isSome(base) ? { base: base.value } : {},
3499
+ ...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
3500
+ });
3501
+ /** What a review will open on, as the setup prints it back. */
3502
+ const opening = (defaults) => {
3503
+ const review = {
3504
+ ...builtIn.review,
3505
+ ...defaults.review
3506
+ };
3507
+ return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
3395
3508
  };
3509
+ const row = (label, value) => `${label.padEnd(12)}${value}`;
3396
3510
  /**
3397
- * The conversation on one tracked pull request, and nothing else.
3511
+ * Both the machine setup and the repository registration: there is deliberately
3512
+ * no separate `setup` command.
3398
3513
  *
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.
3514
+ * The first run on a machine checks `gh` and spells the defaults out in the
3515
+ * configuration file. Run inside a repository, it also registers that
3516
+ * `owner/repo`, taking the name from `gh` so I never type it. Run again, it
3517
+ * changes what the flags name, keeps every other setting the file already had,
3518
+ * and leaves the file untouched where nothing was decided differently.
3403
3519
  *
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.
3520
+ * It asks nothing. Reviews run on Claude Code, and what a run opens on is
3521
+ * `review.command` and `review.prompt` - a line and a paragraph that belong in
3522
+ * the file rather than in a terminal prompt.
3407
3523
  *
3408
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
3409
- * (ADR 0002). Reading is the whole command.
3524
+ * `--effort` and `--base` are about one repository, so they land on the
3525
+ * repository this ran in, or in the defaults when it ran outside one.
3410
3526
  */
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"));
3527
+ const init = Command.make("init", {
3528
+ effort: effortFlag$1,
3529
+ base: baseFlag
3530
+ }, Effect.fn("init")(function* ({ base, effort }) {
3531
+ yield* requireAuth;
3532
+ const config = yield* ConfigStore;
3533
+ const before = yield* read;
3534
+ const file = Option.getOrElse(before, () => ({}));
3535
+ const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
3536
+ const state = yield* stateDirectory;
3537
+ const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
3538
+ const overrides = asked(base, effort);
3539
+ const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3540
+ if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3541
+ yield* Console.log(row("review", opening(written.defaults ?? {})));
3542
+ yield* Console.log(row("config", config.path));
3543
+ yield* Console.log(row("state", state));
3544
+ 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"})`));
3545
+ }, Effect.catchTag([
3546
+ "ConfigMalformed",
3547
+ "GhUnauthenticated",
3548
+ "GhUnavailable",
3549
+ "GhUnreadable"
3550
+ ], asUserError))).pipe(Command.withDescription("Set this machine up and register the repository I am in"));
3431
3551
  //#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)}`;
3552
+ //#region src/adapters/ci.ts
3444
3553
  /**
3445
- * The findings one to a line, in the order the run reported them, ruled so the
3446
- * three columns read apart.
3554
+ * What GitHub says about a pull request's checks, and the evidence a red one
3555
+ * is classified on. Every read here goes through the same `gh` the rest of the
3556
+ * tool does; what it owns is the checks, not the boundary.
3447
3557
  */
3448
- const lines$1 = (found) => table(found.findings.map((finding) => [
3449
- `${finding.file}:${finding.line}`,
3450
- finding.severity,
3451
- finding.summary
3452
- ]), "");
3558
+ const failing = /* @__PURE__ */ new Set([
3559
+ "FAILURE",
3560
+ "TIMED_OUT",
3561
+ "CANCELLED",
3562
+ "STARTUP_FAILURE",
3563
+ "ACTION_REQUIRED",
3564
+ "ERROR"
3565
+ ]);
3566
+ const running = /* @__PURE__ */ new Set([
3567
+ "QUEUED",
3568
+ "IN_PROGRESS",
3569
+ "WAITING",
3570
+ "PENDING",
3571
+ "REQUESTED",
3572
+ "EXPECTED"
3573
+ ]);
3574
+ const nameOf = (entry) => entry.name ?? entry.context ?? "";
3575
+ const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
3576
+ const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
3453
3577
  /**
3454
- * The review run whose findings are the current ones, or the sentence saying
3455
- * there are none.
3578
+ * What the rollup comes to: red when anything failed, pending only while
3579
+ * nothing has failed yet, green when every check that counts has passed.
3456
3580
  *
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.
3581
+ * `ci.ignore` names the checks that do not count towards green, so a check I
3582
+ * have decided to live with cannot hold a PR out of Ready.
3461
3583
  */
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.`);
3465
- });
3584
+ const rollupState = (entries, ignore) => {
3585
+ const checks = checksThatCount(entries, ignore);
3586
+ if (checks.length === 0) return "none";
3587
+ if (checks.some(hasFailed)) return "red";
3588
+ if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
3589
+ return "green";
3590
+ };
3466
3591
  /**
3467
- * What the run reported, or the sentence saying it reported nothing at all.
3592
+ * The checks that failed and count, which are the ones there is a log to read.
3468
3593
  *
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.
3594
+ * `ci.ignore` is applied here as well as in the rollup: a check that cannot
3595
+ * hold a PR out of Ready is not one the classifier should be explaining either.
3471
3596
  */
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);
3475
- };
3597
+ const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
3476
3598
  /**
3477
- * What the current review run found, as a table or as the JSON it is kept in.
3478
- *
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.
3599
+ * What a check reports on, out of the URL it reports at.
3482
3600
  *
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.
3601
+ * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
3602
+ * what the logs endpoint takes and the run id is what `gh run rerun` takes, so
3603
+ * the two ids the tool needs are the two halves of one URL and are read
3604
+ * together. A commit status points somewhere else entirely, which is null:
3605
+ * there is no log of ours to read and no run of ours to re-run.
3486
3606
  */
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
3607
+ const reportedAt = (detailsUrl) => {
3608
+ const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
3609
+ return found?.[1] === void 0 || found[2] === void 0 ? null : {
3610
+ run: found[1],
3611
+ job: found[2]
3612
+ };
3613
+ };
3614
+ const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
3505
3615
  /**
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.
3616
+ * The branch a repository merges into, which is the one the first flaky signal
3617
+ * asks about. An empty repository has none, and `main` is the better guess than
3618
+ * failing the sweep over it.
3619
+ */
3620
+ const defaultBranch = Effect.fnUntraced(function* (repo) {
3621
+ return (yield* readJson("repo view defaultBranchRef", "gh", [
3622
+ "repo",
3623
+ "view",
3624
+ repo,
3625
+ "--json",
3626
+ "defaultBranchRef"
3627
+ ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
3628
+ });
3629
+ const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
3630
+ /** How far back to look for a run that reached a verdict at all. */
3631
+ const recentRuns = 5;
3632
+ /** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
3633
+ const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
3634
+ /** A run that decided something. A skipped or cancelled run says nothing either way. */
3635
+ const verdicts = /* @__PURE__ */ new Set([
3636
+ "failure",
3637
+ "timed_out",
3638
+ "success"
3639
+ ]);
3640
+ /**
3641
+ * Whether `workflow` is red on `branch` right now.
3508
3642
  *
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.
3643
+ * The newest run that reached a verdict is the whole answer: a workflow that
3644
+ * broke last week and was fixed since is not red, and excusing a pull request
3645
+ * for it would hide a failure that is real. A handful of runs are asked for
3646
+ * because the newest ones are often skipped by a path filter.
3511
3647
  */
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
- }
3521
- };
3648
+ const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
3649
+ const newest = (yield* readJson("run list", "gh", [
3650
+ "run",
3651
+ "list",
3652
+ "--repo",
3653
+ repo,
3654
+ "--branch",
3655
+ branch,
3656
+ "--workflow",
3657
+ workflow,
3658
+ "--limit",
3659
+ String(recentRuns),
3660
+ "--json",
3661
+ "conclusion"
3662
+ ], Runs)).find((run) => verdicts.has(run.conclusion));
3663
+ return newest !== void 0 && failedRun.has(newest.conclusion);
3664
+ });
3665
+ const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
3666
+ /** The repository paths a pull request changes. */
3667
+ const prFiles = Effect.fnUntraced(function* (repo, number) {
3668
+ return (yield* readJson("pr view files", "gh", [
3669
+ "pr",
3670
+ "view",
3671
+ String(number),
3672
+ "--repo",
3673
+ repo,
3674
+ "--json",
3675
+ "files"
3676
+ ], PrFiles)).files.map((file) => file.path);
3677
+ });
3678
+ /**
3679
+ * How much of a failing job's log is kept.
3680
+ *
3681
+ * A job that failed prints what went wrong at the end, so the tail is the part
3682
+ * worth classifying, and a build that logged a whole dependency tree is not
3683
+ * worth holding in memory beyond it.
3684
+ */
3685
+ const logTailBytes = 65536;
3522
3686
  /**
3523
- * Failures in the name of the program that was spawned.
3687
+ * What one failing job printed, from the end.
3524
3688
  *
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.
3689
+ * `gh api` refuses a response carrying terminal escape sequences unless it is
3690
+ * told otherwise, and a runner log is full of them. Verified by running it: the
3691
+ * endpoint answers with the plain log once the flag is passed.
3528
3692
  */
3529
- const failedBy = (program) => (detail) => new AgentFailed({
3530
- program,
3531
- detail
3693
+ const jobLog = Effect.fnUntraced(function* (repo, jobId) {
3694
+ const log = yield* capture("gh", [
3695
+ "api",
3696
+ `repos/${repo}/actions/jobs/${jobId}/logs`,
3697
+ "--allow-escape-sequences"
3698
+ ]).pipe(Effect.catchTags({
3699
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3700
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3701
+ command: "api job logs",
3702
+ detail: error.stderr
3703
+ }))
3704
+ }));
3705
+ return log.length <= logTailBytes ? log : log.slice(-65536);
3532
3706
  });
3533
3707
  /**
3534
- * How long each turn gets before it is given up on.
3708
+ * The workflow runs behind the failing checks that count, each named once.
3535
3709
  *
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.
3710
+ * One broken run usually fails several jobs, and re-running it once per failing
3711
+ * job would start the same run over and over.
3541
3712
  *
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.
3713
+ * `ci.ignore` decides which checks get a run into this list, and no more than
3714
+ * that: a run is re-run whole, so an ignored job sharing a run with a counted
3715
+ * one is re-run beside it. What the setting buys is that an ignored check is
3716
+ * never on its own a reason to spend CI minutes.
3544
3717
  */
3545
- const patience = {
3546
- reviewing: Duration.minutes(45),
3547
- reporting: Duration.minutes(5)
3548
- };
3718
+ const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
3719
+ const reported = reportedAt(check.detailsUrl);
3720
+ return reported === null ? [] : [reported.run];
3721
+ }))];
3549
3722
  /**
3550
- * One turn of the launcher in `directory`, with `read` over its standard output.
3723
+ * Asks GitHub to run one workflow run's failed jobs again.
3551
3724
  *
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.
3725
+ * `--failed` is what makes this cheap: the jobs that passed are not run a
3726
+ * second time, so a flaky job costs the minutes it costs and no more. This is a
3727
+ * write to GitHub, and it is one of the three ADR 0002 allows.
3558
3728
  */
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
- });
3729
+ const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
3730
+ yield* capture("gh", [
3731
+ "run",
3732
+ "rerun",
3733
+ runId,
3734
+ "--repo",
3735
+ repo,
3736
+ "--failed"
3737
+ ]).pipe(Effect.catchTags({
3738
+ PlatformError: (error) => Effect.fail(unavailable(error)),
3739
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
3740
+ command: "run rerun",
3741
+ detail: error.stderr
3742
+ }))
3743
+ }));
3577
3744
  });
3578
3745
  //#endregion
3579
- //#region src/adapters/claude.ts
3580
- /**
3581
- * Claude Code: a review on a slash command, a review on the tool's own prompt,
3582
- * and the sessions I steer.
3583
- */
3746
+ //#region src/domain/stamp.ts
3584
3747
  /**
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.
3748
+ * A stamp I took off a pull request by hand, and the head I took it off at.
3749
+ *
3750
+ * The head is the whole record: a withdrawal is my overruling the computation
3751
+ * on code I have read, so it lasts exactly as long as that code is what the
3752
+ * pull request is.
3589
3753
  */
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)
3754
+ const Withdrawal = Schema.Struct({ head: Schema.String });
3755
+ /** The stamp a pull request has not earned, and the first reason it has not. */
3756
+ const withheld = (reason) => ({
3757
+ stamped: false,
3758
+ reason
3606
3759
  });
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] : [])
3617
- };
3760
+ /** What CI has to say before the stamp will rest on it, which is green and nothing else. */
3761
+ const whyNotGreen = {
3762
+ green: null,
3763
+ red: "CI is red",
3764
+ pending: "CI is still running",
3765
+ none: "no CI ran on this head"
3618
3766
  };
3619
- /**
3620
- * The result a turn ended on, or the failure it really was.
3621
- *
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.
3625
- */
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);
3767
+ /** What GitHub has to say about merging, which is that it would. */
3768
+ const whyNotMergeable = {
3769
+ mergeable: null,
3770
+ conflicting: "merge conflict",
3771
+ unknown: "GitHub has not said whether it merges"
3631
3772
  };
3632
3773
  /**
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.
3636
- *
3637
- * Both shapes of review read a turn the same way, so the fold is here rather
3638
- * than once per shape.
3639
- */
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
- })));
3653
- /**
3654
- * One review run on a slash command, headless, in `directory`.
3774
+ * The stamp of one tracked PR: whether it has passed my bar, and why.
3655
3775
  *
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.
3776
+ * The mark is computed rather than clicked, so it means the same thing every
3777
+ * time: a review run on this head that found nothing blocking, CI green as the
3778
+ * repository's `ci.ignore` defines green, and a pull request GitHub would
3779
+ * merge. A red CI the flaky classifier excused is still not green here: an
3780
+ * excuse is a reason not to fix a check, not a reason to land code behind one,
3781
+ * and this mark is what clears `dw-mc merge` (ADR 0008).
3659
3782
  *
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.
3783
+ * Nothing about this rests on a previous stamp, which is what makes a head
3784
+ * change clear it: facts are about one head, and a run is recorded against one.
3666
3785
  *
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.
3786
+ * A withdrawal comes first, because it is the one thing here I decided rather
3787
+ * than computed.
3672
3788
  */
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");
3789
+ const stampFor = (facts, withdrawnAt) => {
3790
+ if (withdrawnAt === facts.head) return withheld("withdrawn by hand");
3791
+ if (facts.reviewRunHead !== facts.head) return withheld("no review run on this head");
3792
+ if (facts.blockingFindings > 0) return withheld(blockedBy(facts.blockingFindings));
3793
+ const ci = whyNotGreen[facts.checks];
3794
+ if (ci !== null) return withheld(ci);
3795
+ const merge = whyNotMergeable[facts.mergeable];
3796
+ if (merge !== null) return withheld(merge);
3696
3797
  return {
3697
- report,
3698
- sessionId: session_id
3798
+ stamped: true,
3799
+ reason: "a clean review run on this head, green CI, mergeable"
3699
3800
  };
3700
- }, Effect.scoped);
3801
+ };
3802
+ /**
3803
+ * The head a stamp was withdrawn at, or null where none was.
3804
+ *
3805
+ * Forgetting a withdrawal hands the pull request back to the computation, which
3806
+ * is what every other input to a stamp already is.
3807
+ */
3808
+ const withdrawnAt = Effect.fn("stamp.withdrawnAt")(function* (repo, number) {
3809
+ const store = yield* storeFor("stamps", Withdrawal);
3810
+ const withdrawal = yield* remembered(store.get(prKey(repo, number)));
3811
+ return Option.match(withdrawal, {
3812
+ onNone: () => null,
3813
+ onSome: (it) => it.head
3814
+ });
3815
+ });
3816
+ /** Takes the stamp off a pull request at `head`, which is the only head it stays off. */
3817
+ const withdraw = Effect.fn("stamp.withdraw")(function* (repo, number, head) {
3818
+ yield* (yield* storeFor("stamps", Withdrawal)).set(prKey(repo, number), { head });
3819
+ });
3820
+ /** The stamp of one tracked PR, with the withdrawal this machine holds against it. */
3821
+ const stampOf = Effect.fn("stamp.stampOf")(function* (facts) {
3822
+ return stampFor(facts, yield* withdrawnAt(facts.repo, facts.number));
3823
+ });
3701
3824
  /**
3702
- * What the second turn asks for.
3825
+ * Which of these tracked PRs carry a stamp, keyed the way their facts are.
3703
3826
  *
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.
3827
+ * A table asks the question of every row at once, and the withdrawals are the
3828
+ * only thing here that has to be read off the disk.
3708
3829
  */
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(" ");
3830
+ const stampedAmong = Effect.fn("stamp.stampedAmong")(function* (facts) {
3831
+ const marks = yield* Effect.forEach(facts, (it) => Effect.map(stampOf(it), (stamp) => ({
3832
+ key: prKey(it.repo, it.number),
3833
+ stamped: stamp.stamped
3834
+ })));
3835
+ return new Set(marks.filter((mark) => mark.stamped).map((mark) => mark.key));
3836
+ });
3837
+ //#endregion
3838
+ //#region src/domain/merge.ts
3716
3839
  /**
3717
- * The second turn of a review run: the prose the first one wrote, back as
3718
- * findings that validate.
3840
+ * Why GitHub would not call this pull request Ready, or null where it would.
3719
3841
  *
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.
3842
+ * Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
3843
+ * A repository that requires no reviewer produces no approval, which is why
3844
+ * `none` passes and `review-required` does not - what holds a merge is somebody
3845
+ * having been asked and not yet answered.
3725
3846
  *
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.
3847
+ * A red CI the flaky classifier excused is still red here. The excuse is a
3848
+ * reason not to fix a check; it is not a reason to land code behind one.
3728
3849
  */
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);
3850
+ const whyNotReady = (situation) => {
3851
+ if (situation.reviewDecision === "changes-requested") return "changes are requested";
3852
+ if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
3853
+ return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
3854
+ };
3754
3855
  /**
3755
- * One review run of the tool's own review prompt, in `directory`.
3856
+ * What to do about a pull request that is Ready and carries no stamp.
3756
3857
  *
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`.
3858
+ * The stamp is withheld for one of three reasons and each has its own next
3859
+ * step, so the refusal names that step rather than leaving me to work out which
3860
+ * of the three it was. A withdrawal is the one with no command: I took the mark
3861
+ * off code I had read, and only that code changing puts it back.
3762
3862
  */
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
3863
+ const earnsIt = (situation) => {
3864
+ if (situation.withdrawnAt === situation.head) return "\n\nYou took it off at this head, and it stays off until the head changes.";
3865
+ const next = situation.reviewRunHead !== situation.head ? {
3866
+ command: `dw-mc review ${situation.number}`,
3867
+ says: "That reviews this head, and a run that finds nothing blocking stamps it."
3868
+ } : {
3869
+ command: `dw-mc fix ${situation.number}`,
3870
+ says: "That opens a session on the findings. The stamp is back once the head has moved and a review run has read it."
3791
3871
  };
3792
- }, Effect.scoped);
3872
+ return `\n\n ${next.command}\n\n${next.says}`;
3873
+ };
3793
3874
  /**
3794
- * One review run, in whichever shape it was configured in.
3875
+ * Why this pull request is not one to merge, or null where it is.
3795
3876
  *
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.
3877
+ * This is the single place the merge guards live, and they carry more than the
3878
+ * merge does: it is the one write the tool makes that no reflog of mine undoes
3879
+ * (ADR 0008). Two bars have to be clear, because each is blind to what the
3880
+ * other sees - GitHub does not know whether anything read the diff, and the
3881
+ * stamp does not know whether a reviewer asked for changes.
3799
3882
  *
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.
3883
+ * Whose pull request it is comes first, as it does everywhere else: one
3884
+ * somebody else opened is none of this tool's business, whatever is true of it.
3885
+ * A draft is next, because a pull request I have not offered to anybody is not
3886
+ * one to land however green it is.
3887
+ *
3888
+ * Ready is asked before the stamp so that the refusal names the bar I am
3889
+ * actually under. The stamp insists on green CI and a mergeable pull request
3890
+ * too, so everything it can be withheld for here is mine rather than GitHub's.
3803
3891
  */
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
- }));
3835
- return {
3836
- sessionId: run.sessionId,
3837
- prose: run.report,
3838
- findings
3839
- };
3840
- });
3892
+ const decide$3 = (situation) => {
3893
+ const where = `${situation.repo}#${situation.number}`;
3894
+ if (!situation.mine) return `${where} is not mine. dw-mc merges pull requests I author and nothing else.`;
3895
+ if (situation.draft) return `${where} is a draft. Mark it ready for review before merging it.`;
3896
+ const ready = whyNotReady(situation);
3897
+ if (ready !== null) return `${where} is not Ready: ${ready}. dw-mc merges nothing GitHub would not merge itself.`;
3898
+ const stamp = stampFor(situation, situation.withdrawnAt);
3899
+ return stamp.stamped ? null : `${where} is Ready and carries no stamp: ${stamp.reason}.${earnsIt(situation)}`;
3900
+ };
3901
+ //#endregion
3902
+ //#region src/cli/merge.ts
3841
3903
  /**
3842
- * An interactive `claude` in `directory`, opened on `prompt`, with my terminal
3843
- * handed straight to it.
3904
+ * Lands one pull request of mine: squashed, with its branch deleted.
3844
3905
  *
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.
3906
+ * This is the write ADR 0008 is about, and the only one the tool makes that no
3907
+ * reflog of mine brings back. It is outside ADR 0002's three because it moves a
3908
+ * shared branch; everything 0002 bars - comment, reply, thread resolve, label,
3909
+ * review, approval, status - still holds here as it does everywhere.
3849
3910
  *
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.
3911
+ * The threshold is two bars at one head: Ready, which is GitHub's opinion, and
3912
+ * my stamp, which is mine. Each is blind to what the other sees, so the write
3913
+ * that cannot be undone clears both.
3855
3914
  *
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.
3915
+ * GitHub's half is read live from a fresh `pr view` rather than off the last
3916
+ * sweep, the way the rebase and re-run guards are. A stale verdict costs a
3917
+ * re-run some CI minutes; here it costs merging code nobody read. My half comes
3918
+ * from the state directory, because the review runs and the withdrawal live
3919
+ * there and are already scoped to the head this read just named.
3858
3920
  *
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.
3921
+ * Typing the command is the confirmation, so it takes no flag. The picker,
3922
+ * where a keystroke is cheaper, asks before it dispatches.
3862
3923
  */
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);
3924
+ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3925
+ const { number, repo, settings } = yield* forPr(pr);
3926
+ const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
3927
+ const head = view.headRefOid;
3928
+ yield* refuse(decide$3({
3929
+ repo,
3930
+ number,
3931
+ head,
3932
+ mine: view.author?.login === me,
3933
+ draft: view.isDraft,
3934
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
3935
+ checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
3936
+ mergeable: mergeabilityOf(view.mergeable),
3937
+ ...yield* reviewedAt(repo, number, head, settings.stamp.blocks_on),
3938
+ withdrawnAt: yield* withdrawnAt(repo, number)
3939
+ }));
3940
+ yield* mergePr(repo, number);
3941
+ yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3942
+ yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
3943
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3880
3944
  //#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)
3886
- });
3945
+ //#region src/domain/flaky.ts
3887
3946
  /**
3888
- * What a fix session is handed: the findings I picked, and the review run they
3889
- * came from.
3947
+ * The failures that are flaky wherever they appear: a machine, a network or a
3948
+ * runner giving up, never a test disagreeing with the code.
3890
3949
  *
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.
3950
+ * `ci.flaky_patterns` adds to this list rather than replacing it, because the
3951
+ * failures a repository of mine produces are extra ones, not different ones.
3893
3952
  */
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));
3953
+ const builtInPatterns = [
3954
+ "timed out",
3955
+ "deadline exceeded",
3956
+ "ETIMEDOUT",
3957
+ "ECONNRESET",
3958
+ "ECONNREFUSED",
3959
+ "connection refused",
3960
+ "socket hang up",
3961
+ "lock timeout",
3962
+ "could not obtain lock",
3963
+ "runner lost communication",
3964
+ "The runner has received a shutdown signal",
3965
+ "net/http: request canceled",
3966
+ "ResourceExhausted",
3967
+ "Too many open files",
3968
+ "no space left on device"
3969
+ ];
3970
+ const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
3902
3971
  /**
3903
- * The prompt a fix session opens on: what these findings are, and the findings
3904
- * themselves as JSON.
3905
- *
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.
3972
+ * The changed file the log names, preferring one it spells in full.
3910
3973
  *
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.
3974
+ * A bare file name is worth matching - a stack trace often prints nothing else
3975
+ * - and it is worth matching second, because a name as ordinary as `index.ts`
3976
+ * belongs to more repositories than mine.
3915
3977
  */
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"));
3978
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3922
3979
  /**
3923
- * Why these findings cannot be fixed where the pull request now is, or nothing
3924
- * where they can.
3980
+ * Whether the log names a file called `base` rather than some longer name
3981
+ * ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
3982
+ * complaining about.
3983
+ */
3984
+ const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
3985
+ const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
3986
+ /**
3987
+ * The flaky pattern the log matches, mine before the built-in ones.
3925
3988
  *
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.
3989
+ * A pattern is text and not a regular expression: it comes out of a
3990
+ * configuration file I edit by hand, where a stray `*` should cost me a missed
3991
+ * match and never a crash.
3930
3992
  */
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);
3993
+ const matchedPattern = (log, patterns) => {
3994
+ const haystack = log.toLowerCase();
3995
+ return [...patterns, ...builtInPatterns].find((pattern) => haystack.includes(pattern.toLowerCase())) ?? null;
3996
+ };
3936
3997
  /**
3937
- * The findings to pick from, each on the line `dw-mc findings` gives it.
3998
+ * Whether a red CI is mine to fix, and why.
3938
3999
  *
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.
4000
+ * Two of the signals say flaky and one says legitimate, and the one outranks
4001
+ * the two: a log that names a file this pull request changes is the failure
4002
+ * pointing at my own work, and a workflow that is broken everywhere does not
4003
+ * stop it pointing there.
4004
+ *
4005
+ * Everything else that is unexplained is mine as well. The two mistakes do not
4006
+ * cost the same - a real failure called flaky is a broken pull request nobody
4007
+ * tells me about, while a flake called mine costs me one look - so the default
4008
+ * is the one I can recover from.
3943
4009
  */
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
- }));
4010
+ const classify = (evidence, flakyPatterns) => {
4011
+ const named = namedChangedFile(evidence.log, evidence.changedFiles);
4012
+ if (named !== null) return {
4013
+ classification: "legitimate",
4014
+ reason: `the log names ${named}, which this PR changes`
4015
+ };
4016
+ const redOnDefaultBranch = evidence.alsoRedOnDefaultBranch[0];
4017
+ const pattern = matchedPattern(evidence.log, flakyPatterns);
4018
+ 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);
4019
+ return excuses.length === 0 ? {
4020
+ classification: "legitimate",
4021
+ reason: "nothing explains the failure"
4022
+ } : {
4023
+ classification: "flaky",
4024
+ reason: excuses.join(", and ")
4025
+ };
3951
4026
  };
3952
4027
  /**
3953
- * Each picked finding with whatever I have to say about it.
4028
+ * How many failing jobs the log is read from.
3954
4029
  *
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.
4030
+ * One broken workflow usually fails several jobs with the same cause, and the
4031
+ * logs are the one read here that is measured in megabytes.
3958
4032
  */
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;
4033
+ const loggedJobs = 3;
4034
+ /** The values of `xs` that `f` has one for. */
4035
+ const filterMap = (xs, f) => xs.flatMap((x) => {
4036
+ const b = f(x);
4037
+ return b === null ? [] : [b];
3972
4038
  });
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 }));
4039
+ /** No evidence at all, which is what an unreadable CI comes to. */
4040
+ const nothing = {
4041
+ alsoRedOnDefaultBranch: [],
4042
+ changedFiles: [],
4043
+ log: ""
3977
4044
  };
3978
4045
  /**
3979
- * A fix session: the findings I picked, in an agent session I steer.
3980
- *
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.
4046
+ * What a red CI looks like to the classifier.
3987
4047
  *
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.
4048
+ * A read that fails costs its own signal and nothing else. GitHub drops an
4049
+ * Actions log after ninety days, so a pull request open that long would
4050
+ * otherwise lose its row over a log nobody can fetch any more - and a missing
4051
+ * signal only ever moves the verdict towards legitimate, which is the answer
4052
+ * that puts the pull request in front of me rather than hiding it.
3992
4053
  */
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 } } : {}
4059
- });
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
4054
+ const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entries, ignore) {
4055
+ const failed = failedChecks(entries, ignore);
4056
+ const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
4057
+ const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
4058
+ const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
4059
+ if (branch === null) return nothing;
4060
+ const [alsoRed, changedFiles, logs] = yield* Effect.all([
4061
+ Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
4062
+ Effect.orElseSucceed(prFiles(repo, number), () => []),
4063
+ Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
4064
+ ], { concurrency: 3 });
4065
+ return {
4066
+ alsoRedOnDefaultBranch: alsoRed.flat(),
4067
+ changedFiles,
4068
+ log: logs.join("\n")
4065
4069
  };
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}`;
4070
+ });
4069
4071
  /**
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.
4072
+ * Why a red CI is excused, or null where it is mine to fix.
4078
4073
  *
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.
4074
+ * Reading the evidence and classifying it is one act, so it is one function:
4075
+ * a sweep writes what it returns down as `ciFlaky`, and `dw-mc rerun` asks it
4076
+ * again live. Two callers asking the same question have to get the same answer,
4077
+ * which they cannot if each of them spells the question out.
4078
+ */
4079
+ const flakyReason = Effect.fn("flaky.flakyReason")(function* (repo, number, entries, ignore, patterns) {
4080
+ const verdict = classify(yield* evidenceFor(repo, number, entries, ignore), patterns);
4081
+ return verdict.classification === "flaky" ? verdict.reason : null;
4082
+ });
4083
+ //#endregion
4084
+ //#region src/domain/quiet.ts
4085
+ /** The pulse of a PR a previous sweep recorded. */
4086
+ const pulseOf = (facts) => ({
4087
+ head: facts.head,
4088
+ checks: facts.checks,
4089
+ newestHumanCommentAt: facts.newestHumanCommentAt
4090
+ });
4091
+ /**
4092
+ * Whether a PR is where the last sweep left it.
4082
4093
  *
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.
4094
+ * A quiet PR keeps the facts it already had rather than being read out again,
4095
+ * so a sweep over many pull requests spends its time on the few that moved.
4085
4096
  */
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"));
4097
+ const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
4110
4098
  //#endregion
4111
- //#region src/domain/stamp.ts
4099
+ //#region src/domain/rebase.ts
4112
4100
  /**
4113
- * A stamp I took off a pull request by hand, and the head I took it off at.
4101
+ * How many pull requests this one stands on.
4114
4102
  *
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.
4103
+ * A branch is walked to what it merges into and on from there, however deep the
4104
+ * stack goes. Every pull request the walk has already counted is left alone,
4105
+ * which is what keeps two branches that merge into each other from being walked
4106
+ * around forever.
4118
4107
  */
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
4124
- });
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"
4108
+ const ancestorsOf = (pr, open, seen) => {
4109
+ let count = 0;
4110
+ let current = pr;
4111
+ for (;;) {
4112
+ const parent = open.find((it) => it.head === current.base && !seen.has(it.number));
4113
+ if (parent === void 0) return count;
4114
+ seen.add(parent.number);
4115
+ count += 1;
4116
+ current = parent;
4117
+ }
4131
4118
  };
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"
4119
+ /**
4120
+ * How deep the stack goes above this pull request.
4121
+ *
4122
+ * Two branches cut from the same one are not two stacks deep, they are two
4123
+ * branches, so what counts is the deepest single line of them rather than how
4124
+ * many pull requests stand above it in total.
4125
+ */
4126
+ const descendantsOf = (pr, open, seen) => {
4127
+ let deepest = 0;
4128
+ for (const child of open.filter((it) => it.base === pr.head && !seen.has(it.number))) {
4129
+ seen.add(child.number);
4130
+ deepest = Math.max(deepest, 1 + descendantsOf(child, open, seen));
4131
+ }
4132
+ return deepest;
4137
4133
  };
4138
4134
  /**
4139
- * The stamp of one tracked PR: whether it has passed my bar, and why.
4135
+ * Where a pull request sits in its stack, or null where it is in none.
4140
4136
  *
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).
4137
+ * A stack is read off the branches alone: a pull request that merges into
4138
+ * another pull request's branch, or that another one merges into, is part of
4139
+ * one. The tool does not understand stacks and never drives them, so this
4140
+ * exists to recognise one and say where the pull request sits in it.
4141
+ */
4142
+ const stackOf = (number, open) => {
4143
+ const pr = open.find((it) => it.number === number);
4144
+ if (pr === void 0) return null;
4145
+ const seen = /* @__PURE__ */ new Set([number]);
4146
+ const below = ancestorsOf(pr, open, seen);
4147
+ const above = descendantsOf(pr, open, seen);
4148
+ return below === 0 && above === 0 ? null : {
4149
+ position: below + 1,
4150
+ length: below + above + 1
4151
+ };
4152
+ };
4153
+ /**
4154
+ * Why this branch is nobody's to touch here, or null where it is mine.
4147
4155
  *
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.
4156
+ * These are the guards about the branch rather than about what is done to it,
4157
+ * which is why they are their own and why they say nothing about pushing: who
4158
+ * authored the pull request and where its branch lives is the boundary itself -
4159
+ * a branch somebody else authored and a branch in a fork are not mine to work
4160
+ * on, whatever else is true of them and whichever command asks. A stack comes
4161
+ * next, and a pull request the stack was not read from counts as one, because a
4162
+ * stack the tool cannot see is one it could drive: the tool does not understand
4163
+ * stacks, so the one thing it has to say about one is where the pull request
4164
+ * sits in it.
4165
+ */
4166
+ const boundary = (branch) => {
4167
+ const where = `${branch.repo}#${branch.number}`;
4168
+ if (!branch.mine) return `${where} is not mine. dw-mc works on branches I author and on nothing else.`;
4169
+ 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.`;
4170
+ 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.`;
4171
+ 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.`;
4172
+ return null;
4173
+ };
4174
+ /**
4175
+ * Why this branch is not one to rebase, or null where it is.
4150
4176
  *
4151
- * A withdrawal comes first, because it is the one thing here I decided rather
4152
- * than computed.
4177
+ * This is the single place the guards live, and they matter more than the
4178
+ * rebase itself: a force push is the one write the tool makes that can lose
4179
+ * work, and every rule here is about it never being a surprise.
4180
+ *
4181
+ * Being off is said first, because a repository that has not turned rebase on
4182
+ * has decided the question and nothing else about the pull request changes it.
4183
+ * The branch's own guards come next. CI is last and costs the most to get
4184
+ * wrong - rebasing while a run is in flight cancels the run I am waiting on,
4185
+ * and a red build is mine to fix where it is.
4153
4186
  */
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"
4165
- };
4187
+ const decide$2 = (situation) => {
4188
+ const where = `${situation.repo}#${situation.number}`;
4189
+ 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.`;
4190
+ const refused = boundary(situation);
4191
+ if (refused !== null) return refused;
4192
+ if (situation.checks === "pending") return `CI is still running on ${where}. A rebase now would cancel the run you are waiting on.`;
4193
+ if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
4194
+ return null;
4166
4195
  };
4167
4196
  /**
4168
- * The head a stamp was withdrawn at, or null where none was.
4197
+ * A rebase that conflicted: the head it conflicted at and the files it stopped
4198
+ * on.
4199
+ *
4200
+ * The head is what the record is scoped to, as it is for a withdrawn stamp: a
4201
+ * conflict is about the code the branch is at, so it lasts exactly as long as
4202
+ * that code is what the pull request is. A branch that moved is a branch
4203
+ * nothing here has tried to rebase yet.
4169
4204
  *
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.
4205
+ * The paths are what makes the conflict something to open: `a rebase
4206
+ * conflicted` cannot tell a stale lockfile from half the pull request. They are
4207
+ * an optional key rather than a required one so a record an older version wrote
4208
+ * still reads, and a conflict with no paths still puts the pull request in
4209
+ * Needs me.
4174
4210
  */
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
4181
- });
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));
4211
+ const Conflict = Schema.Struct({
4212
+ head: Schema.String,
4213
+ paths: Schema.optionalKey(Schema.Array(Schema.String))
4190
4214
  });
4191
4215
  /**
4192
- * Which of these tracked PRs carry a stamp, keyed the way their facts are.
4216
+ * The conflict a rebase last left on this pull request, or null where it left
4217
+ * none.
4193
4218
  *
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.
4219
+ * Forgetting one costs the pull request one reason to be in Needs me, where
4220
+ * failing here would cost me the whole table.
4196
4221
  */
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));
4222
+ const conflictFor = Effect.fn("rebase.conflictFor")(function* (repo, number) {
4223
+ const store = yield* storeFor("rebases", Conflict);
4224
+ const conflict = yield* remembered(store.get(prKey(repo, number)));
4225
+ return Option.getOrNull(conflict);
4226
+ });
4227
+ /** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
4228
+ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
4229
+ yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
4230
+ head,
4231
+ paths
4232
+ });
4203
4233
  });
4204
4234
  //#endregion
4205
- //#region src/domain/merge.ts
4235
+ //#region src/cli/sweep.ts
4236
+ const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
4237
+ const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
4206
4238
  /**
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.
4239
+ * The facts about one tracked PR, read from GitHub and kept on disk.
4213
4240
  *
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.
4241
+ * The cheap reads happen every time, because they are what says whether the PR
4242
+ * moved. The commits are asked for only when it did: `gh` returns every commit
4243
+ * message in full, and on a PR that is where the last sweep left it that whole
4244
+ * read buys a timestamp the state directory already has.
4216
4245
  */
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
- };
4246
+ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, settings) {
4247
+ const view = yield* prView(found.repo, found.number);
4248
+ const [onThePr, inReviews] = yield* Effect.all([prComments(found.repo, found.number), prReviews(found.repo, found.number)], { concurrency: 2 });
4249
+ const comments = [...onThePr, ...inReviews];
4250
+ const checks = rollupState(view.statusCheckRollup, settings.ci.ignore);
4251
+ const newestHumanCommentAt = newest(byHumansOtherThan(comments, me));
4252
+ const key = prKey(found.repo, found.number);
4253
+ const previous = Option.getOrUndefined(yield* remembered(store.get(key)));
4254
+ const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings.stamp.blocks_on);
4255
+ const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
4256
+ head: view.headRefOid,
4257
+ checks,
4258
+ newestHumanCommentAt
4259
+ }) ? previous : void 0;
4260
+ const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
4261
+ 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);
4262
+ const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
4263
+ const facts = {
4264
+ repo: found.repo,
4265
+ number: found.number,
4266
+ title: view.title,
4267
+ url: view.url,
4268
+ draft: view.isDraft,
4269
+ head: view.headRefOid,
4270
+ mergeable: mergeabilityOf(view.mergeable),
4271
+ reviewDecision: reviewDecisionOf(view.reviewDecision),
4272
+ checks,
4273
+ ciFlaky,
4274
+ rebaseConflictAt,
4275
+ newestHumanCommentAt,
4276
+ myLastCommentAt: newest(writtenBy(comments, me)),
4277
+ myLastCommitAt,
4278
+ ...reviewed
4279
+ };
4280
+ yield* store.set(key, facts);
4281
+ return facts;
4282
+ });
4283
+ /** A read that came back, or the trouble it came back with instead. */
4284
+ const attempt = (where, read) => read.pipe(Effect.map((got) => ({
4285
+ got,
4286
+ troubles: []
4287
+ })), Effect.catch((error) => Effect.succeed({
4288
+ got: [],
4289
+ troubles: [{
4290
+ where,
4291
+ detail: error.message
4292
+ }]
4293
+ })));
4294
+ const gather = (attempts) => ({
4295
+ got: attempts.flatMap((it) => it.got),
4296
+ troubles: attempts.flatMap((it) => it.troubles)
4297
+ });
4298
+ /** How many reads of GitHub are in flight at once. */
4299
+ const concurrency = 4;
4300
+ /** `n` repositories, which `count` cannot say: the plural is not the noun plus s. */
4301
+ const repositories = (n) => n === 1 ? "1 repository" : `${n} repositories`;
4302
+ /** How the heartbeat of a sweep reads, wherever a command turns one. */
4303
+ const saying$1 = (swept) => (since) => [
4304
+ "sweeping",
4305
+ swept._tag === "searching" ? `${swept.done} of ${repositories(swept.of)}` : `${swept.done} of ${count(swept.of, "pull request")}`,
4306
+ since
4307
+ ].join(" · ");
4222
4308
  /**
4223
- * What to do about a pull request that is Ready and carries no stamp.
4309
+ * One pass over every tracked PR, and nothing else: a sweep only ever reads.
4224
4310
  *
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.
4311
+ * Every repository and every pull request is read on its own, so one of them
4312
+ * failing costs me its rows and leaves the rest of the table standing. What
4313
+ * failed comes back beside the facts rather than instead of them.
4314
+ *
4315
+ * `report` is told how far the pass has got, every time it gets further. What
4316
+ * that is worth saying is the caller's, which is why it is handed a count and
4317
+ * not a sentence.
4229
4318
  */
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."
4319
+ const sweep = Effect.fn("sweep")(function* (report) {
4320
+ const file = Option.getOrElse(yield* read, () => ({}));
4321
+ const repos = Object.keys(file.repos ?? {}).toSorted();
4322
+ if (repos.length === 0) return {
4323
+ repos,
4324
+ facts: [],
4325
+ troubles: []
4238
4326
  };
4239
- return `\n\n ${next.command}\n\n${next.says}`;
4240
- };
4327
+ const store = yield* storeFor("prs", Facts);
4328
+ const me = yield* viewer;
4329
+ let searched = 0;
4330
+ yield* report({
4331
+ _tag: "searching",
4332
+ done: 0,
4333
+ of: repos.length
4334
+ });
4335
+ const found = gather(yield* Effect.forEach(repos, (repo) => Effect.tap(attempt(repo, searchPrs(repo)), () => {
4336
+ searched = searched + 1;
4337
+ return report({
4338
+ _tag: "searching",
4339
+ done: searched,
4340
+ of: repos.length
4341
+ });
4342
+ }), { concurrency }));
4343
+ let read$1 = 0;
4344
+ yield* report({
4345
+ _tag: "reading",
4346
+ done: 0,
4347
+ of: found.got.length
4348
+ });
4349
+ 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])), () => {
4350
+ read$1 = read$1 + 1;
4351
+ return report({
4352
+ _tag: "reading",
4353
+ done: read$1,
4354
+ of: found.got.length
4355
+ });
4356
+ }), { concurrency }));
4357
+ return {
4358
+ repos,
4359
+ facts: swept.got,
4360
+ troubles: [...found.troubles, ...swept.troubles]
4361
+ };
4362
+ });
4241
4363
  /**
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.
4364
+ * A sweep under its heartbeat, which is how every command that sweeps runs one.
4254
4365
  *
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.
4366
+ * The three of them want the same line, so they say it once here rather than
4367
+ * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
4368
+ * prints exactly what it printed before there was a heartbeat at all.
4258
4369
  */
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
4370
+ const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
4371
+ /** What a sweep could not read, under a heading, so the table above it stands alone. */
4372
+ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4373
+ if (troubles.length === 0) return;
4374
+ yield* Console.log("");
4375
+ yield* Console.log("Could not load");
4376
+ for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
4377
+ });
4270
4378
  /**
4271
- * Lands one pull request of mine: squashed, with its branch deleted.
4272
- *
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.
4379
+ * Refreshes what mission control knows about every tracked PR.
4287
4380
  *
4288
- * Typing the command is the confirmation, so it takes no flag. The picker,
4289
- * where a keystroke is cheaper, asks before it dispatches.
4381
+ * `dw-mc status` does this too, so this command is for the pass on its own:
4382
+ * warming the state directory, or seeing what GitHub would not answer.
4290
4383
  */
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"));
4384
+ const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
4385
+ const report = yield* sweeping;
4386
+ 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)}`);
4387
+ yield* printTroubles(report.troubles);
4388
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
4313
4389
  //#endregion
4314
4390
  //#region src/domain/pick.ts
4315
4391
  /**
@@ -4434,13 +4510,11 @@ const Rerun = Schema.Struct({ head: Schema.String });
4434
4510
  * The head a re-run was last asked for at on this pull request, or null where
4435
4511
  * none has been.
4436
4512
  *
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.
4513
+ * Forgetting one costs a flaky pull request one extra re-run.
4440
4514
  */
4441
4515
  const rerunFor = Effect.fn("rerun.rerunFor")(function* (repo, number) {
4442
4516
  const store = yield* storeFor("reruns", Rerun);
4443
- const rerun = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
4517
+ const rerun = yield* remembered(store.get(prKey(repo, number)));
4444
4518
  return Option.getOrNull(rerun)?.head ?? null;
4445
4519
  });
4446
4520
  /** Writes down that a re-run was asked for at `head`, which is the only head it caps. */
@@ -4592,15 +4666,13 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4592
4666
  * so what it has to say about one is where the pull request sits in it.
4593
4667
  */
4594
4668
  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);
4669
+ const { number, repo, settings } = yield* forPr(pr);
4598
4670
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4599
4671
  prView(repo, number),
4600
4672
  openPrs(repo),
4601
4673
  viewer
4602
4674
  ]));
4603
- yield* refuse(decide$3({
4675
+ yield* refuse(decide$2({
4604
4676
  repo,
4605
4677
  number,
4606
4678
  base: view.baseRefName,
@@ -4633,7 +4705,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4633
4705
  return;
4634
4706
  }
4635
4707
  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"));
4708
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4637
4709
  //#endregion
4638
4710
  //#region src/cli/rerun.ts
4639
4711
  /**
@@ -4655,9 +4727,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4655
4727
  * it is a pull request re-running itself until the minutes run out.
4656
4728
  */
4657
4729
  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);
4730
+ const { number, repo, settings } = yield* forPr(pr);
4661
4731
  const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
4662
4732
  const unclassified = {
4663
4733
  repo,
@@ -4745,11 +4815,6 @@ const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
4745
4815
  //#endregion
4746
4816
  //#region src/cli/resolve.ts
4747
4817
  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
4818
  /**
4754
4819
  * A session on the conflict that stopped a rebase, in a worktree that is mine.
4755
4820
  *
@@ -4774,15 +4839,14 @@ const resolve = Command.make("resolve", {
4774
4839
  pr: prArgument,
4775
4840
  print: printFlag
4776
4841
  }, 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());
4842
+ const { number, repo, launcher } = yield* forPr(pr);
4779
4843
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4780
4844
  prView(repo, number),
4781
4845
  openPrs(repo),
4782
4846
  viewer
4783
4847
  ]));
4784
4848
  const conflict = yield* conflictFor(repo, number);
4785
- yield* allowed({
4849
+ yield* refuse(decide({
4786
4850
  repo,
4787
4851
  number,
4788
4852
  mine: view.author?.login === me,
@@ -4791,7 +4855,7 @@ const resolve = Command.make("resolve", {
4791
4855
  stack: stackOf(number, open),
4792
4856
  head: view.headRefOid,
4793
4857
  conflictAt: conflict === null ? null : conflict.head
4794
- });
4858
+ }));
4795
4859
  /** The conflict as the prompt takes it, around whichever paths are known by then. */
4796
4860
  const conflicted = (paths) => ({
4797
4861
  repo,
@@ -4824,7 +4888,7 @@ const resolve = Command.make("resolve", {
4824
4888
  yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
4825
4889
  yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
4826
4890
  const ended = yield* steeredSession({
4827
- launcher: launcherOf(file),
4891
+ launcher,
4828
4892
  directory: worktree.directory,
4829
4893
  prompt: yield* promptFor(conflicted(stopped.paths))
4830
4894
  });
@@ -4838,12 +4902,7 @@ const resolve = Command.make("resolve", {
4838
4902
  ``
4839
4903
  ], (line) => Console.log(line));
4840
4904
  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"));
4905
+ }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4847
4906
  //#endregion
4848
4907
  //#region src/adapters/notify.ts
4849
4908
  /** A string as AppleScript spells one, so a quotation mark cannot end it early. */
@@ -5119,10 +5178,7 @@ const review = Command.make("review", {
5119
5178
  commandOnly: commandOnlyFlag,
5120
5179
  force: forceFlag$1
5121
5180
  }, 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);
5181
+ const { number, repo, settings, launcher } = yield* forPr(pr);
5126
5182
  const asked = yield* asking({
5127
5183
  settings,
5128
5184
  command,
@@ -5196,7 +5252,7 @@ const review = Command.make("review", {
5196
5252
  yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5197
5253
  yield* unreported(run, number);
5198
5254
  }).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"));
5255
+ }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
5200
5256
  //#endregion
5201
5257
  //#region src/cli/stamp.ts
5202
5258
  const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
@@ -5221,8 +5277,7 @@ const stampCommand = Command.make("stamp", {
5221
5277
  pr: prArgument,
5222
5278
  withdraw: withdrawFlag
5223
5279
  }, 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());
5280
+ const { number, repo } = yield* forPr(pr);
5226
5281
  const facts = yield* swept(repo, number);
5227
5282
  const where = `${repo}#${number} ${short(facts.head)}`;
5228
5283
  if (byHand) {
@@ -5354,7 +5409,7 @@ const uninstall = Command.make("uninstall", {
5354
5409
  * Running from source leaves the constant undeclared rather than undefined, so
5355
5410
  * the check has to be `typeof` and the fallback is what a test reads.
5356
5411
  */
5357
- const version = "0.5.0";
5412
+ const version = "0.5.1";
5358
5413
  /** Where the project lives, printed beside the version in the header. */
5359
5414
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5360
5415
  const subcommands = [