dw-mc 0.4.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.
- package/dist/bin.js +2060 -1900
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -112,13 +112,21 @@ const encodeYaml = (value) => {
|
|
|
112
112
|
return `${out.join("\n")}\n`;
|
|
113
113
|
};
|
|
114
114
|
//#endregion
|
|
115
|
-
//#region src/
|
|
115
|
+
//#region src/terms/review.ts
|
|
116
|
+
/**
|
|
117
|
+
* The words a review run is described in.
|
|
118
|
+
*
|
|
119
|
+
* What a run opens on and how much it spends are facts about Claude Code, and
|
|
120
|
+
* how much a finding weighs is a rule of mine, but all three are read on both
|
|
121
|
+
* sides of the adapter seam: the domain writes the turn and weighs the
|
|
122
|
+
* findings, the adapter spawns the turn and is held to the same words.
|
|
123
|
+
*/
|
|
116
124
|
/**
|
|
117
125
|
* How much a review run spends, in the words the slash command takes.
|
|
118
126
|
*
|
|
119
|
-
* The set is Claude Code's and not this tool's, so it is wider than
|
|
120
|
-
* words
|
|
121
|
-
*
|
|
127
|
+
* The set is Claude Code's and not this tool's, so it is wider than three
|
|
128
|
+
* words: a run that would be worth `max` is one I should be able to ask for
|
|
129
|
+
* without spelling the whole command out.
|
|
122
130
|
*/
|
|
123
131
|
const Effort = Schema.Literals([
|
|
124
132
|
"low",
|
|
@@ -133,6 +141,8 @@ const Severity = Schema.Literals([
|
|
|
133
141
|
"warning",
|
|
134
142
|
"info"
|
|
135
143
|
]);
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/adapters/config.ts
|
|
136
146
|
/**
|
|
137
147
|
* What one section of the file may say. Every key is optional: what the file
|
|
138
148
|
* leaves out is inherited rather than reset, so `defaults` and a repository's
|
|
@@ -322,7 +332,7 @@ var ConfigMalformed = class extends Schema.TaggedError()("ConfigMalformed", {
|
|
|
322
332
|
return `${this.path} is not valid dw-mc configuration: ${this.reason}\nFix the file, or delete it and run 'dw-mc init' again.`;
|
|
323
333
|
}
|
|
324
334
|
};
|
|
325
|
-
const reasonOf = (cause) => cause
|
|
335
|
+
const reasonOf = (cause) => Predicate.isError(cause) ? cause.message : String(cause);
|
|
326
336
|
/** The keys an earlier version had, read off a file loosely enough to find them. */
|
|
327
337
|
const LegacySection = Schema.Struct({
|
|
328
338
|
review: Schema.optionalKey(Schema.Struct({
|
|
@@ -525,6 +535,18 @@ const textStoreFor = Effect.fn("store.textStoreFor")(function* (namespace) {
|
|
|
525
535
|
const store = yield* KeyValueStore.KeyValueStore;
|
|
526
536
|
return KeyValueStore.prefix(store, `${namespace}/`);
|
|
527
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());
|
|
528
550
|
/** The state directory on disk. */
|
|
529
551
|
const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => KeyValueStore.layerFileSystem(directory)));
|
|
530
552
|
KeyValueStore.layerMemory;
|
|
@@ -553,32 +575,63 @@ const sessionOf = (cut) => ({
|
|
|
553
575
|
})[cut];
|
|
554
576
|
/** Where the bare clones sit, under the state directory. */
|
|
555
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}`;
|
|
556
604
|
/** What a directory holds, or nothing at all where it is not there. */
|
|
557
605
|
const entriesOf = Effect.fnUntraced(function* (directory) {
|
|
558
606
|
const fs = yield* FileSystem.FileSystem;
|
|
559
607
|
return yield* Effect.orElseSucceed(fs.readDirectory(directory), () => []);
|
|
560
608
|
});
|
|
561
609
|
/**
|
|
562
|
-
* What `directory`
|
|
610
|
+
* What the entries named under `directory` weigh together.
|
|
563
611
|
*
|
|
564
612
|
* A file that is gone by the time it is asked about weighs nothing rather than
|
|
565
613
|
* failing the walk: the directory is being read while the tool may be writing
|
|
566
614
|
* to it, and a size on a screen is worth less than the listing it sits in.
|
|
567
615
|
*/
|
|
568
|
-
const
|
|
616
|
+
const weightOf = Effect.fnUntraced(function* (directory, entries) {
|
|
569
617
|
const fs = yield* FileSystem.FileSystem;
|
|
570
618
|
const path = yield* Path.Path;
|
|
571
|
-
const entries = yield* Effect.orElseSucceed(fs.readDirectory(directory, { recursive: true }), () => []);
|
|
572
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 });
|
|
573
620
|
return ByteSize.bytes(sizes.reduce((total, size) => total + size, BigInt(0)));
|
|
574
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
|
+
});
|
|
575
628
|
/** The bare clones, named by the `owner/repo` the two directory levels spell. */
|
|
576
629
|
const clonesOf = Effect.fnUntraced(function* (state) {
|
|
577
630
|
const path = yield* Path.Path;
|
|
578
631
|
const root = path.join(state, clonesIn);
|
|
579
632
|
const clones = [];
|
|
580
633
|
for (const owner of yield* entriesOf(root)) for (const name of yield* entriesOf(path.join(root, owner))) {
|
|
581
|
-
if (!name.endsWith(
|
|
634
|
+
if (!name.endsWith(bare)) continue;
|
|
582
635
|
const directory = path.join(root, owner, name);
|
|
583
636
|
clones.push({
|
|
584
637
|
repo: `${owner}/${name.slice(0, -4)}`,
|
|
@@ -607,19 +660,16 @@ const cuttingsOf = Effect.fnUntraced(function* (state) {
|
|
|
607
660
|
});
|
|
608
661
|
/** Everything the state directory holds, in one pass over the disk. */
|
|
609
662
|
const inventory = Effect.gen(function* () {
|
|
610
|
-
const fs = yield* FileSystem.FileSystem;
|
|
611
|
-
const path = yield* Path.Path;
|
|
612
663
|
const directory = yield* stateDirectory;
|
|
613
664
|
const directories = /* @__PURE__ */ new Set([clonesIn, ...cuts]);
|
|
614
665
|
const keys = (yield* entriesOf(directory)).filter((entry) => !directories.has(entry));
|
|
615
|
-
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 });
|
|
616
666
|
return {
|
|
617
667
|
directory,
|
|
618
668
|
clones: yield* clonesOf(directory),
|
|
619
669
|
cuttings: yield* cuttingsOf(directory),
|
|
620
670
|
records: {
|
|
621
671
|
keys: keys.length,
|
|
622
|
-
size:
|
|
672
|
+
size: yield* weightOf(directory, keys)
|
|
623
673
|
}
|
|
624
674
|
};
|
|
625
675
|
}).pipe(Effect.withSpan("store.inventory"));
|
|
@@ -657,6 +707,71 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
|
|
|
657
707
|
at = path.dirname(at);
|
|
658
708
|
}
|
|
659
709
|
});
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/adapters/heartbeat.ts
|
|
712
|
+
/** The frames of the spinner, in the order they turn. */
|
|
713
|
+
const frames = [
|
|
714
|
+
"⠋",
|
|
715
|
+
"⠙",
|
|
716
|
+
"⠹",
|
|
717
|
+
"⠸",
|
|
718
|
+
"⠼",
|
|
719
|
+
"⠴",
|
|
720
|
+
"⠦",
|
|
721
|
+
"⠧",
|
|
722
|
+
"⠇",
|
|
723
|
+
"⠏"
|
|
724
|
+
];
|
|
725
|
+
/** How long one frame is on the screen. */
|
|
726
|
+
const frameFor = Duration.millis(120);
|
|
727
|
+
/** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
|
|
728
|
+
const elapsed = (millis) => {
|
|
729
|
+
const seconds = Math.floor(millis / 1e3);
|
|
730
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
731
|
+
};
|
|
732
|
+
/**
|
|
733
|
+
* Runs `use` while one line says the work is still going, and hands `use` the
|
|
734
|
+
* way to say how that line reads.
|
|
735
|
+
*
|
|
736
|
+
* Work that takes seconds and prints nothing while it does is work I stop
|
|
737
|
+
* trusting. What the screen showed instead was either silence or a line per
|
|
738
|
+
* step, and a wall of `· Bash` says as little as silence did. This keeps one
|
|
739
|
+
* line and rewrites it: the spinner says the work is alive, the words say how
|
|
740
|
+
* far it has got, and the line is gone when the work is over, so what stays on
|
|
741
|
+
* the screen is the report.
|
|
742
|
+
*
|
|
743
|
+
* What the line counts is the command's and not this module's business. A
|
|
744
|
+
* review counts tools, a sweep counts pull requests, and a command reading two
|
|
745
|
+
* guards counts nothing at all - and a screen that worded any of them here
|
|
746
|
+
* would need the words a command already has.
|
|
747
|
+
*
|
|
748
|
+
* Where there is no screen to measure - a pipe, a CI log, a test - a rewritten
|
|
749
|
+
* line would be a mess of half-drawn ones, so nothing is drawn. What goes out
|
|
750
|
+
* instead is whatever `aside` the command gives, one to a line, and where it
|
|
751
|
+
* gives none the output is what it was before there was a heartbeat at all.
|
|
752
|
+
* `columns` is zero exactly there.
|
|
753
|
+
*/
|
|
754
|
+
const beating = Effect.fnUntraced(function* (from, use) {
|
|
755
|
+
const terminal = yield* Terminal.Terminal;
|
|
756
|
+
const columns = yield* terminal.columns;
|
|
757
|
+
if (columns === 0) return yield* use((_, aside) => aside === void 0 ? Effect.void : Console.log(aside));
|
|
758
|
+
const started = yield* Clock.currentTimeMillis;
|
|
759
|
+
const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
|
|
760
|
+
let reads = from;
|
|
761
|
+
let at = 0;
|
|
762
|
+
/** The line as it stands: this frame of the spinner, and the latest wording. */
|
|
763
|
+
const paint = Effect.flatMap(Clock.currentTimeMillis, (now) => draw(`${frames[at % frames.length]} ${reads(elapsed(now - started))}`));
|
|
764
|
+
const says = (next) => Effect.andThen(Effect.sync(() => void (reads = next)), paint);
|
|
765
|
+
yield* paint;
|
|
766
|
+
const beat = yield* Effect.forkChild(Effect.gen(function* () {
|
|
767
|
+
for (;;) {
|
|
768
|
+
yield* Effect.sleep(frameFor);
|
|
769
|
+
at = at + 1;
|
|
770
|
+
yield* paint;
|
|
771
|
+
}
|
|
772
|
+
}));
|
|
773
|
+
return yield* Effect.onExit(use(says), () => Effect.flatMap(Fiber.interrupt(beat), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
|
|
774
|
+
});
|
|
660
775
|
new TextEncoder();
|
|
661
776
|
/** A program that ran but ended badly. */
|
|
662
777
|
var CommandFailed = class extends Schema.TaggedError()("CommandFailed", {
|
|
@@ -712,6 +827,14 @@ const git = (args) => capture("git", args).pipe(Effect.catchTags({
|
|
|
712
827
|
detail: error.stderr
|
|
713
828
|
}))
|
|
714
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"}`;
|
|
715
838
|
/** A fix worktree that still holds work of mine, which nothing may cut away. */
|
|
716
839
|
var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
|
|
717
840
|
directory: Schema.String,
|
|
@@ -739,42 +862,55 @@ var WorktreeHeld = class extends Schema.TaggedError()("WorktreeHeld", {
|
|
|
739
862
|
* The head comes from the pull request's ref rather than from what a sweep last
|
|
740
863
|
* saw, so what is cut is the commit the run really reads.
|
|
741
864
|
*/
|
|
865
|
+
/**
|
|
866
|
+
* How the heartbeat of a cut reads.
|
|
867
|
+
*
|
|
868
|
+
* The stages are named apart because a first clone and a hundredth fetch take
|
|
869
|
+
* wildly different times, and the line is what explains the difference: a
|
|
870
|
+
* `cloning` that sits there for two minutes is a large repository arriving
|
|
871
|
+
* once, not a tool that has hung.
|
|
872
|
+
*/
|
|
873
|
+
const cutting = (what, repo) => (since) => `${what} ${repo} · ${since}`;
|
|
742
874
|
const whereToCut = Effect.fn("git.whereToCut")(function* (repo, number, cut) {
|
|
743
|
-
const
|
|
744
|
-
const
|
|
745
|
-
const clone = path.join(state, clonesIn, `${repo}.git`);
|
|
746
|
-
if ((yield* Effect.orElseSucceed(git([
|
|
875
|
+
const clone = yield* cloneAt(repo);
|
|
876
|
+
const bare = yield* Effect.orElseSucceed(git([
|
|
747
877
|
"-C",
|
|
748
878
|
clone,
|
|
749
879
|
"rev-parse",
|
|
750
880
|
"--is-bare-repository"
|
|
751
|
-
]), () => "")
|
|
752
|
-
|
|
753
|
-
"--bare",
|
|
754
|
-
"--filter=blob:none",
|
|
755
|
-
`https://github.com/${repo}.git`,
|
|
756
|
-
clone
|
|
757
|
-
]);
|
|
758
|
-
const pullRef = `refs/dw-mc/pr/${number}`;
|
|
759
|
-
yield* git([
|
|
760
|
-
"-C",
|
|
761
|
-
clone,
|
|
762
|
-
"fetch",
|
|
763
|
-
"--no-tags",
|
|
764
|
-
"--force",
|
|
765
|
-
"origin",
|
|
766
|
-
`+refs/pull/${number}/head:${pullRef}`,
|
|
767
|
-
"+refs/heads/*:refs/heads/*"
|
|
768
|
-
]);
|
|
881
|
+
]), () => "");
|
|
882
|
+
const ref = pullRef(number);
|
|
769
883
|
return {
|
|
770
884
|
clone,
|
|
771
|
-
head: yield*
|
|
772
|
-
"
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
885
|
+
head: yield* beating(cutting(bare === "true" ? "fetching" : "cloning", repo), (says) => Effect.gen(function* () {
|
|
886
|
+
if (bare !== "true") {
|
|
887
|
+
yield* git([
|
|
888
|
+
"clone",
|
|
889
|
+
"--bare",
|
|
890
|
+
"--filter=blob:none",
|
|
891
|
+
`https://github.com/${repo}.git`,
|
|
892
|
+
clone
|
|
893
|
+
]);
|
|
894
|
+
yield* says(cutting("fetching", repo));
|
|
895
|
+
}
|
|
896
|
+
yield* git([
|
|
897
|
+
"-C",
|
|
898
|
+
clone,
|
|
899
|
+
"fetch",
|
|
900
|
+
"--no-tags",
|
|
901
|
+
"--force",
|
|
902
|
+
"origin",
|
|
903
|
+
`+refs/pull/${number}/head:${ref}`,
|
|
904
|
+
"+refs/heads/*:refs/heads/*"
|
|
905
|
+
]);
|
|
906
|
+
return yield* git([
|
|
907
|
+
"-C",
|
|
908
|
+
clone,
|
|
909
|
+
"rev-parse",
|
|
910
|
+
ref
|
|
911
|
+
]);
|
|
912
|
+
})),
|
|
913
|
+
directory: yield* cutAt(cut, repo, number)
|
|
778
914
|
};
|
|
779
915
|
});
|
|
780
916
|
/**
|
|
@@ -798,7 +934,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
|
|
|
798
934
|
"--force",
|
|
799
935
|
directory
|
|
800
936
|
]));
|
|
801
|
-
return yield* Effect.acquireUseRelease(Effect.flatMap(remove, () => git([
|
|
937
|
+
return yield* Effect.acquireUseRelease(beating(cutting("cutting a worktree of", repo), () => Effect.flatMap(remove, () => git([
|
|
802
938
|
"-C",
|
|
803
939
|
clone,
|
|
804
940
|
"worktree",
|
|
@@ -806,7 +942,7 @@ const withWorktree = Effect.fn("git.withWorktree")(function* (repo, number, use)
|
|
|
806
942
|
"--detach",
|
|
807
943
|
directory,
|
|
808
944
|
head
|
|
809
|
-
])), () => use({
|
|
945
|
+
]))), () => use({
|
|
810
946
|
directory,
|
|
811
947
|
head
|
|
812
948
|
}), () => remove);
|
|
@@ -935,11 +1071,11 @@ const reuseResolutions = Effect.fn("git.reuseResolutions")(function* (clone) {
|
|
|
935
1071
|
*/
|
|
936
1072
|
const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, number, prBranch, session) {
|
|
937
1073
|
const { clone, directory, head } = yield* whereToCut(repo, number, under[session]);
|
|
938
|
-
const branch =
|
|
1074
|
+
const branch = sessionBranch(session, number);
|
|
939
1075
|
const ahead = yield* aheadOf(clone, branch, head);
|
|
940
1076
|
if (ahead > 0) return yield* new WorktreeHeld({
|
|
941
1077
|
directory,
|
|
942
|
-
detail: `The last fix session on ${repo}#${number} left ${ahead}
|
|
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.`
|
|
943
1079
|
});
|
|
944
1080
|
if ((yield* worktreesOf(clone)).includes(directory)) yield* git([
|
|
945
1081
|
"-C",
|
|
@@ -950,7 +1086,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
|
|
|
950
1086
|
]);
|
|
951
1087
|
yield* perWorktreeConfig(clone);
|
|
952
1088
|
if (session === "rebase") yield* reuseResolutions(clone);
|
|
953
|
-
yield* git([
|
|
1089
|
+
yield* beating(cutting("cutting a worktree of", repo), () => git([
|
|
954
1090
|
"-C",
|
|
955
1091
|
clone,
|
|
956
1092
|
"worktree",
|
|
@@ -959,7 +1095,7 @@ const standingWorktree = Effect.fn("git.standingWorktree")(function* (repo, numb
|
|
|
959
1095
|
branch,
|
|
960
1096
|
directory,
|
|
961
1097
|
head
|
|
962
|
-
]);
|
|
1098
|
+
]));
|
|
963
1099
|
yield* git([
|
|
964
1100
|
"-C",
|
|
965
1101
|
clone,
|
|
@@ -1181,11 +1317,9 @@ const clear = { _tag: "clear" };
|
|
|
1181
1317
|
* pruned or moved by hand still leaves the branch holding the commits.
|
|
1182
1318
|
*/
|
|
1183
1319
|
const holding = Effect.fn("git.holding")(function* (repo, number, session) {
|
|
1184
|
-
const
|
|
1185
|
-
const
|
|
1186
|
-
const
|
|
1187
|
-
const directory = path.join(state, under[session], repo, String(number));
|
|
1188
|
-
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);
|
|
1189
1323
|
if ((yield* Effect.orElseSucceed(git([
|
|
1190
1324
|
"-C",
|
|
1191
1325
|
directory,
|
|
@@ -1208,7 +1342,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
|
|
|
1208
1342
|
"-C",
|
|
1209
1343
|
clone,
|
|
1210
1344
|
"rev-parse",
|
|
1211
|
-
|
|
1345
|
+
pullRef(number)
|
|
1212
1346
|
]), () => "");
|
|
1213
1347
|
if (head.trim() === "") return {
|
|
1214
1348
|
_tag: "held",
|
|
@@ -1217,7 +1351,7 @@ const holding = Effect.fn("git.holding")(function* (repo, number, session) {
|
|
|
1217
1351
|
const ahead = yield* aheadOf(clone, branch, head.trim());
|
|
1218
1352
|
return ahead === 0 ? clear : {
|
|
1219
1353
|
_tag: "held",
|
|
1220
|
-
detail: `${ahead}
|
|
1354
|
+
detail: `${commits(ahead)} that the pull request's head does not have`
|
|
1221
1355
|
};
|
|
1222
1356
|
});
|
|
1223
1357
|
/**
|
|
@@ -1482,7 +1616,7 @@ const lines$3 = (it, state, path, paint) => {
|
|
|
1482
1616
|
const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
|
|
1483
1617
|
const path = yield* Path.Path;
|
|
1484
1618
|
const paint = yield* Paint;
|
|
1485
|
-
const found = yield* inventory;
|
|
1619
|
+
const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
|
|
1486
1620
|
const it = plan(found);
|
|
1487
1621
|
if (empty(it)) {
|
|
1488
1622
|
yield* Console.log(`Nothing to take back in ${found.directory}.`);
|
|
@@ -1914,6 +2048,40 @@ const prConversation = Effect.fnUntraced(function* (repo, number) {
|
|
|
1914
2048
|
}], ...threads];
|
|
1915
2049
|
});
|
|
1916
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
|
|
1917
2085
|
//#region src/domain/moment.ts
|
|
1918
2086
|
const isLater = Order.isGreaterThan(DateTime.Order);
|
|
1919
2087
|
/** Whether `self` happened after `other`, counting never as before anything. */
|
|
@@ -1925,7 +2093,15 @@ const isSame = (self, other) => self === null || other === null ? self === other
|
|
|
1925
2093
|
/** The latest of many, or never when there are none. */
|
|
1926
2094
|
const newest = (moments) => moments.reduce(later, null);
|
|
1927
2095
|
//#endregion
|
|
1928
|
-
//#region src/
|
|
2096
|
+
//#region src/terms/pr.ts
|
|
2097
|
+
/**
|
|
2098
|
+
* What GitHub says about a pull request, in this tool's words.
|
|
2099
|
+
*
|
|
2100
|
+
* The three of them are here because both sides need the same one: `gh` and the
|
|
2101
|
+
* checks adapter answer in these words, and the bucket rules decide on them. A
|
|
2102
|
+
* union restated on each side is a case that goes unreachable the day the other
|
|
2103
|
+
* side gains a member.
|
|
2104
|
+
*/
|
|
1929
2105
|
/** How far GitHub has got towards letting a tracked PR merge. */
|
|
1930
2106
|
const Mergeability = Schema.Literals([
|
|
1931
2107
|
"mergeable",
|
|
@@ -1946,6 +2122,8 @@ const ChecksState = Schema.Literals([
|
|
|
1946
2122
|
"pending",
|
|
1947
2123
|
"none"
|
|
1948
2124
|
]);
|
|
2125
|
+
//#endregion
|
|
2126
|
+
//#region src/domain/bucket.ts
|
|
1949
2127
|
/**
|
|
1950
2128
|
* Everything the bucket rules are allowed to know about a tracked PR.
|
|
1951
2129
|
*
|
|
@@ -2002,6 +2180,15 @@ const order = [
|
|
|
2002
2180
|
*/
|
|
2003
2181
|
const unanswered = "a comment I have not answered";
|
|
2004
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
|
+
/**
|
|
2005
2192
|
* The first of the rules that makes a PR mine to move, or null when none
|
|
2006
2193
|
* does. The order is the order I would fix them in: a conflict makes every
|
|
2007
2194
|
* other signal on the PR stale, and a red build is worth more than a comment.
|
|
@@ -2011,7 +2198,7 @@ const needsMe = (facts) => {
|
|
|
2011
2198
|
if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
|
|
2012
2199
|
if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
|
|
2013
2200
|
if (facts.reviewDecision === "changes-requested") return "changes requested";
|
|
2014
|
-
if (facts.blockingFindings > 0) return
|
|
2201
|
+
if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
|
|
2015
2202
|
if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
|
|
2016
2203
|
return null;
|
|
2017
2204
|
};
|
|
@@ -2121,2036 +2308,2084 @@ const resolve$1 = (text, registered) => {
|
|
|
2121
2308
|
};
|
|
2122
2309
|
};
|
|
2123
2310
|
//#endregion
|
|
2124
|
-
//#region src/
|
|
2125
|
-
/**
|
|
2126
|
-
const
|
|
2127
|
-
/**
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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"
|
|
2132
2342
|
};
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
|
2137
2352
|
};
|
|
2353
|
+
/** One problem a review run reports, at a file and line. */
|
|
2354
|
+
const Finding = Schema.Struct({
|
|
2355
|
+
...shared,
|
|
2356
|
+
severity: Severity
|
|
2357
|
+
});
|
|
2138
2358
|
/**
|
|
2139
|
-
*
|
|
2140
|
-
*
|
|
2141
|
-
* Every guard in the tool answers the same shape - the sentence saying why not,
|
|
2142
|
-
* or null - so turning that answer into a refusal is spelled once here rather
|
|
2143
|
-
* 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.
|
|
2144
2361
|
*/
|
|
2145
|
-
const
|
|
2362
|
+
const Findings = Schema.Struct({
|
|
2363
|
+
verdict: Verdict,
|
|
2364
|
+
findings: Schema.Array(Finding)
|
|
2365
|
+
});
|
|
2146
2366
|
/**
|
|
2147
|
-
*
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
* A command that reads these rather than GitHub says what the table said: the
|
|
2151
|
-
* stamp and the cutoff a conversation is measured against are both computed
|
|
2152
|
-
* from the facts a sweep wrote down, and asking GitHub again would make them a
|
|
2153
|
-
* 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.
|
|
2154
2369
|
*
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
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.
|
|
2157
2372
|
*/
|
|
2158
|
-
const
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2373
|
+
const Reported = Schema.Struct({
|
|
2374
|
+
verdict: Verdict,
|
|
2375
|
+
findings: Schema.Array(Schema.Struct({
|
|
2376
|
+
...shared,
|
|
2377
|
+
severity: Weighed
|
|
2378
|
+
}))
|
|
2163
2379
|
});
|
|
2164
|
-
//#endregion
|
|
2165
|
-
//#region src/cli/row.ts
|
|
2166
2380
|
/**
|
|
2167
|
-
*
|
|
2168
|
-
*
|
|
2169
|
-
* The table `dw-mc status` prints and the list the picker asks me to choose
|
|
2170
|
-
* from are the same rows, so a pull request reads the same in both and neither
|
|
2171
|
-
* command owns how the other draws it.
|
|
2381
|
+
* The schema every runner must satisfy, as the JSON Schema a runner is handed.
|
|
2172
2382
|
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2175
|
-
*
|
|
2176
|
-
*
|
|
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.
|
|
2388
|
+
*/
|
|
2389
|
+
const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
|
|
2390
|
+
/**
|
|
2391
|
+
* The findings as the Markdown a report is written in.
|
|
2177
2392
|
*
|
|
2178
|
-
*
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
* - 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.
|
|
2182
2396
|
*/
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
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
|
|
2189
2403
|
};
|
|
2190
2404
|
/**
|
|
2191
|
-
* The
|
|
2405
|
+
* The findings that withhold the stamp: everything at `blocksOn` or above it.
|
|
2192
2406
|
*
|
|
2193
|
-
*
|
|
2194
|
-
*
|
|
2195
|
-
*
|
|
2196
|
-
* much of the pull request is done, so the column reads at a glance even where
|
|
2197
|
-
* 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.
|
|
2198
2410
|
*/
|
|
2199
|
-
const
|
|
2200
|
-
"needs-me": "●",
|
|
2201
|
-
"needs-review-run": "◐",
|
|
2202
|
-
"waiting-on-others": "○",
|
|
2203
|
-
ready: "◆"
|
|
2204
|
-
};
|
|
2205
|
-
/** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
|
|
2206
|
-
const tint = (paint, bucket) => ({
|
|
2207
|
-
"needs-me": paint.red,
|
|
2208
|
-
"needs-review-run": paint.yellow,
|
|
2209
|
-
"waiting-on-others": paint.dim,
|
|
2210
|
-
ready: paint.green
|
|
2211
|
-
})[bucket];
|
|
2212
|
-
/** What sits between two columns: three columns of prose run into one another without a rule. */
|
|
2213
|
-
const rule = " │ ";
|
|
2214
|
-
/**
|
|
2215
|
-
* One row: which pull request, what it is, and what it waits on.
|
|
2216
|
-
*
|
|
2217
|
-
* A stamp is a mark beside the pull request rather than a column of its own, so
|
|
2218
|
-
* a table where nothing is stamped is exactly the table it was before: the
|
|
2219
|
-
* stamp is a thing I look for, not a thing I read every row of.
|
|
2220
|
-
*
|
|
2221
|
-
* The title is the only cell with give in it, so how much room it gets is the
|
|
2222
|
-
* caller's to say: a table printed down the screen can afford a whole commit
|
|
2223
|
-
* subject, and a row inside a prompt has a column more to carry and a frame
|
|
2224
|
-
* around it.
|
|
2225
|
-
*
|
|
2226
|
-
* A named lead carries the colour for the whole row. It is the one place a
|
|
2227
|
-
* prompt's row is coloured, and it carries no link at all: a prompt counts the
|
|
2228
|
-
* lines it has to erase from the length of what it drew, escape sequences and
|
|
2229
|
-
* all, so every colour on a row costs the title characters it could have shown,
|
|
2230
|
-
* and a link costs it the whole URL. The table has no such arithmetic to keep
|
|
2231
|
-
* straight, so its rows say it in more than one place and open the pull request
|
|
2232
|
-
* besides.
|
|
2233
|
-
*/
|
|
2234
|
-
const cells = (placed, stamped, room, paint, lead) => {
|
|
2235
|
-
const { facts } = placed;
|
|
2236
|
-
const { bucket } = placed.placement;
|
|
2237
|
-
const say = tint(paint, bucket);
|
|
2238
|
-
const reference = `${facts.repo}#${facts.number}`;
|
|
2239
|
-
const named = lead === "named";
|
|
2240
|
-
const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
|
|
2241
|
-
return named ? [
|
|
2242
|
-
say(`${marker[bucket]} ${heading[bucket]}`),
|
|
2243
|
-
pr,
|
|
2244
|
-
truncate(facts.title, room),
|
|
2245
|
-
placed.placement.reason
|
|
2246
|
-
] : [
|
|
2247
|
-
`${say(marker[bucket])} ${pr}`,
|
|
2248
|
-
paint.dim(truncate(facts.title, room)),
|
|
2249
|
-
say(placed.placement.reason)
|
|
2250
|
-
];
|
|
2251
|
-
};
|
|
2411
|
+
const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
|
|
2252
2412
|
//#endregion
|
|
2253
|
-
//#region src/
|
|
2413
|
+
//#region src/domain/review.ts
|
|
2254
2414
|
/**
|
|
2255
|
-
* What
|
|
2256
|
-
*
|
|
2257
|
-
*
|
|
2415
|
+
* What a review run came to, which is what its second turn reported.
|
|
2416
|
+
*
|
|
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.
|
|
2258
2420
|
*/
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
"STARTUP_FAILURE",
|
|
2264
|
-
"ACTION_REQUIRED",
|
|
2265
|
-
"ERROR"
|
|
2266
|
-
]);
|
|
2267
|
-
const running = /* @__PURE__ */ new Set([
|
|
2268
|
-
"QUEUED",
|
|
2269
|
-
"IN_PROGRESS",
|
|
2270
|
-
"WAITING",
|
|
2271
|
-
"PENDING",
|
|
2272
|
-
"REQUESTED",
|
|
2273
|
-
"EXPECTED"
|
|
2274
|
-
]);
|
|
2275
|
-
const nameOf = (entry) => entry.name ?? entry.context ?? "";
|
|
2276
|
-
const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
|
|
2277
|
-
const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
|
|
2421
|
+
const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
|
|
2422
|
+
verdict: Verdict,
|
|
2423
|
+
findings: Schema.Array(Finding)
|
|
2424
|
+
}), Schema.TaggedStruct("failed", { detail: Schema.String })]);
|
|
2278
2425
|
/**
|
|
2279
|
-
*
|
|
2280
|
-
* nothing has failed yet, green when every check that counts has passed.
|
|
2426
|
+
* One review run against a tracked PR at a specific head commit.
|
|
2281
2427
|
*
|
|
2282
|
-
*
|
|
2283
|
-
*
|
|
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.
|
|
2284
2431
|
*/
|
|
2285
|
-
const
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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);
|
|
2292
2456
|
/**
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2295
|
-
* `ci.ignore` is applied here as well as in the rollup: a check that cannot
|
|
2296
|
-
* hold a PR out of Ready is not one the classifier should be explaining either.
|
|
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.
|
|
2297
2459
|
*/
|
|
2298
|
-
const
|
|
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`;
|
|
2299
2463
|
/**
|
|
2300
|
-
*
|
|
2464
|
+
* Which head a pull request was last reviewed at: an index beside `runKey` and
|
|
2465
|
+
* `reportKey` rather than a thing the glossary names.
|
|
2301
2466
|
*
|
|
2302
|
-
* A
|
|
2303
|
-
*
|
|
2304
|
-
* the
|
|
2305
|
-
*
|
|
2306
|
-
* there is no log of ours to read and no run of ours to re-run.
|
|
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.
|
|
2307
2471
|
*/
|
|
2308
|
-
const
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
run: found[1],
|
|
2312
|
-
job: found[2]
|
|
2313
|
-
};
|
|
2314
|
-
};
|
|
2315
|
-
const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
|
|
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`;
|
|
2316
2475
|
/**
|
|
2317
|
-
* The
|
|
2318
|
-
*
|
|
2319
|
-
*
|
|
2476
|
+
* The run at one head, or none where nothing has reviewed it.
|
|
2477
|
+
*
|
|
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.
|
|
2481
|
+
*
|
|
2482
|
+
* Forgetting a run costs one review.
|
|
2320
2483
|
*/
|
|
2321
|
-
const
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
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);
|
|
2329
2493
|
});
|
|
2330
|
-
const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
|
|
2331
|
-
/** How far back to look for a run that reached a verdict at all. */
|
|
2332
|
-
const recentRuns = 5;
|
|
2333
|
-
/** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
|
|
2334
|
-
const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
|
|
2335
|
-
/** A run that decided something. A skipped or cancelled run says nothing either way. */
|
|
2336
|
-
const verdicts = /* @__PURE__ */ new Set([
|
|
2337
|
-
"failure",
|
|
2338
|
-
"timed_out",
|
|
2339
|
-
"success"
|
|
2340
|
-
]);
|
|
2341
2494
|
/**
|
|
2342
|
-
*
|
|
2495
|
+
* What a run reported, or null where it reported nothing at all.
|
|
2343
2496
|
*
|
|
2344
|
-
*
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
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.
|
|
2348
2501
|
*/
|
|
2349
|
-
const
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
"--repo",
|
|
2354
|
-
repo,
|
|
2355
|
-
"--branch",
|
|
2356
|
-
branch,
|
|
2357
|
-
"--workflow",
|
|
2358
|
-
workflow,
|
|
2359
|
-
"--limit",
|
|
2360
|
-
String(recentRuns),
|
|
2361
|
-
"--json",
|
|
2362
|
-
"conclusion"
|
|
2363
|
-
], Runs)).find((run) => verdicts.has(run.conclusion));
|
|
2364
|
-
return newest !== void 0 && failedRun.has(newest.conclusion);
|
|
2365
|
-
});
|
|
2366
|
-
const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
|
|
2367
|
-
/** The repository paths a pull request changes. */
|
|
2368
|
-
const prFiles = Effect.fnUntraced(function* (repo, number) {
|
|
2369
|
-
return (yield* readJson("pr view files", "gh", [
|
|
2370
|
-
"pr",
|
|
2371
|
-
"view",
|
|
2372
|
-
String(number),
|
|
2373
|
-
"--repo",
|
|
2374
|
-
repo,
|
|
2375
|
-
"--json",
|
|
2376
|
-
"files"
|
|
2377
|
-
], PrFiles)).files.map((file) => file.path);
|
|
2378
|
-
});
|
|
2502
|
+
const reportedBy = (run) => run.outcome._tag === "reported" ? {
|
|
2503
|
+
verdict: run.outcome.verdict,
|
|
2504
|
+
findings: run.outcome.findings
|
|
2505
|
+
} : null;
|
|
2379
2506
|
/**
|
|
2380
|
-
*
|
|
2507
|
+
* Why a run reported nothing, or null where it reported.
|
|
2381
2508
|
*
|
|
2382
|
-
*
|
|
2383
|
-
*
|
|
2384
|
-
*
|
|
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.
|
|
2385
2512
|
*/
|
|
2386
|
-
const
|
|
2513
|
+
const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
|
|
2387
2514
|
/**
|
|
2388
|
-
*
|
|
2515
|
+
* Whether the files changed since the last run are worth paying for another.
|
|
2389
2516
|
*
|
|
2390
|
-
*
|
|
2391
|
-
*
|
|
2392
|
-
*
|
|
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.
|
|
2393
2520
|
*/
|
|
2394
|
-
const
|
|
2395
|
-
const log = yield* capture("gh", [
|
|
2396
|
-
"api",
|
|
2397
|
-
`repos/${repo}/actions/jobs/${jobId}/logs`,
|
|
2398
|
-
"--allow-escape-sequences"
|
|
2399
|
-
]).pipe(Effect.catchTags({
|
|
2400
|
-
PlatformError: (error) => Effect.fail(unavailable(error)),
|
|
2401
|
-
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
2402
|
-
command: "api job logs",
|
|
2403
|
-
detail: error.stderr
|
|
2404
|
-
}))
|
|
2405
|
-
}));
|
|
2406
|
-
return log.length <= logTailBytes ? log : log.slice(-65536);
|
|
2407
|
-
});
|
|
2521
|
+
const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
|
|
2408
2522
|
/**
|
|
2409
|
-
* The
|
|
2410
|
-
*
|
|
2411
|
-
* One broken run usually fails several jobs, and re-running it once per failing
|
|
2412
|
-
* 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.
|
|
2413
2524
|
*
|
|
2414
|
-
*
|
|
2415
|
-
*
|
|
2416
|
-
*
|
|
2417
|
-
*
|
|
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.
|
|
2418
2532
|
*/
|
|
2419
|
-
const
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
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(" ");
|
|
2423
2540
|
/**
|
|
2424
|
-
*
|
|
2541
|
+
* The report as it is written down: what it is of, then what the run said.
|
|
2425
2542
|
*
|
|
2426
|
-
*
|
|
2427
|
-
*
|
|
2428
|
-
*
|
|
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.
|
|
2429
2546
|
*/
|
|
2430
|
-
const
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
2441
|
-
command: "run rerun",
|
|
2442
|
-
detail: error.stderr
|
|
2443
|
-
}))
|
|
2444
|
-
}));
|
|
2445
|
-
});
|
|
2446
|
-
//#endregion
|
|
2447
|
-
//#region src/domain/flaky.ts
|
|
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");
|
|
2448
2557
|
/**
|
|
2449
|
-
*
|
|
2450
|
-
* runner giving up, never a test disagreeing with the code.
|
|
2558
|
+
* Whether `head` has the review it needs.
|
|
2451
2559
|
*
|
|
2452
|
-
*
|
|
2453
|
-
*
|
|
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.
|
|
2454
2563
|
*/
|
|
2455
|
-
const
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
"connection refused",
|
|
2462
|
-
"socket hang up",
|
|
2463
|
-
"lock timeout",
|
|
2464
|
-
"could not obtain lock",
|
|
2465
|
-
"runner lost communication",
|
|
2466
|
-
"The runner has received a shutdown signal",
|
|
2467
|
-
"net/http: request canceled",
|
|
2468
|
-
"ResourceExhausted",
|
|
2469
|
-
"Too many open files",
|
|
2470
|
-
"no space left on device"
|
|
2471
|
-
];
|
|
2472
|
-
const baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
|
|
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);
|
|
2569
|
+
};
|
|
2473
2570
|
/**
|
|
2474
|
-
*
|
|
2571
|
+
* What the review runs on `head` say about it, for the stamp to rest on.
|
|
2475
2572
|
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
/**
|
|
2482
|
-
* Whether the log names a file called `base` rather than some longer name
|
|
2483
|
-
* ending in it: a changed `src/a.ts` is not what a log printing `data.ts` is
|
|
2484
|
-
* complaining about.
|
|
2485
|
-
*/
|
|
2486
|
-
const namesFile = (log, base) => new RegExp(`(^|[^\\w.-])${escaped(base)}`).test(log);
|
|
2487
|
-
const namedChangedFile = (log, changedFiles) => changedFiles.find((file) => log.includes(file)) ?? changedFiles.find((file) => namesFile(log, baseName(file))) ?? null;
|
|
2488
|
-
/**
|
|
2489
|
-
* The flaky pattern the log matches, mine before the built-in ones.
|
|
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.
|
|
2490
2578
|
*
|
|
2491
|
-
*
|
|
2492
|
-
*
|
|
2493
|
-
*
|
|
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.
|
|
2494
2582
|
*/
|
|
2495
|
-
const
|
|
2496
|
-
const
|
|
2497
|
-
return
|
|
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
|
|
2588
|
+
};
|
|
2589
|
+
});
|
|
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) }));
|
|
2498
2604
|
};
|
|
2499
2605
|
/**
|
|
2500
|
-
*
|
|
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.
|
|
2501
2608
|
*
|
|
2502
|
-
*
|
|
2503
|
-
*
|
|
2504
|
-
*
|
|
2505
|
-
*
|
|
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.
|
|
2506
2614
|
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
* is the one I can recover from.
|
|
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.
|
|
2511
2618
|
*/
|
|
2512
|
-
const
|
|
2513
|
-
const
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
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);
|
|
2521
|
-
return excuses.length === 0 ? {
|
|
2522
|
-
classification: "legitimate",
|
|
2523
|
-
reason: "nothing explains the failure"
|
|
2524
|
-
} : {
|
|
2525
|
-
classification: "flaky",
|
|
2526
|
-
reason: excuses.join(", and ")
|
|
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());
|
|
2622
|
+
return {
|
|
2623
|
+
repo,
|
|
2624
|
+
number,
|
|
2625
|
+
settings: settingsFor(file, repo),
|
|
2626
|
+
launcher: launcherOf(file)
|
|
2527
2627
|
};
|
|
2528
|
-
};
|
|
2628
|
+
});
|
|
2529
2629
|
/**
|
|
2530
|
-
*
|
|
2630
|
+
* A domain guard's word, as the command's own failure.
|
|
2531
2631
|
*
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
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.
|
|
2534
2635
|
*/
|
|
2535
|
-
const
|
|
2536
|
-
/** The values of `xs` that `f` has one for. */
|
|
2537
|
-
const filterMap = (xs, f) => xs.flatMap((x) => {
|
|
2538
|
-
const b = f(x);
|
|
2539
|
-
return b === null ? [] : [b];
|
|
2540
|
-
});
|
|
2541
|
-
/** No evidence at all, which is what an unreadable CI comes to. */
|
|
2542
|
-
const nothing$1 = {
|
|
2543
|
-
alsoRedOnDefaultBranch: [],
|
|
2544
|
-
changedFiles: [],
|
|
2545
|
-
log: ""
|
|
2546
|
-
};
|
|
2636
|
+
const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
|
|
2547
2637
|
/**
|
|
2548
|
-
* What
|
|
2638
|
+
* What the last sweep learned about one pull request, or the sentence sending
|
|
2639
|
+
* me to a sweep.
|
|
2549
2640
|
*
|
|
2550
|
-
* A
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
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.
|
|
2555
2648
|
*/
|
|
2556
|
-
const
|
|
2557
|
-
const
|
|
2558
|
-
const
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
if (branch === null) return nothing$1;
|
|
2562
|
-
const [alsoRed, changedFiles, logs] = yield* Effect.all([
|
|
2563
|
-
Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
|
|
2564
|
-
Effect.orElseSucceed(prFiles(repo, number), () => []),
|
|
2565
|
-
Effect.forEach(jobs, (job) => Effect.orElseSucceed(jobLog(repo, job), () => ""))
|
|
2566
|
-
], { concurrency: 3 });
|
|
2567
|
-
return {
|
|
2568
|
-
alsoRedOnDefaultBranch: alsoRed.flat(),
|
|
2569
|
-
changedFiles,
|
|
2570
|
-
log: logs.join("\n")
|
|
2571
|
-
};
|
|
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;
|
|
2572
2654
|
});
|
|
2573
2655
|
/**
|
|
2574
|
-
*
|
|
2656
|
+
* The review run whose findings are the current ones, or the sentence saying
|
|
2657
|
+
* there are none.
|
|
2575
2658
|
*
|
|
2576
|
-
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2579
|
-
*
|
|
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.
|
|
2580
2663
|
*/
|
|
2581
|
-
const
|
|
2582
|
-
const
|
|
2583
|
-
|
|
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;
|
|
2584
2668
|
});
|
|
2669
|
+
/**
|
|
2670
|
+
* The guard reads of one command, under a heartbeat.
|
|
2671
|
+
*
|
|
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.
|
|
2681
|
+
*/
|
|
2682
|
+
const reading = (where, read) => beating((since) => `reading ${where} · ${since}`, () => read);
|
|
2585
2683
|
//#endregion
|
|
2586
|
-
//#region src/
|
|
2587
|
-
/** The pulse of a PR a previous sweep recorded. */
|
|
2588
|
-
const pulseOf = (facts) => ({
|
|
2589
|
-
head: facts.head,
|
|
2590
|
-
checks: facts.checks,
|
|
2591
|
-
newestHumanCommentAt: facts.newestHumanCommentAt
|
|
2592
|
-
});
|
|
2684
|
+
//#region src/cli/row.ts
|
|
2593
2685
|
/**
|
|
2594
|
-
*
|
|
2686
|
+
* How one tracked PR is written down, wherever it is written down.
|
|
2595
2687
|
*
|
|
2596
|
-
*
|
|
2597
|
-
*
|
|
2598
|
-
|
|
2599
|
-
const isQuiet = (previous, current) => previous.head === current.head && previous.checks === current.checks && isSame(previous.newestHumanCommentAt, current.newestHumanCommentAt);
|
|
2600
|
-
//#endregion
|
|
2601
|
-
//#region src/domain/rebase.ts
|
|
2602
|
-
/**
|
|
2603
|
-
* How many pull requests this one stands on.
|
|
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.
|
|
2604
2691
|
*
|
|
2605
|
-
*
|
|
2606
|
-
*
|
|
2607
|
-
*
|
|
2608
|
-
*
|
|
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.
|
|
2609
2701
|
*/
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
seen.add(parent.number);
|
|
2617
|
-
count += 1;
|
|
2618
|
-
current = parent;
|
|
2619
|
-
}
|
|
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"
|
|
2620
2708
|
};
|
|
2621
2709
|
/**
|
|
2622
|
-
*
|
|
2710
|
+
* The mark that says which bucket a row is in without being read.
|
|
2623
2711
|
*
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
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.
|
|
2627
2717
|
*/
|
|
2628
|
-
const
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
}
|
|
2634
|
-
return deepest;
|
|
2718
|
+
const marker = {
|
|
2719
|
+
"needs-me": "●",
|
|
2720
|
+
"needs-review-run": "◐",
|
|
2721
|
+
"waiting-on-others": "○",
|
|
2722
|
+
ready: "◆"
|
|
2635
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 = " │ ";
|
|
2636
2733
|
/**
|
|
2637
|
-
*
|
|
2734
|
+
* One row: which pull request, what it is, and what it waits on.
|
|
2638
2735
|
*
|
|
2639
|
-
* A
|
|
2640
|
-
*
|
|
2641
|
-
*
|
|
2642
|
-
*
|
|
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.
|
|
2739
|
+
*
|
|
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.
|
|
2643
2752
|
*/
|
|
2644
|
-
const
|
|
2645
|
-
const
|
|
2646
|
-
|
|
2647
|
-
const
|
|
2648
|
-
const
|
|
2649
|
-
const
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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
|
+
];
|
|
2654
2770
|
};
|
|
2771
|
+
//#endregion
|
|
2772
|
+
//#region src/domain/comments.ts
|
|
2655
2773
|
/**
|
|
2656
|
-
*
|
|
2774
|
+
* One thread's share of a strand, cut to what is worth reading.
|
|
2657
2775
|
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
2661
|
-
*
|
|
2662
|
-
*
|
|
2663
|
-
*
|
|
2664
|
-
*
|
|
2665
|
-
* stacks, so the one thing it has to say about one is where the pull request
|
|
2666
|
-
* sits in it.
|
|
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.
|
|
2780
|
+
*
|
|
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.
|
|
2667
2783
|
*/
|
|
2668
|
-
const
|
|
2669
|
-
const
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
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
|
+
}];
|
|
2675
2791
|
};
|
|
2676
2792
|
/**
|
|
2677
|
-
*
|
|
2793
|
+
* The threads worth putting on screen, given what I have already done.
|
|
2678
2794
|
*
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
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.
|
|
2682
2799
|
*
|
|
2683
|
-
*
|
|
2684
|
-
*
|
|
2685
|
-
*
|
|
2686
|
-
*
|
|
2687
|
-
* and a red build is mine to fix where it is.
|
|
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.
|
|
2688
2804
|
*/
|
|
2689
|
-
const
|
|
2690
|
-
const
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
if (situation.checks === "red") return `CI is red on ${where}, which is yours to fix before the branch moves.`;
|
|
2696
|
-
return null;
|
|
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
|
+
};
|
|
2697
2811
|
};
|
|
2812
|
+
//#endregion
|
|
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}`;
|
|
2698
2817
|
/**
|
|
2699
|
-
*
|
|
2700
|
-
* on.
|
|
2701
|
-
*
|
|
2702
|
-
* The head is what the record is scoped to, as it is for a withdrawn stamp: a
|
|
2703
|
-
* conflict is about the code the branch is at, so it lasts exactly as long as
|
|
2704
|
-
* that code is what the pull request is. A branch that moved is a branch
|
|
2705
|
-
* nothing here has tried to rebase yet.
|
|
2818
|
+
* What is true of a thread beyond where it hangs.
|
|
2706
2819
|
*
|
|
2707
|
-
*
|
|
2708
|
-
*
|
|
2709
|
-
*
|
|
2710
|
-
* still reads, and a conflict with no paths still puts the pull request in
|
|
2711
|
-
* Needs me.
|
|
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.
|
|
2712
2823
|
*/
|
|
2713
|
-
const
|
|
2714
|
-
head: Schema.String,
|
|
2715
|
-
paths: Schema.optionalKey(Schema.Array(Schema.String))
|
|
2716
|
-
});
|
|
2824
|
+
const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
|
|
2717
2825
|
/**
|
|
2718
|
-
*
|
|
2719
|
-
*
|
|
2826
|
+
* One thread as a block: where it hangs, then everybody who said something in
|
|
2827
|
+
* it, then what they said in full.
|
|
2720
2828
|
*
|
|
2721
|
-
*
|
|
2722
|
-
*
|
|
2723
|
-
*
|
|
2724
|
-
*
|
|
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.
|
|
2725
2833
|
*/
|
|
2726
|
-
const
|
|
2727
|
-
|
|
2728
|
-
const conflict = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
|
|
2729
|
-
return Option.getOrNull(conflict);
|
|
2730
|
-
});
|
|
2731
|
-
/** Writes down that a rebase of `head` conflicted on `paths`, which is the only head it holds for. */
|
|
2732
|
-
const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, number, head, paths) {
|
|
2733
|
-
yield* (yield* storeFor("rebases", Conflict)).set(prKey(repo, number), {
|
|
2734
|
-
head,
|
|
2735
|
-
paths
|
|
2736
|
-
});
|
|
2737
|
-
});
|
|
2738
|
-
//#endregion
|
|
2739
|
-
//#region src/domain/findings.ts
|
|
2740
|
-
/** Whether a review run found anything at all. */
|
|
2741
|
-
const Verdict = Schema.Literals(["clean", "findings"]);
|
|
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]);
|
|
2742
2836
|
/**
|
|
2743
|
-
*
|
|
2837
|
+
* The conversation on screen: people first, then a rule, then the bots.
|
|
2744
2838
|
*
|
|
2745
|
-
* The
|
|
2746
|
-
*
|
|
2747
|
-
*
|
|
2748
|
-
*
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
"
|
|
2758
|
-
"FYI"
|
|
2759
|
-
]);
|
|
2760
|
-
/** What each of those words weighs. The record is exhaustive, so neither list can drift. */
|
|
2761
|
-
const severityOf = {
|
|
2762
|
-
error: "error",
|
|
2763
|
-
warning: "warning",
|
|
2764
|
-
info: "info",
|
|
2765
|
-
Critical: "error",
|
|
2766
|
-
Required: "error",
|
|
2767
|
-
Optional: "warning",
|
|
2768
|
-
Nit: "info",
|
|
2769
|
-
FYI: "info"
|
|
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.
|
|
2842
|
+
*
|
|
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.
|
|
2847
|
+
*/
|
|
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]]);
|
|
2770
2852
|
};
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
}
|
|
2775
|
-
|
|
2776
|
-
const
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
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];
|
|
2780
2864
|
};
|
|
2781
|
-
/** One problem a review run reports, at a file and line. */
|
|
2782
|
-
const Finding = Schema.Struct({
|
|
2783
|
-
...shared,
|
|
2784
|
-
severity: Severity
|
|
2785
|
-
});
|
|
2786
|
-
/**
|
|
2787
|
-
* What a review run found: the shape the tool keeps, and the one a fix session
|
|
2788
|
-
* is later handed.
|
|
2789
|
-
*/
|
|
2790
|
-
const Findings = Schema.Struct({
|
|
2791
|
-
verdict: Verdict,
|
|
2792
|
-
findings: Schema.Array(Finding)
|
|
2793
|
-
});
|
|
2794
2865
|
/**
|
|
2795
|
-
* The
|
|
2796
|
-
* output is read with.
|
|
2866
|
+
* The conversation on one tracked pull request, and nothing else.
|
|
2797
2867
|
*
|
|
2798
|
-
*
|
|
2799
|
-
*
|
|
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.
|
|
2872
|
+
*
|
|
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.
|
|
2876
|
+
*
|
|
2877
|
+
* It writes nothing, here or on GitHub: no reply, no resolve, no reaction
|
|
2878
|
+
* (ADR 0002). Reading is the whole command.
|
|
2800
2879
|
*/
|
|
2801
|
-
const
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
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)}`;
|
|
2808
2912
|
/**
|
|
2809
|
-
* The
|
|
2810
|
-
*
|
|
2811
|
-
* It is derived from the schema the findings are kept under rather than written
|
|
2812
|
-
* out beside it, so a runner is asked for exactly the shape that is persisted.
|
|
2813
|
-
* `Reported` is wider on purpose and only on the severity: what a runner is
|
|
2814
|
-
* asked for is our three words, and a persona's five are read where they arrive
|
|
2815
|
-
* anyway rather than being asked for.
|
|
2913
|
+
* The findings one to a line, in the order the run reported them, ruled so the
|
|
2914
|
+
* three columns read apart.
|
|
2816
2915
|
*/
|
|
2817
|
-
const
|
|
2916
|
+
const lines$1 = (found) => table(found.findings.map((finding) => [
|
|
2917
|
+
`${finding.file}:${finding.line}`,
|
|
2918
|
+
finding.severity,
|
|
2919
|
+
finding.summary
|
|
2920
|
+
]), rule);
|
|
2818
2921
|
/**
|
|
2819
|
-
*
|
|
2922
|
+
* What the run reported, or the sentence saying it reported nothing at all.
|
|
2820
2923
|
*
|
|
2821
|
-
*
|
|
2822
|
-
*
|
|
2823
|
-
* from the findings themselves rather than left empty.
|
|
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.
|
|
2824
2926
|
*/
|
|
2825
|
-
const
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
info: 0,
|
|
2829
|
-
warning: 1,
|
|
2830
|
-
error: 2
|
|
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);
|
|
2831
2930
|
};
|
|
2832
2931
|
/**
|
|
2833
|
-
*
|
|
2932
|
+
* What the current review run found, as a table or as the JSON it is kept in.
|
|
2834
2933
|
*
|
|
2835
|
-
* `
|
|
2836
|
-
*
|
|
2837
|
-
*
|
|
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.
|
|
2937
|
+
*
|
|
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.
|
|
2838
2941
|
*/
|
|
2839
|
-
const
|
|
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"));
|
|
2840
2956
|
//#endregion
|
|
2841
|
-
//#region src/
|
|
2957
|
+
//#region src/adapters/agent.ts
|
|
2842
2958
|
/**
|
|
2843
|
-
*
|
|
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.
|
|
2844
2961
|
*
|
|
2845
|
-
*
|
|
2846
|
-
*
|
|
2847
|
-
* found nothing, which is not the same as having found nothing wrong.
|
|
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.
|
|
2848
2964
|
*/
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
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
|
+
};
|
|
2853
2975
|
/**
|
|
2854
|
-
*
|
|
2976
|
+
* Failures in the name of the program that was spawned.
|
|
2855
2977
|
*
|
|
2856
|
-
*
|
|
2857
|
-
*
|
|
2858
|
-
*
|
|
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.
|
|
2859
2981
|
*/
|
|
2860
|
-
const
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
/** The head the run covers. A run never vouches for code it did not see. */
|
|
2864
|
-
head: Schema.String,
|
|
2865
|
-
/**
|
|
2866
|
-
* The slash command line the run opened on, or null where it opened on the
|
|
2867
|
-
* tool's own prompt. A report found months later says what it was asked, and a
|
|
2868
|
-
* record an earlier version wrote carries no such field and is forgotten.
|
|
2869
|
-
*/
|
|
2870
|
-
command: Schema.NullOr(Schema.String),
|
|
2871
|
-
effort: Schema.NullOr(Effort),
|
|
2872
|
-
/**
|
|
2873
|
-
* The agent session the run happened in, or null where it never reached one.
|
|
2874
|
-
*
|
|
2875
|
-
* A run that would not start or exited before it said anything has no session,
|
|
2876
|
-
* and the run is still recorded: a failure is recorded as what it is.
|
|
2877
|
-
*/
|
|
2878
|
-
sessionId: Schema.NullOr(Schema.String),
|
|
2879
|
-
ranAt: Schema.DateTimeUtcFromString,
|
|
2880
|
-
outcome: Outcome
|
|
2982
|
+
const failedBy = (program) => (detail) => new AgentFailed({
|
|
2983
|
+
program,
|
|
2984
|
+
detail
|
|
2881
2985
|
});
|
|
2882
|
-
/** A head as it is read out loud: the seven characters git itself abbreviates to. */
|
|
2883
|
-
const short = (head) => head.slice(0, 7);
|
|
2884
|
-
/**
|
|
2885
|
-
* Where a run is kept: one key per head, so a run and the code it read cannot
|
|
2886
|
-
* drift apart, and a re-review replaces the run before it.
|
|
2887
|
-
*/
|
|
2888
|
-
const runKey = (repo, number, head) => `${repo}#${number}@${head}`;
|
|
2889
|
-
/** Where the run's report is kept: beside the run, as the Markdown it is. */
|
|
2890
|
-
const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
|
|
2891
2986
|
/**
|
|
2892
|
-
*
|
|
2893
|
-
* `reportKey` rather than a thing the glossary names.
|
|
2987
|
+
* How long each turn gets before it is given up on.
|
|
2894
2988
|
*
|
|
2895
|
-
*
|
|
2896
|
-
*
|
|
2897
|
-
*
|
|
2898
|
-
*
|
|
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.
|
|
2899
2997
|
*/
|
|
2900
|
-
const
|
|
2901
|
-
|
|
2902
|
-
|
|
2998
|
+
const patience = {
|
|
2999
|
+
reviewing: Duration.minutes(45),
|
|
3000
|
+
reporting: Duration.minutes(5)
|
|
3001
|
+
};
|
|
2903
3002
|
/**
|
|
2904
|
-
*
|
|
3003
|
+
* One turn of the launcher in `directory`, with `read` over its standard output.
|
|
2905
3004
|
*
|
|
2906
|
-
*
|
|
2907
|
-
*
|
|
2908
|
-
*
|
|
2909
|
-
*
|
|
2910
|
-
*
|
|
2911
|
-
*
|
|
2912
|
-
* it costs one review, where failing here would cost me the command I asked for.
|
|
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.
|
|
2913
3011
|
*/
|
|
2914
|
-
const
|
|
2915
|
-
const
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
const
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
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
|
+
});
|
|
2923
3030
|
});
|
|
3031
|
+
//#endregion
|
|
3032
|
+
//#region src/adapters/claude.ts
|
|
2924
3033
|
/**
|
|
2925
|
-
*
|
|
2926
|
-
*
|
|
2927
|
-
* A failure is not a clean verdict: a run that could not report has found
|
|
2928
|
-
* nothing, which is not the same as having found nothing wrong. Everything that
|
|
2929
|
-
* reads a run's findings reads them through here, so the distinction is drawn
|
|
2930
|
-
* once rather than at every caller that might forget it.
|
|
2931
|
-
*/
|
|
2932
|
-
const reportedBy = (run) => run.outcome._tag === "reported" ? {
|
|
2933
|
-
verdict: run.outcome.verdict,
|
|
2934
|
-
findings: run.outcome.findings
|
|
2935
|
-
} : null;
|
|
2936
|
-
/**
|
|
2937
|
-
* Why a run reported nothing, or null where it reported.
|
|
2938
|
-
*
|
|
2939
|
-
* The sibling of `reportedBy`, and here for the same reason: the two halves of
|
|
2940
|
-
* an outcome are read through one place each rather than re-narrowed at every
|
|
2941
|
-
* caller.
|
|
3034
|
+
* Claude Code: a review on a slash command, a review on the tool's own prompt,
|
|
3035
|
+
* and the sessions I steer.
|
|
2942
3036
|
*/
|
|
2943
|
-
const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
|
|
2944
3037
|
/**
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2947
|
-
*
|
|
2948
|
-
*
|
|
2949
|
-
* thousand lines inside them are still prose.
|
|
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.
|
|
2950
3042
|
*/
|
|
2951
|
-
const
|
|
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
|
+
};
|
|
3071
|
+
};
|
|
2952
3072
|
/**
|
|
2953
|
-
* The
|
|
3073
|
+
* The result a turn ended on, or the failure it really was.
|
|
2954
3074
|
*
|
|
2955
|
-
* A
|
|
2956
|
-
*
|
|
2957
|
-
*
|
|
2958
|
-
* with no run behind it, a run that reported nothing, a comparison GitHub would
|
|
2959
|
-
* not answer, and anything that changed outside the globs. A head that has
|
|
2960
|
-
* already had a run changed nothing at all, which is the one case that needs no
|
|
2961
|
-
* comparison to decide.
|
|
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.
|
|
2962
3078
|
*/
|
|
2963
|
-
const
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
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);
|
|
2967
3084
|
};
|
|
2968
|
-
/** What a run was opened on, as the report says it. */
|
|
2969
|
-
const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
|
|
2970
3085
|
/**
|
|
2971
|
-
*
|
|
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.
|
|
2972
3089
|
*
|
|
2973
|
-
*
|
|
2974
|
-
*
|
|
2975
|
-
* what the run was asked, without anything else having to be open.
|
|
3090
|
+
* Both shapes of review read a turn the same way, so the fold is here rather
|
|
3091
|
+
* than once per shape.
|
|
2976
3092
|
*/
|
|
2977
|
-
const
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
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
|
+
})));
|
|
2987
3106
|
/**
|
|
2988
|
-
*
|
|
3107
|
+
* One review run on a slash command, headless, in `directory`.
|
|
2989
3108
|
*
|
|
2990
|
-
*
|
|
2991
|
-
* `
|
|
2992
|
-
* nothing
|
|
2993
|
-
*/
|
|
2994
|
-
const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
|
|
2995
|
-
/** The findings at one head that withhold the stamp. */
|
|
2996
|
-
const blockingIn = (run, blocksOn) => {
|
|
2997
|
-
const found = run === null ? null : reportedBy(run);
|
|
2998
|
-
return found === null ? [] : blocking(found.findings, blocksOn);
|
|
2999
|
-
};
|
|
3000
|
-
/**
|
|
3001
|
-
* What the review runs on `head` say about it, for the stamp to rest on.
|
|
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.
|
|
3002
3112
|
*
|
|
3003
|
-
*
|
|
3004
|
-
*
|
|
3005
|
-
*
|
|
3006
|
-
*
|
|
3007
|
-
*
|
|
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.
|
|
3008
3119
|
*
|
|
3009
|
-
*
|
|
3010
|
-
*
|
|
3011
|
-
*
|
|
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.
|
|
3012
3125
|
*/
|
|
3013
|
-
const
|
|
3014
|
-
const
|
|
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");
|
|
3015
3149
|
return {
|
|
3016
|
-
|
|
3017
|
-
|
|
3150
|
+
report,
|
|
3151
|
+
sessionId: session_id
|
|
3018
3152
|
};
|
|
3019
|
-
});
|
|
3020
|
-
//#endregion
|
|
3021
|
-
//#region src/cli/sweep.ts
|
|
3022
|
-
const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
|
|
3023
|
-
const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
|
|
3153
|
+
}, Effect.scoped);
|
|
3024
3154
|
/**
|
|
3025
|
-
*
|
|
3155
|
+
* What the second turn asks for.
|
|
3026
3156
|
*
|
|
3027
|
-
*
|
|
3028
|
-
*
|
|
3029
|
-
*
|
|
3030
|
-
*
|
|
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.
|
|
3031
3161
|
*/
|
|
3032
|
-
const
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
const previous = Option.getOrUndefined(yield* Effect.orElseSucceed(store.get(key), () => Option.none()));
|
|
3040
|
-
const reviewed = yield* reviewedAt(found.repo, found.number, view.headRefOid, settings);
|
|
3041
|
-
const quiet = previous !== void 0 && isQuiet(pulseOf(previous), {
|
|
3042
|
-
head: view.headRefOid,
|
|
3043
|
-
checks,
|
|
3044
|
-
newestHumanCommentAt
|
|
3045
|
-
}) ? previous : void 0;
|
|
3046
|
-
const myLastCommitAt = quiet !== void 0 ? quiet.myLastCommitAt : newest((yield* prCommits(found.repo, found.number)).filter((commit) => commit.logins.includes(me)).map((commit) => commit.at));
|
|
3047
|
-
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);
|
|
3048
|
-
const rebaseConflictAt = yield* Effect.map(conflictFor(found.repo, found.number), (it) => it?.head ?? null);
|
|
3049
|
-
const facts = {
|
|
3050
|
-
repo: found.repo,
|
|
3051
|
-
number: found.number,
|
|
3052
|
-
title: view.title,
|
|
3053
|
-
url: view.url,
|
|
3054
|
-
draft: view.isDraft,
|
|
3055
|
-
head: view.headRefOid,
|
|
3056
|
-
mergeable: mergeabilityOf(view.mergeable),
|
|
3057
|
-
reviewDecision: reviewDecisionOf(view.reviewDecision),
|
|
3058
|
-
checks,
|
|
3059
|
-
ciFlaky,
|
|
3060
|
-
rebaseConflictAt,
|
|
3061
|
-
newestHumanCommentAt,
|
|
3062
|
-
myLastCommentAt: newest(writtenBy(comments, me)),
|
|
3063
|
-
myLastCommitAt,
|
|
3064
|
-
...reviewed
|
|
3065
|
-
};
|
|
3066
|
-
yield* store.set(key, facts);
|
|
3067
|
-
return facts;
|
|
3068
|
-
});
|
|
3069
|
-
/** A read that came back, or the trouble it came back with instead. */
|
|
3070
|
-
const attempt = (where, read) => read.pipe(Effect.map((got) => ({
|
|
3071
|
-
got,
|
|
3072
|
-
troubles: []
|
|
3073
|
-
})), Effect.catch((error) => Effect.succeed({
|
|
3074
|
-
got: [],
|
|
3075
|
-
troubles: [{
|
|
3076
|
-
where,
|
|
3077
|
-
detail: error.message
|
|
3078
|
-
}]
|
|
3079
|
-
})));
|
|
3080
|
-
const gather = (attempts) => ({
|
|
3081
|
-
got: attempts.flatMap((it) => it.got),
|
|
3082
|
-
troubles: attempts.flatMap((it) => it.troubles)
|
|
3083
|
-
});
|
|
3084
|
-
/** How many reads of GitHub are in flight at once. */
|
|
3085
|
-
const concurrency = 4;
|
|
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(" ");
|
|
3086
3169
|
/**
|
|
3087
|
-
*
|
|
3170
|
+
* The second turn of a review run: the prose the first one wrote, back as
|
|
3171
|
+
* findings that validate.
|
|
3088
3172
|
*
|
|
3089
|
-
*
|
|
3090
|
-
*
|
|
3091
|
-
*
|
|
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.
|
|
3178
|
+
*
|
|
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.
|
|
3092
3181
|
*/
|
|
3093
|
-
const
|
|
3094
|
-
const
|
|
3095
|
-
const
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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))
|
|
3202
|
+
});
|
|
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)
|
|
3236
|
+
});
|
|
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();
|
|
3105
3240
|
return {
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3241
|
+
findings: structured_output,
|
|
3242
|
+
sessionId: session_id,
|
|
3243
|
+
prose: prose === "" ? null : prose
|
|
3109
3244
|
};
|
|
3110
|
-
}
|
|
3245
|
+
}, Effect.scoped);
|
|
3111
3246
|
/**
|
|
3112
|
-
*
|
|
3113
|
-
*
|
|
3114
|
-
*
|
|
3247
|
+
* One review run, in whichever shape it was configured in.
|
|
3248
|
+
*
|
|
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.
|
|
3115
3256
|
*/
|
|
3116
|
-
const
|
|
3117
|
-
|
|
3118
|
-
"
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
+
};
|
|
3130
3293
|
});
|
|
3131
3294
|
/**
|
|
3132
|
-
*
|
|
3295
|
+
* An interactive `claude` in `directory`, opened on `prompt`, with my terminal
|
|
3296
|
+
* handed straight to it.
|
|
3133
3297
|
*
|
|
3134
|
-
*
|
|
3135
|
-
*
|
|
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.
|
|
3136
3315
|
*/
|
|
3137
|
-
const
|
|
3138
|
-
const
|
|
3139
|
-
|
|
3140
|
-
yield*
|
|
3141
|
-
|
|
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);
|
|
3142
3333
|
//#endregion
|
|
3143
|
-
//#region src/domain/
|
|
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)
|
|
3339
|
+
});
|
|
3144
3340
|
/**
|
|
3145
|
-
*
|
|
3146
|
-
*
|
|
3147
|
-
* A review thread is answered as a whole, so a single comment newer than my
|
|
3148
|
-
* last activity brings the whole thread with it: the follow-up on its own is a
|
|
3149
|
-
* line answering something the screen does not show, which is what sends me to
|
|
3150
|
-
* the browser.
|
|
3341
|
+
* What a fix session is handed: the findings I picked, and the review run they
|
|
3342
|
+
* came from.
|
|
3151
3343
|
*
|
|
3152
|
-
* The
|
|
3153
|
-
*
|
|
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.
|
|
3154
3346
|
*/
|
|
3155
|
-
const
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
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));
|
|
3163
3355
|
/**
|
|
3164
|
-
* The
|
|
3356
|
+
* The prompt a fix session opens on: what these findings are, and the findings
|
|
3357
|
+
* themselves as JSON.
|
|
3165
3358
|
*
|
|
3166
|
-
*
|
|
3167
|
-
*
|
|
3168
|
-
*
|
|
3169
|
-
*
|
|
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.
|
|
3170
3363
|
*
|
|
3171
|
-
*
|
|
3172
|
-
*
|
|
3173
|
-
*
|
|
3174
|
-
*
|
|
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.
|
|
3175
3368
|
*/
|
|
3176
|
-
const
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
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"));
|
|
3375
|
+
/**
|
|
3376
|
+
* Why these findings cannot be fixed where the pull request now is, or nothing
|
|
3377
|
+
* where they can.
|
|
3378
|
+
*
|
|
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.
|
|
3383
|
+
*/
|
|
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.`;
|
|
3183
3385
|
//#endregion
|
|
3184
|
-
//#region src/cli/
|
|
3185
|
-
const
|
|
3186
|
-
|
|
3187
|
-
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);
|
|
3188
3389
|
/**
|
|
3189
|
-
*
|
|
3390
|
+
* The findings to pick from, each on the line `dw-mc findings` gives it.
|
|
3190
3391
|
*
|
|
3191
|
-
*
|
|
3192
|
-
*
|
|
3193
|
-
*
|
|
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.
|
|
3194
3396
|
*/
|
|
3195
|
-
const
|
|
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
|
+
};
|
|
3196
3405
|
/**
|
|
3197
|
-
*
|
|
3198
|
-
* it, then what they said in full.
|
|
3406
|
+
* Each picked finding with whatever I have to say about it.
|
|
3199
3407
|
*
|
|
3200
|
-
*
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
* 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.
|
|
3204
3411
|
*/
|
|
3205
|
-
const
|
|
3206
|
-
const
|
|
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
|
+
});
|
|
3207
3426
|
/**
|
|
3208
|
-
*
|
|
3427
|
+
* A fix session: the findings I picked, in an agent session I steer.
|
|
3209
3428
|
*
|
|
3210
|
-
* The
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
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.
|
|
3213
3435
|
*
|
|
3214
|
-
*
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
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.
|
|
3218
3440
|
*/
|
|
3219
|
-
const
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
}
|
|
3224
|
-
|
|
3225
|
-
const
|
|
3226
|
-
const
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
const
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
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(" ");
|
|
3235
3508
|
};
|
|
3509
|
+
const row = (label, value) => `${label.padEnd(12)}${value}`;
|
|
3236
3510
|
/**
|
|
3237
|
-
*
|
|
3238
|
-
*
|
|
3239
|
-
* What it shows by default is what the bucket rule measures: the comments newer
|
|
3240
|
-
* than the later of my last comment and my last commit, which are the ones that
|
|
3241
|
-
* put the pull request in Needs me. Reading it answers the question the table
|
|
3242
|
-
* asked.
|
|
3511
|
+
* Both the machine setup and the repository registration: there is deliberately
|
|
3512
|
+
* no separate `setup` command.
|
|
3243
3513
|
*
|
|
3244
|
-
* The
|
|
3245
|
-
*
|
|
3246
|
-
*
|
|
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.
|
|
3247
3519
|
*
|
|
3248
|
-
* It
|
|
3249
|
-
*
|
|
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.
|
|
3523
|
+
*
|
|
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.
|
|
3250
3526
|
*/
|
|
3251
|
-
const
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
}, Effect.fn("
|
|
3255
|
-
|
|
3256
|
-
const
|
|
3257
|
-
const
|
|
3258
|
-
const
|
|
3259
|
-
const
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
yield* Console.log(
|
|
3268
|
-
yield* Console.log("");
|
|
3269
|
-
|
|
3270
|
-
|
|
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"));
|
|
3271
3551
|
//#endregion
|
|
3272
|
-
//#region src/
|
|
3273
|
-
/** The findings as the JSON the schema defines, rather than as this file spells it. */
|
|
3274
|
-
const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
|
|
3275
|
-
const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
|
|
3276
|
-
/** What a run's findings come to in one line, against the bar that blocks. */
|
|
3277
|
-
const summary = (found, blocksOn) => {
|
|
3278
|
-
if (found.findings.length === 0) return "clean, nothing to fix";
|
|
3279
|
-
const blocked = blocking(found.findings, blocksOn).length;
|
|
3280
|
-
return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
|
|
3281
|
-
};
|
|
3282
|
-
/** Which run these findings are, and what they come to: the line above the list. */
|
|
3283
|
-
const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
|
|
3552
|
+
//#region src/adapters/ci.ts
|
|
3284
3553
|
/**
|
|
3285
|
-
*
|
|
3286
|
-
*
|
|
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.
|
|
3287
3557
|
*/
|
|
3288
|
-
const
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
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 ?? "");
|
|
3293
3577
|
/**
|
|
3294
|
-
*
|
|
3295
|
-
*
|
|
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.
|
|
3296
3580
|
*
|
|
3297
|
-
*
|
|
3298
|
-
*
|
|
3299
|
-
* one I run inside a fix session, where another round trip to GitHub buys
|
|
3300
|
-
* 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.
|
|
3301
3583
|
*/
|
|
3302
|
-
const
|
|
3303
|
-
const
|
|
3304
|
-
|
|
3305
|
-
|
|
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
|
+
};
|
|
3306
3591
|
/**
|
|
3307
|
-
*
|
|
3592
|
+
* The checks that failed and count, which are the ones there is a log to read.
|
|
3308
3593
|
*
|
|
3309
|
-
*
|
|
3310
|
-
*
|
|
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.
|
|
3311
3596
|
*/
|
|
3312
|
-
const
|
|
3313
|
-
const found = reportedBy(run);
|
|
3314
|
-
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);
|
|
3315
|
-
};
|
|
3597
|
+
const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
|
|
3316
3598
|
/**
|
|
3317
|
-
* What
|
|
3599
|
+
* What a check reports on, out of the URL it reports at.
|
|
3318
3600
|
*
|
|
3319
|
-
*
|
|
3320
|
-
*
|
|
3321
|
-
*
|
|
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.
|
|
3606
|
+
*/
|
|
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 })) }));
|
|
3615
|
+
/**
|
|
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.
|
|
3322
3642
|
*
|
|
3323
|
-
*
|
|
3324
|
-
*
|
|
3325
|
-
*
|
|
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.
|
|
3326
3647
|
*/
|
|
3327
|
-
const
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
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
|
+
});
|
|
3345
3678
|
/**
|
|
3346
|
-
* How
|
|
3347
|
-
* answers against a schema comes back with.
|
|
3679
|
+
* How much of a failing job's log is kept.
|
|
3348
3680
|
*
|
|
3349
|
-
*
|
|
3350
|
-
*
|
|
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.
|
|
3351
3684
|
*/
|
|
3352
|
-
|
|
3353
|
-
var AgentFailed = class extends Schema.TaggedError()("AgentFailed", {
|
|
3354
|
-
/** The program that was spawned, which is what a search for it has to name. */
|
|
3355
|
-
program: Schema.String,
|
|
3356
|
-
detail: Schema.String
|
|
3357
|
-
}) {
|
|
3358
|
-
get message() {
|
|
3359
|
-
return `The ${this.program} review run failed: ${this.detail}`;
|
|
3360
|
-
}
|
|
3361
|
-
};
|
|
3685
|
+
const logTailBytes = 65536;
|
|
3362
3686
|
/**
|
|
3363
|
-
*
|
|
3687
|
+
* What one failing job printed, from the end.
|
|
3364
3688
|
*
|
|
3365
|
-
*
|
|
3366
|
-
*
|
|
3367
|
-
*
|
|
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.
|
|
3368
3692
|
*/
|
|
3369
|
-
const
|
|
3370
|
-
|
|
3371
|
-
|
|
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);
|
|
3372
3706
|
});
|
|
3373
3707
|
/**
|
|
3374
|
-
*
|
|
3708
|
+
* The workflow runs behind the failing checks that count, each named once.
|
|
3375
3709
|
*
|
|
3376
|
-
*
|
|
3377
|
-
*
|
|
3378
|
-
* stopped rather than one that is slow. The second turn reads no code and
|
|
3379
|
-
* decides nothing - the review it reports on is already in the session it
|
|
3380
|
-
* 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.
|
|
3381
3712
|
*
|
|
3382
|
-
*
|
|
3383
|
-
*
|
|
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.
|
|
3384
3717
|
*/
|
|
3385
|
-
const
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
};
|
|
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
|
+
}))];
|
|
3389
3722
|
/**
|
|
3390
|
-
*
|
|
3723
|
+
* Asks GitHub to run one workflow run's failed jobs again.
|
|
3391
3724
|
*
|
|
3392
|
-
*
|
|
3393
|
-
*
|
|
3394
|
-
*
|
|
3395
|
-
* the other. Every way a turn can fail to finish comes back from here as a
|
|
3396
|
-
* `AgentFailed`, so a caller is left with the turn's own answer and nothing else
|
|
3397
|
-
* 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.
|
|
3398
3728
|
*/
|
|
3399
|
-
const
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
duration: options.patience.duration,
|
|
3415
|
-
orElse: () => failed(`${options.patience.turn} did not come back within ${Duration.format(options.patience.duration)}`)
|
|
3416
|
-
});
|
|
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
|
+
}));
|
|
3417
3744
|
});
|
|
3418
3745
|
//#endregion
|
|
3419
|
-
//#region src/
|
|
3420
|
-
/**
|
|
3421
|
-
* Claude Code: a review on a slash command, a review on the tool's own prompt,
|
|
3422
|
-
* and the sessions I steer.
|
|
3423
|
-
*/
|
|
3746
|
+
//#region src/domain/stamp.ts
|
|
3424
3747
|
/**
|
|
3425
|
-
*
|
|
3426
|
-
*
|
|
3427
|
-
*
|
|
3428
|
-
*
|
|
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.
|
|
3429
3753
|
*/
|
|
3430
|
-
const
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
text: Schema.optionalKey(Schema.String)
|
|
3436
|
-
})) })
|
|
3437
|
-
});
|
|
3438
|
-
const Ended = Schema.Struct({
|
|
3439
|
-
type: Schema.Literal("result"),
|
|
3440
|
-
subtype: Schema.String,
|
|
3441
|
-
is_error: Schema.Boolean,
|
|
3442
|
-
session_id: Schema.String,
|
|
3443
|
-
result: Schema.optionalKey(Schema.String),
|
|
3444
|
-
/** What a turn given a JSON schema validated, which this hands on unread. */
|
|
3445
|
-
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
|
|
3446
3759
|
});
|
|
3447
|
-
|
|
3448
|
-
const
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
});
|
|
3454
|
-
return {
|
|
3455
|
-
tools: blocks.flatMap((block) => block.type === "tool_use" && block.name !== void 0 ? [block.name] : []),
|
|
3456
|
-
said: blocks.flatMap((block) => block.type === "text" && block.text !== void 0 ? [block.text] : [])
|
|
3457
|
-
};
|
|
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"
|
|
3458
3766
|
};
|
|
3459
|
-
/**
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
* lost its connection says so, and its `result` is the only word on why.
|
|
3465
|
-
*/
|
|
3466
|
-
const ended = (program, result) => {
|
|
3467
|
-
const failed = failedBy(program);
|
|
3468
|
-
if (Option.isNone(result)) return Effect.fail(failed("the turn came back with no result"));
|
|
3469
|
-
const { is_error, result: lastWord, subtype } = result.value;
|
|
3470
|
-
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"
|
|
3471
3772
|
};
|
|
3472
3773
|
/**
|
|
3473
|
-
*
|
|
3474
|
-
* to `onTool` while the run is still going, and what it said and how it ended
|
|
3475
|
-
* are what comes back.
|
|
3476
|
-
*
|
|
3477
|
-
* Both shapes of review read a turn the same way, so the fold is here rather
|
|
3478
|
-
* than once per shape.
|
|
3479
|
-
*/
|
|
3480
|
-
const transcript = (onTool) => (stdout) => stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.mapEffect((line) => {
|
|
3481
|
-
const heard = heardIn(line);
|
|
3482
|
-
return Effect.as(Effect.forEach(heard.tools, onTool, { discard: true }), {
|
|
3483
|
-
line,
|
|
3484
|
-
heard
|
|
3485
|
-
});
|
|
3486
|
-
}), Stream.runFold(() => ({
|
|
3487
|
-
said: [],
|
|
3488
|
-
result: Option.none()
|
|
3489
|
-
}), (soFar, { heard, line }) => ({
|
|
3490
|
-
said: [...soFar.said, ...heard.said],
|
|
3491
|
-
result: Option.orElse(asResult(line), () => soFar.result)
|
|
3492
|
-
})));
|
|
3493
|
-
/**
|
|
3494
|
-
* 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.
|
|
3495
3775
|
*
|
|
3496
|
-
* The
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
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).
|
|
3499
3782
|
*
|
|
3500
|
-
*
|
|
3501
|
-
*
|
|
3502
|
-
* turn that resumes the session and asks for the findings. My own instructions
|
|
3503
|
-
* ride on `--append-system-prompt` rather than on the command's own line,
|
|
3504
|
-
* because what a slash command does with its arguments is its business and not
|
|
3505
|
-
* 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.
|
|
3506
3785
|
*
|
|
3507
|
-
*
|
|
3508
|
-
*
|
|
3509
|
-
* the run said on its own turns rather than the `result` alone: verified by
|
|
3510
|
-
* running it, a repository whose review command fans out to subagents can end on
|
|
3511
|
-
* 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.
|
|
3512
3788
|
*/
|
|
3513
|
-
const
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
"--output-format",
|
|
3522
|
-
"stream-json",
|
|
3523
|
-
"--verbose",
|
|
3524
|
-
...options.instructions === null ? [] : ["--append-system-prompt", options.instructions],
|
|
3525
|
-
...options.model === null ? [] : ["--model", options.model]
|
|
3526
|
-
],
|
|
3527
|
-
patience: {
|
|
3528
|
-
turn: "the review",
|
|
3529
|
-
duration: patience.reviewing
|
|
3530
|
-
},
|
|
3531
|
-
read: transcript(options.onTool)
|
|
3532
|
-
});
|
|
3533
|
-
const { result: lastWord, session_id } = yield* ended(program, run.result);
|
|
3534
|
-
const report = (run.said.length === 0 ? lastWord ?? "" : run.said.join("\n\n")).trim();
|
|
3535
|
-
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);
|
|
3536
3797
|
return {
|
|
3537
|
-
|
|
3538
|
-
|
|
3798
|
+
stamped: true,
|
|
3799
|
+
reason: "a clean review run on this head, green CI, mergeable"
|
|
3539
3800
|
};
|
|
3540
|
-
}
|
|
3801
|
+
};
|
|
3541
3802
|
/**
|
|
3542
|
-
*
|
|
3803
|
+
* The head a stamp was withdrawn at, or null where none was.
|
|
3543
3804
|
*
|
|
3544
|
-
*
|
|
3545
|
-
*
|
|
3546
|
-
|
|
3547
|
-
|
|
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
|
+
});
|
|
3824
|
+
/**
|
|
3825
|
+
* Which of these tracked PRs carry a stamp, keyed the way their facts are.
|
|
3826
|
+
*
|
|
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.
|
|
3548
3829
|
*/
|
|
3549
|
-
const
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
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
|
|
3556
3839
|
/**
|
|
3557
|
-
*
|
|
3558
|
-
* findings that validate.
|
|
3840
|
+
* Why GitHub would not call this pull request Ready, or null where it would.
|
|
3559
3841
|
*
|
|
3560
|
-
*
|
|
3561
|
-
*
|
|
3562
|
-
*
|
|
3563
|
-
*
|
|
3564
|
-
* 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.
|
|
3565
3846
|
*
|
|
3566
|
-
*
|
|
3567
|
-
*
|
|
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.
|
|
3568
3849
|
*/
|
|
3569
|
-
const
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
patience: {
|
|
3575
|
-
turn: "the findings turn",
|
|
3576
|
-
duration: patience.reporting
|
|
3577
|
-
},
|
|
3578
|
-
args: [
|
|
3579
|
-
"-p",
|
|
3580
|
-
"--resume",
|
|
3581
|
-
options.sessionId,
|
|
3582
|
-
reportFindings,
|
|
3583
|
-
"--output-format",
|
|
3584
|
-
"json",
|
|
3585
|
-
"--json-schema",
|
|
3586
|
-
options.jsonSchema
|
|
3587
|
-
],
|
|
3588
|
-
read: (stdout) => Stream.mkString(Stream.decodeText(stdout))
|
|
3589
|
-
});
|
|
3590
|
-
const { structured_output } = yield* ended(program, asResult(printed.trim()));
|
|
3591
|
-
if (structured_output === void 0) return yield* failedBy(program)("the findings turn came back with no structured output");
|
|
3592
|
-
return structured_output;
|
|
3593
|
-
}, 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
|
+
};
|
|
3594
3855
|
/**
|
|
3595
|
-
*
|
|
3856
|
+
* What to do about a pull request that is Ready and carries no stamp.
|
|
3596
3857
|
*
|
|
3597
|
-
*
|
|
3598
|
-
*
|
|
3599
|
-
*
|
|
3600
|
-
*
|
|
3601
|
-
* 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.
|
|
3602
3862
|
*/
|
|
3603
|
-
const
|
|
3604
|
-
|
|
3605
|
-
const
|
|
3606
|
-
command:
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
},
|
|
3612
|
-
args: [
|
|
3613
|
-
"-p",
|
|
3614
|
-
options.prompt,
|
|
3615
|
-
"--output-format",
|
|
3616
|
-
"stream-json",
|
|
3617
|
-
"--verbose",
|
|
3618
|
-
"--json-schema",
|
|
3619
|
-
options.jsonSchema,
|
|
3620
|
-
...options.model === null ? [] : ["--model", options.model]
|
|
3621
|
-
],
|
|
3622
|
-
read: transcript(options.onTool)
|
|
3623
|
-
});
|
|
3624
|
-
const { session_id, structured_output } = yield* ended(program, run.result);
|
|
3625
|
-
if (structured_output === void 0) return yield* failedBy(program)("the review came back with no structured output");
|
|
3626
|
-
const prose = run.said.join("\n\n").trim();
|
|
3627
|
-
return {
|
|
3628
|
-
findings: structured_output,
|
|
3629
|
-
sessionId: session_id,
|
|
3630
|
-
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."
|
|
3631
3871
|
};
|
|
3632
|
-
|
|
3872
|
+
return `\n\n ${next.command}\n\n${next.says}`;
|
|
3873
|
+
};
|
|
3633
3874
|
/**
|
|
3634
|
-
*
|
|
3875
|
+
* Why this pull request is not one to merge, or null where it is.
|
|
3635
3876
|
*
|
|
3636
|
-
*
|
|
3637
|
-
*
|
|
3638
|
-
*
|
|
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.
|
|
3639
3882
|
*
|
|
3640
|
-
*
|
|
3641
|
-
*
|
|
3642
|
-
*
|
|
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.
|
|
3643
3891
|
*/
|
|
3644
|
-
const
|
|
3645
|
-
const
|
|
3646
|
-
if (
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
return {
|
|
3656
|
-
sessionId: run.sessionId,
|
|
3657
|
-
prose: run.prose,
|
|
3658
|
-
findings: Result.succeed(run.findings)
|
|
3659
|
-
};
|
|
3660
|
-
}
|
|
3661
|
-
const run = yield* commandReview({
|
|
3662
|
-
launcher,
|
|
3663
|
-
directory,
|
|
3664
|
-
line: options.turn.line,
|
|
3665
|
-
instructions: options.turn.instructions,
|
|
3666
|
-
model,
|
|
3667
|
-
onTool
|
|
3668
|
-
});
|
|
3669
|
-
const findings = yield* Effect.result(findingsTurn({
|
|
3670
|
-
launcher,
|
|
3671
|
-
directory,
|
|
3672
|
-
sessionId: run.sessionId,
|
|
3673
|
-
jsonSchema
|
|
3674
|
-
}));
|
|
3675
|
-
return {
|
|
3676
|
-
sessionId: run.sessionId,
|
|
3677
|
-
prose: run.report,
|
|
3678
|
-
findings
|
|
3679
|
-
};
|
|
3680
|
-
});
|
|
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
|
|
3681
3903
|
/**
|
|
3682
|
-
*
|
|
3683
|
-
* handed straight to it.
|
|
3904
|
+
* Lands one pull request of mine: squashed, with its branch deleted.
|
|
3684
3905
|
*
|
|
3685
|
-
*
|
|
3686
|
-
*
|
|
3687
|
-
*
|
|
3688
|
-
*
|
|
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.
|
|
3689
3910
|
*
|
|
3690
|
-
*
|
|
3691
|
-
*
|
|
3692
|
-
*
|
|
3693
|
-
* a detached child sits outside the terminal's foreground process group, where
|
|
3694
|
-
* 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.
|
|
3695
3914
|
*
|
|
3696
|
-
*
|
|
3697
|
-
*
|
|
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.
|
|
3698
3920
|
*
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
* 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.
|
|
3702
3923
|
*/
|
|
3703
|
-
const
|
|
3704
|
-
const
|
|
3705
|
-
const
|
|
3706
|
-
const
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
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"));
|
|
3720
3944
|
//#endregion
|
|
3721
|
-
//#region src/domain/
|
|
3722
|
-
/** One finding I chose to act on, carrying what I think about it. */
|
|
3723
|
-
const Chosen = Schema.Struct({
|
|
3724
|
-
...Finding.fields,
|
|
3725
|
-
note: Schema.optionalKey(Schema.String)
|
|
3726
|
-
});
|
|
3945
|
+
//#region src/domain/flaky.ts
|
|
3727
3946
|
/**
|
|
3728
|
-
*
|
|
3729
|
-
*
|
|
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.
|
|
3730
3949
|
*
|
|
3731
|
-
*
|
|
3732
|
-
*
|
|
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.
|
|
3733
3952
|
*/
|
|
3734
|
-
const
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
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);
|
|
3742
3971
|
/**
|
|
3743
|
-
* The
|
|
3744
|
-
* themselves as JSON.
|
|
3745
|
-
*
|
|
3746
|
-
* The findings go in verbatim rather than described, because a re-description
|
|
3747
|
-
* is where a file, a line or my own note quietly changes. A note outranks the
|
|
3748
|
-
* finding it is on: the finding is what the review thought, the note is what I
|
|
3749
|
-
* think, and I am the one who picked it.
|
|
3972
|
+
* The changed file the log names, preferring one it spells in full.
|
|
3750
3973
|
*
|
|
3751
|
-
*
|
|
3752
|
-
*
|
|
3753
|
-
*
|
|
3754
|
-
* 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.
|
|
3755
3977
|
*/
|
|
3756
|
-
const
|
|
3757
|
-
`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.`,
|
|
3758
|
-
"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.",
|
|
3759
|
-
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.`,
|
|
3760
|
-
json
|
|
3761
|
-
].join("\n\n"));
|
|
3978
|
+
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3762
3979
|
/**
|
|
3763
|
-
*
|
|
3764
|
-
*
|
|
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.
|
|
3765
3988
|
*
|
|
3766
|
-
* A
|
|
3767
|
-
*
|
|
3768
|
-
*
|
|
3769
|
-
* 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.
|
|
3770
3992
|
*/
|
|
3771
|
-
const
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
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
|
+
};
|
|
3776
3997
|
/**
|
|
3777
|
-
*
|
|
3998
|
+
* Whether a red CI is mine to fix, and why.
|
|
3778
3999
|
*
|
|
3779
|
-
*
|
|
3780
|
-
*
|
|
3781
|
-
*
|
|
3782
|
-
*
|
|
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.
|
|
3783
4009
|
*/
|
|
3784
|
-
const
|
|
3785
|
-
const
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
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
|
+
};
|
|
3791
4026
|
};
|
|
3792
4027
|
/**
|
|
3793
|
-
*
|
|
4028
|
+
* How many failing jobs the log is read from.
|
|
3794
4029
|
*
|
|
3795
|
-
*
|
|
3796
|
-
*
|
|
3797
|
-
* 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.
|
|
3798
4032
|
*/
|
|
3799
|
-
const
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
onNone: () => finding,
|
|
3805
|
-
onSome: (text) => ({
|
|
3806
|
-
...finding,
|
|
3807
|
-
note: text
|
|
3808
|
-
})
|
|
3809
|
-
}));
|
|
3810
|
-
}
|
|
3811
|
-
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];
|
|
3812
4038
|
});
|
|
3813
|
-
/**
|
|
3814
|
-
const
|
|
3815
|
-
|
|
3816
|
-
|
|
4039
|
+
/** No evidence at all, which is what an unreadable CI comes to. */
|
|
4040
|
+
const nothing = {
|
|
4041
|
+
alsoRedOnDefaultBranch: [],
|
|
4042
|
+
changedFiles: [],
|
|
4043
|
+
log: ""
|
|
3817
4044
|
};
|
|
3818
4045
|
/**
|
|
3819
|
-
*
|
|
4046
|
+
* What a red CI looks like to the classifier.
|
|
3820
4047
|
*
|
|
3821
|
-
*
|
|
3822
|
-
*
|
|
3823
|
-
*
|
|
3824
|
-
*
|
|
3825
|
-
*
|
|
3826
|
-
|
|
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.
|
|
4053
|
+
*/
|
|
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")
|
|
4069
|
+
};
|
|
4070
|
+
});
|
|
4071
|
+
/**
|
|
4072
|
+
* Why a red CI is excused, or null where it is mine to fix.
|
|
3827
4073
|
*
|
|
3828
|
-
*
|
|
3829
|
-
*
|
|
3830
|
-
*
|
|
3831
|
-
*
|
|
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.
|
|
3832
4078
|
*/
|
|
3833
|
-
const
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
}, Effect.fn("fix")(function* ({ commit, pr, print }) {
|
|
3838
|
-
const file = Option.getOrElse(yield* read, () => ({}));
|
|
3839
|
-
const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
|
|
3840
|
-
const settings = settingsFor(file, repo);
|
|
3841
|
-
const run = yield* currentRun(repo, number);
|
|
3842
|
-
const found = yield* whatItFound(run);
|
|
3843
|
-
yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
|
|
3844
|
-
if (found.findings.length === 0) return;
|
|
3845
|
-
const view = yield* prView(repo, number);
|
|
3846
|
-
yield* fixable(number, run.head, view.headRefOid);
|
|
3847
|
-
const picked = yield* choose("Which findings does the session carry?", choicesOf$1(found, yield* width));
|
|
3848
|
-
const chosen = yield* Effect.catchTag(noted(Option.getOrElse(picked, () => [])), "QuitError", () => Effect.succeed([]));
|
|
3849
|
-
if (chosen.length === 0) {
|
|
3850
|
-
yield* Console.log("Nothing picked, so no session was opened.");
|
|
3851
|
-
return;
|
|
3852
|
-
}
|
|
3853
|
-
const commits = Option.getOrElse(commit, () => settings.fix.commits);
|
|
3854
|
-
if (print) {
|
|
3855
|
-
yield* Console.log(yield* promptFor$1({
|
|
3856
|
-
repo,
|
|
3857
|
-
number,
|
|
3858
|
-
head: run.head,
|
|
3859
|
-
findings: chosen
|
|
3860
|
-
}, commits));
|
|
3861
|
-
return;
|
|
3862
|
-
}
|
|
3863
|
-
const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
|
|
3864
|
-
yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
|
|
3865
|
-
yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
|
|
3866
|
-
const ended = yield* steeredSession({
|
|
3867
|
-
launcher: launcherOf(file),
|
|
3868
|
-
directory: worktree.directory,
|
|
3869
|
-
prompt: yield* promptFor$1({
|
|
3870
|
-
repo,
|
|
3871
|
-
number,
|
|
3872
|
-
head: worktree.head,
|
|
3873
|
-
findings: chosen
|
|
3874
|
-
}, commits)
|
|
3875
|
-
});
|
|
3876
|
-
yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
|
|
3877
|
-
yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
|
|
3878
|
-
yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
|
|
3879
|
-
}, Effect.catchTag([
|
|
3880
|
-
...userFacing,
|
|
3881
|
-
"GitFailed",
|
|
3882
|
-
"WorktreeHeld",
|
|
3883
|
-
"AgentFailed"
|
|
3884
|
-
], asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
|
|
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
|
+
});
|
|
3885
4083
|
//#endregion
|
|
3886
|
-
//#region src/
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
"max"
|
|
3893
|
-
]).pipe(Flag.withDescription("How much a review run spends on this repository"), Flag.optional);
|
|
3894
|
-
const baseFlag = Flag.String("base").pipe(Flag.withDescription("The branch this repository's pull requests target, over the default one"), Flag.optional);
|
|
3895
|
-
/** The settings the flags asked for, and only those. */
|
|
3896
|
-
const asked = (base, effort) => ({
|
|
3897
|
-
...Option.isSome(base) ? { base: base.value } : {},
|
|
3898
|
-
...Option.isSome(effort) ? { review: { effort: effort.value } } : {}
|
|
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
|
|
3899
4090
|
});
|
|
3900
|
-
/** What a review will open on, as the setup prints it back. */
|
|
3901
|
-
const opening = (defaults) => {
|
|
3902
|
-
const review = {
|
|
3903
|
-
...builtIn.review,
|
|
3904
|
-
...defaults.review
|
|
3905
|
-
};
|
|
3906
|
-
return review.command === null ? "my own prompt" : [review.command, review.effort].filter((part) => part !== null).join(" ");
|
|
3907
|
-
};
|
|
3908
|
-
const row = (label, value) => `${label.padEnd(12)}${value}`;
|
|
3909
4091
|
/**
|
|
3910
|
-
*
|
|
3911
|
-
* no separate `setup` command.
|
|
3912
|
-
*
|
|
3913
|
-
* The first run on a machine checks `gh` and spells the defaults out in the
|
|
3914
|
-
* configuration file. Run inside a repository, it also registers that
|
|
3915
|
-
* `owner/repo`, taking the name from `gh` so I never type it. Run again, it
|
|
3916
|
-
* changes what the flags name, keeps every other setting the file already had,
|
|
3917
|
-
* and leaves the file untouched where nothing was decided differently.
|
|
3918
|
-
*
|
|
3919
|
-
* It asks nothing. Reviews run on Claude Code, and what a run opens on is
|
|
3920
|
-
* `review.command` and `review.prompt` - a line and a paragraph that belong in
|
|
3921
|
-
* the file rather than in a terminal prompt.
|
|
4092
|
+
* Whether a PR is where the last sweep left it.
|
|
3922
4093
|
*
|
|
3923
|
-
*
|
|
3924
|
-
*
|
|
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.
|
|
3925
4096
|
*/
|
|
3926
|
-
const
|
|
3927
|
-
effort: effortFlag$1,
|
|
3928
|
-
base: baseFlag
|
|
3929
|
-
}, Effect.fn("init")(function* ({ base, effort }) {
|
|
3930
|
-
yield* requireAuth;
|
|
3931
|
-
const config = yield* ConfigStore;
|
|
3932
|
-
const before = yield* read;
|
|
3933
|
-
const file = Option.getOrElse(before, () => ({}));
|
|
3934
|
-
const defaults = file.defaults === void 0 ? merge$1(builtIn, file.defaults ?? {}) : file.defaults ?? {};
|
|
3935
|
-
const state = yield* stateDirectory;
|
|
3936
|
-
const repo = yield* currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone));
|
|
3937
|
-
const overrides = asked(base, effort);
|
|
3938
|
-
const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
|
|
3939
|
-
if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
|
|
3940
|
-
yield* Console.log(row("review", opening(written.defaults ?? {})));
|
|
3941
|
-
yield* Console.log(row("config", config.path));
|
|
3942
|
-
yield* Console.log(row("state", state));
|
|
3943
|
-
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"})`));
|
|
3944
|
-
}, Effect.catchTag([
|
|
3945
|
-
"ConfigMalformed",
|
|
3946
|
-
"GhUnauthenticated",
|
|
3947
|
-
"GhUnavailable",
|
|
3948
|
-
"GhUnreadable"
|
|
3949
|
-
], 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);
|
|
3950
4098
|
//#endregion
|
|
3951
|
-
//#region src/domain/
|
|
4099
|
+
//#region src/domain/rebase.ts
|
|
3952
4100
|
/**
|
|
3953
|
-
*
|
|
4101
|
+
* How many pull requests this one stands on.
|
|
3954
4102
|
*
|
|
3955
|
-
*
|
|
3956
|
-
*
|
|
3957
|
-
*
|
|
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.
|
|
3958
4107
|
*/
|
|
3959
|
-
const
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
pending: "CI is still running",
|
|
3970
|
-
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
|
+
}
|
|
3971
4118
|
};
|
|
3972
|
-
/**
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
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;
|
|
3977
4133
|
};
|
|
3978
4134
|
/**
|
|
3979
|
-
*
|
|
4135
|
+
* Where a pull request sits in its stack, or null where it is in none.
|
|
3980
4136
|
*
|
|
3981
|
-
*
|
|
3982
|
-
*
|
|
3983
|
-
*
|
|
3984
|
-
*
|
|
3985
|
-
|
|
3986
|
-
|
|
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.
|
|
3987
4155
|
*
|
|
3988
|
-
*
|
|
3989
|
-
*
|
|
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.
|
|
3990
4176
|
*
|
|
3991
|
-
*
|
|
3992
|
-
*
|
|
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.
|
|
3993
4186
|
*/
|
|
3994
|
-
const
|
|
3995
|
-
|
|
3996
|
-
if (
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
if (
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
return {
|
|
4003
|
-
stamped: true,
|
|
4004
|
-
reason: "a clean review run on this head, green CI, mergeable"
|
|
4005
|
-
};
|
|
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;
|
|
4006
4195
|
};
|
|
4007
4196
|
/**
|
|
4008
|
-
*
|
|
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.
|
|
4009
4204
|
*
|
|
4010
|
-
*
|
|
4011
|
-
*
|
|
4012
|
-
*
|
|
4013
|
-
*
|
|
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.
|
|
4014
4210
|
*/
|
|
4015
|
-
const
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
return Option.match(withdrawal, {
|
|
4019
|
-
onNone: () => null,
|
|
4020
|
-
onSome: (it) => it.head
|
|
4021
|
-
});
|
|
4211
|
+
const Conflict = Schema.Struct({
|
|
4212
|
+
head: Schema.String,
|
|
4213
|
+
paths: Schema.optionalKey(Schema.Array(Schema.String))
|
|
4022
4214
|
});
|
|
4023
|
-
/**
|
|
4024
|
-
|
|
4025
|
-
|
|
4215
|
+
/**
|
|
4216
|
+
* The conflict a rebase last left on this pull request, or null where it left
|
|
4217
|
+
* none.
|
|
4218
|
+
*
|
|
4219
|
+
* Forgetting one costs the pull request one reason to be in Needs me, where
|
|
4220
|
+
* failing here would cost me the whole table.
|
|
4221
|
+
*/
|
|
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);
|
|
4026
4226
|
});
|
|
4027
|
-
/**
|
|
4028
|
-
const
|
|
4029
|
-
|
|
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
|
+
});
|
|
4233
|
+
});
|
|
4234
|
+
//#endregion
|
|
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);
|
|
4238
|
+
/**
|
|
4239
|
+
* The facts about one tracked PR, read from GitHub and kept on disk.
|
|
4240
|
+
*
|
|
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.
|
|
4245
|
+
*/
|
|
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;
|
|
4030
4282
|
});
|
|
4031
|
-
/**
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
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)
|
|
4043
4297
|
});
|
|
4044
|
-
|
|
4045
|
-
|
|
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(" · ");
|
|
4046
4308
|
/**
|
|
4047
|
-
*
|
|
4048
|
-
*
|
|
4049
|
-
* Ready is GitHub's opinion and nothing of mine: approved, green, mergeable.
|
|
4050
|
-
* A repository that requires no reviewer produces no approval, which is why
|
|
4051
|
-
* `none` passes and `review-required` does not - what holds a merge is somebody
|
|
4052
|
-
* having been asked and not yet answered.
|
|
4309
|
+
* One pass over every tracked PR, and nothing else: a sweep only ever reads.
|
|
4053
4310
|
*
|
|
4054
|
-
*
|
|
4055
|
-
*
|
|
4056
|
-
|
|
4057
|
-
const whyNotReady = (situation) => {
|
|
4058
|
-
if (situation.reviewDecision === "changes-requested") return "changes are requested";
|
|
4059
|
-
if (situation.reviewDecision === "review-required") return "a review from someone else is still wanted";
|
|
4060
|
-
return whyNotGreen[situation.checks] ?? whyNotMergeable[situation.mergeable];
|
|
4061
|
-
};
|
|
4062
|
-
/**
|
|
4063
|
-
* What to do about a pull request that is Ready and carries no stamp.
|
|
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.
|
|
4064
4314
|
*
|
|
4065
|
-
*
|
|
4066
|
-
*
|
|
4067
|
-
*
|
|
4068
|
-
* off code I had read, and only that code changing puts it back.
|
|
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.
|
|
4069
4318
|
*/
|
|
4070
|
-
const
|
|
4071
|
-
|
|
4072
|
-
const
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
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: []
|
|
4078
4326
|
};
|
|
4079
|
-
|
|
4080
|
-
|
|
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
|
+
});
|
|
4081
4363
|
/**
|
|
4082
|
-
*
|
|
4083
|
-
*
|
|
4084
|
-
* This is the single place the merge guards live, and they carry more than the
|
|
4085
|
-
* merge does: it is the one write the tool makes that no reflog of mine undoes
|
|
4086
|
-
* (ADR 0008). Two bars have to be clear, because each is blind to what the
|
|
4087
|
-
* other sees - GitHub does not know whether anything read the diff, and the
|
|
4088
|
-
* stamp does not know whether a reviewer asked for changes.
|
|
4089
|
-
*
|
|
4090
|
-
* Whose pull request it is comes first, as it does everywhere else: one
|
|
4091
|
-
* somebody else opened is none of this tool's business, whatever is true of it.
|
|
4092
|
-
* A draft is next, because a pull request I have not offered to anybody is not
|
|
4093
|
-
* one to land however green it is.
|
|
4364
|
+
* A sweep under its heartbeat, which is how every command that sweeps runs one.
|
|
4094
4365
|
*
|
|
4095
|
-
*
|
|
4096
|
-
*
|
|
4097
|
-
*
|
|
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.
|
|
4098
4369
|
*/
|
|
4099
|
-
const
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
if (
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
const
|
|
4106
|
-
|
|
4107
|
-
};
|
|
4108
|
-
//#endregion
|
|
4109
|
-
//#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
|
+
});
|
|
4110
4378
|
/**
|
|
4111
|
-
*
|
|
4112
|
-
*
|
|
4113
|
-
* This is the write ADR 0008 is about, and the only one the tool makes that no
|
|
4114
|
-
* reflog of mine brings back. It is outside ADR 0002's three because it moves a
|
|
4115
|
-
* shared branch; everything 0002 bars - comment, reply, thread resolve, label,
|
|
4116
|
-
* review, approval, status - still holds here as it does everywhere.
|
|
4117
|
-
*
|
|
4118
|
-
* The threshold is two bars at one head: Ready, which is GitHub's opinion, and
|
|
4119
|
-
* my stamp, which is mine. Each is blind to what the other sees, so the write
|
|
4120
|
-
* that cannot be undone clears both.
|
|
4121
|
-
*
|
|
4122
|
-
* GitHub's half is read live from a fresh `pr view` rather than off the last
|
|
4123
|
-
* sweep, the way the rebase and re-run guards are. A stale verdict costs a
|
|
4124
|
-
* re-run some CI minutes; here it costs merging code nobody read. My half comes
|
|
4125
|
-
* from the state directory, because the review runs and the withdrawal live
|
|
4126
|
-
* there and are already scoped to the head this read just named.
|
|
4379
|
+
* Refreshes what mission control knows about every tracked PR.
|
|
4127
4380
|
*
|
|
4128
|
-
*
|
|
4129
|
-
*
|
|
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.
|
|
4130
4383
|
*/
|
|
4131
|
-
const
|
|
4132
|
-
const
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
const me = yield* viewer;
|
|
4137
|
-
const head = view.headRefOid;
|
|
4138
|
-
yield* refuse(decide$2({
|
|
4139
|
-
repo,
|
|
4140
|
-
number,
|
|
4141
|
-
head,
|
|
4142
|
-
mine: view.author?.login === me,
|
|
4143
|
-
draft: view.isDraft,
|
|
4144
|
-
reviewDecision: reviewDecisionOf(view.reviewDecision),
|
|
4145
|
-
checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
|
|
4146
|
-
mergeable: mergeabilityOf(view.mergeable),
|
|
4147
|
-
...yield* reviewedAt(repo, number, head, settings),
|
|
4148
|
-
withdrawnAt: yield* withdrawnAt(repo, number)
|
|
4149
|
-
}));
|
|
4150
|
-
yield* mergePr(repo, number);
|
|
4151
|
-
yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
|
|
4152
|
-
yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
|
|
4153
|
-
}, 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"));
|
|
4154
4389
|
//#endregion
|
|
4155
4390
|
//#region src/domain/pick.ts
|
|
4156
4391
|
/**
|
|
@@ -4275,13 +4510,11 @@ const Rerun = Schema.Struct({ head: Schema.String });
|
|
|
4275
4510
|
* The head a re-run was last asked for at on this pull request, or null where
|
|
4276
4511
|
* none has been.
|
|
4277
4512
|
*
|
|
4278
|
-
*
|
|
4279
|
-
* it again as nothing costs a flaky pull request one extra re-run, where failing
|
|
4280
|
-
* here would cost the command outright.
|
|
4513
|
+
* Forgetting one costs a flaky pull request one extra re-run.
|
|
4281
4514
|
*/
|
|
4282
4515
|
const rerunFor = Effect.fn("rerun.rerunFor")(function* (repo, number) {
|
|
4283
4516
|
const store = yield* storeFor("reruns", Rerun);
|
|
4284
|
-
const rerun = yield*
|
|
4517
|
+
const rerun = yield* remembered(store.get(prKey(repo, number)));
|
|
4285
4518
|
return Option.getOrNull(rerun)?.head ?? null;
|
|
4286
4519
|
});
|
|
4287
4520
|
/** Writes down that a re-run was asked for at `head`, which is the only head it caps. */
|
|
@@ -4370,7 +4603,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
|
|
|
4370
4603
|
* and a merge must never cost only that (ADR 0008).
|
|
4371
4604
|
*/
|
|
4372
4605
|
const picker = (dispatch) => Effect.fn("pick")(function* () {
|
|
4373
|
-
const report = yield*
|
|
4606
|
+
const report = yield* sweeping;
|
|
4374
4607
|
if (report.repos.length === 0) {
|
|
4375
4608
|
yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
|
|
4376
4609
|
return;
|
|
@@ -4433,13 +4666,13 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
|
|
|
4433
4666
|
* so what it has to say about one is where the pull request sits in it.
|
|
4434
4667
|
*/
|
|
4435
4668
|
const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(function* ({ pr }) {
|
|
4436
|
-
const
|
|
4437
|
-
const
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
yield* refuse(decide$
|
|
4669
|
+
const { number, repo, settings } = yield* forPr(pr);
|
|
4670
|
+
const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
|
|
4671
|
+
prView(repo, number),
|
|
4672
|
+
openPrs(repo),
|
|
4673
|
+
viewer
|
|
4674
|
+
]));
|
|
4675
|
+
yield* refuse(decide$2({
|
|
4443
4676
|
repo,
|
|
4444
4677
|
number,
|
|
4445
4678
|
base: view.baseRefName,
|
|
@@ -4472,7 +4705,7 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
|
|
|
4472
4705
|
return;
|
|
4473
4706
|
}
|
|
4474
4707
|
yield* Console.log(`${where} ${short(done.before)} → ${short(done.after)} rebased ${count(done.behind, "commit")} of ${view.baseRefName} and pushed with a lease`);
|
|
4475
|
-
}, Effect.catchTag(
|
|
4708
|
+
}, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
|
|
4476
4709
|
//#endregion
|
|
4477
4710
|
//#region src/cli/rerun.ts
|
|
4478
4711
|
/**
|
|
@@ -4494,11 +4727,8 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
|
|
|
4494
4727
|
* it is a pull request re-running itself until the minutes run out.
|
|
4495
4728
|
*/
|
|
4496
4729
|
const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(function* ({ pr }) {
|
|
4497
|
-
const
|
|
4498
|
-
const
|
|
4499
|
-
const settings = settingsFor(file, repo);
|
|
4500
|
-
const view = yield* prView(repo, number);
|
|
4501
|
-
const me = yield* viewer;
|
|
4730
|
+
const { number, repo, settings } = yield* forPr(pr);
|
|
4731
|
+
const [view, me] = yield* reading(`${repo}#${number}`, Effect.all([prView(repo, number), viewer]));
|
|
4502
4732
|
const unclassified = {
|
|
4503
4733
|
repo,
|
|
4504
4734
|
number,
|
|
@@ -4585,11 +4815,6 @@ const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
|
|
|
4585
4815
|
//#endregion
|
|
4586
4816
|
//#region src/cli/resolve.ts
|
|
4587
4817
|
const printFlag = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withDescription("Print the prompt a session would open on, and open none"));
|
|
4588
|
-
/** The domain's word on a conflict that is not one to open, as the command's own failure. */
|
|
4589
|
-
const allowed = (situation) => {
|
|
4590
|
-
const refused = decide(situation);
|
|
4591
|
-
return refused === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: refused }));
|
|
4592
|
-
};
|
|
4593
4818
|
/**
|
|
4594
4819
|
* A session on the conflict that stopped a rebase, in a worktree that is mine.
|
|
4595
4820
|
*
|
|
@@ -4614,13 +4839,14 @@ const resolve = Command.make("resolve", {
|
|
|
4614
4839
|
pr: prArgument,
|
|
4615
4840
|
print: printFlag
|
|
4616
4841
|
}, Effect.fn("resolve")(function* ({ pr, print }) {
|
|
4617
|
-
const
|
|
4618
|
-
const
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4842
|
+
const { number, repo, launcher } = yield* forPr(pr);
|
|
4843
|
+
const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
|
|
4844
|
+
prView(repo, number),
|
|
4845
|
+
openPrs(repo),
|
|
4846
|
+
viewer
|
|
4847
|
+
]));
|
|
4622
4848
|
const conflict = yield* conflictFor(repo, number);
|
|
4623
|
-
yield*
|
|
4849
|
+
yield* refuse(decide({
|
|
4624
4850
|
repo,
|
|
4625
4851
|
number,
|
|
4626
4852
|
mine: view.author?.login === me,
|
|
@@ -4629,7 +4855,7 @@ const resolve = Command.make("resolve", {
|
|
|
4629
4855
|
stack: stackOf(number, open),
|
|
4630
4856
|
head: view.headRefOid,
|
|
4631
4857
|
conflictAt: conflict === null ? null : conflict.head
|
|
4632
|
-
});
|
|
4858
|
+
}));
|
|
4633
4859
|
/** The conflict as the prompt takes it, around whichever paths are known by then. */
|
|
4634
4860
|
const conflicted = (paths) => ({
|
|
4635
4861
|
repo,
|
|
@@ -4662,7 +4888,7 @@ const resolve = Command.make("resolve", {
|
|
|
4662
4888
|
yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
|
|
4663
4889
|
yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
|
|
4664
4890
|
const ended = yield* steeredSession({
|
|
4665
|
-
launcher
|
|
4891
|
+
launcher,
|
|
4666
4892
|
directory: worktree.directory,
|
|
4667
4893
|
prompt: yield* promptFor(conflicted(stopped.paths))
|
|
4668
4894
|
});
|
|
@@ -4676,12 +4902,7 @@ const resolve = Command.make("resolve", {
|
|
|
4676
4902
|
``
|
|
4677
4903
|
], (line) => Console.log(line));
|
|
4678
4904
|
yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
|
|
4679
|
-
}, Effect.catchTag(
|
|
4680
|
-
...userFacing,
|
|
4681
|
-
"GitFailed",
|
|
4682
|
-
"WorktreeHeld",
|
|
4683
|
-
"AgentFailed"
|
|
4684
|
-
], 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"));
|
|
4685
4906
|
//#endregion
|
|
4686
4907
|
//#region src/adapters/notify.ts
|
|
4687
4908
|
/** A string as AppleScript spells one, so a quotation mark cannot end it early. */
|
|
@@ -4701,73 +4922,6 @@ const announce = Effect.fn("notify.announce")(function* (title, message) {
|
|
|
4701
4922
|
yield* Effect.ignore(capture("osascript", ["-e", `display notification ${quoted(message)} with title ${quoted(title)}`]));
|
|
4702
4923
|
});
|
|
4703
4924
|
//#endregion
|
|
4704
|
-
//#region src/adapters/progress.ts
|
|
4705
|
-
/** The frames of the spinner, in the order they turn. */
|
|
4706
|
-
const frames = [
|
|
4707
|
-
"⠋",
|
|
4708
|
-
"⠙",
|
|
4709
|
-
"⠹",
|
|
4710
|
-
"⠸",
|
|
4711
|
-
"⠼",
|
|
4712
|
-
"⠴",
|
|
4713
|
-
"⠦",
|
|
4714
|
-
"⠧",
|
|
4715
|
-
"⠇",
|
|
4716
|
-
"⠏"
|
|
4717
|
-
];
|
|
4718
|
-
/** How long one frame is on the screen. */
|
|
4719
|
-
const frameFor = Duration.millis(120);
|
|
4720
|
-
/** A stretch of time as a terminal says it: `1m12s`, or `9s` under the minute. */
|
|
4721
|
-
const elapsed = (millis) => {
|
|
4722
|
-
const seconds = Math.floor(millis / 1e3);
|
|
4723
|
-
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
4724
|
-
};
|
|
4725
|
-
/**
|
|
4726
|
-
* Runs `use` while the screen says it is still going, and hands `use` the way
|
|
4727
|
-
* to report what the run reached for.
|
|
4728
|
-
*
|
|
4729
|
-
* A review takes minutes, and a terminal that prints nothing for minutes is one
|
|
4730
|
-
* I stop trusting. What it printed instead was a line per tool call, which is a
|
|
4731
|
-
* wall of `· Bash` that says as little as silence did. This keeps one line and
|
|
4732
|
-
* rewrites it: the spinner says the run is alive, the counts say how far it has
|
|
4733
|
-
* got, and the line is gone when the run is over, so what stays on the screen is
|
|
4734
|
-
* the report.
|
|
4735
|
-
*
|
|
4736
|
-
* How that line reads is `reads` and not this module's business. What a count
|
|
4737
|
-
* is worth saying belongs to the command that is counting, and a screen that
|
|
4738
|
-
* worded it here would need the words a command already has.
|
|
4739
|
-
*
|
|
4740
|
-
* Where there is no screen to measure - a pipe, a CI log, a test - the counts
|
|
4741
|
-
* would be a mess of half-drawn lines, so the tools go out one to a line as
|
|
4742
|
-
* they did before. `columns` is zero exactly there.
|
|
4743
|
-
*/
|
|
4744
|
-
const spinning = Effect.fnUntraced(function* (reads, use) {
|
|
4745
|
-
const terminal = yield* Terminal.Terminal;
|
|
4746
|
-
const columns = yield* terminal.columns;
|
|
4747
|
-
if (columns === 0) return yield* use((tool) => Console.log(` · ${tool}`));
|
|
4748
|
-
let doing = {
|
|
4749
|
-
tools: 0,
|
|
4750
|
-
subagents: 0
|
|
4751
|
-
};
|
|
4752
|
-
const onTool = (tool) => Effect.sync(() => {
|
|
4753
|
-
doing = {
|
|
4754
|
-
tools: doing.tools + 1,
|
|
4755
|
-
subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
|
|
4756
|
-
};
|
|
4757
|
-
});
|
|
4758
|
-
const started = yield* Clock.currentTimeMillis;
|
|
4759
|
-
const draw = (text) => Effect.ignore(terminal.display(`\r${text.slice(0, columns - 1).padEnd(columns - 1)}`));
|
|
4760
|
-
const frame = (since, turn) => `${frames[turn % frames.length]} ${reads(doing, elapsed(since))}`;
|
|
4761
|
-
yield* draw(frame(0, 0));
|
|
4762
|
-
const turning = yield* Effect.forkChild(Effect.gen(function* () {
|
|
4763
|
-
for (let turn = 1;; turn = turn + 1) {
|
|
4764
|
-
yield* Effect.sleep(frameFor);
|
|
4765
|
-
yield* draw(frame((yield* Clock.currentTimeMillis) - started, turn));
|
|
4766
|
-
}
|
|
4767
|
-
}));
|
|
4768
|
-
return yield* Effect.onExit(use(onTool), () => Effect.flatMap(Fiber.interrupt(turning), () => Effect.ignore(terminal.display(`\r${" ".repeat(columns - 1)}\r`))));
|
|
4769
|
-
});
|
|
4770
|
-
//#endregion
|
|
4771
4925
|
//#region src/domain/persona.ts
|
|
4772
4926
|
/**
|
|
4773
4927
|
* The reviewer persona, derived from Addy Osmani's `code-reviewer` agent
|
|
@@ -4859,8 +5013,8 @@ const turnFor = (review, about) => review.command === null ? {
|
|
|
4859
5013
|
};
|
|
4860
5014
|
//#endregion
|
|
4861
5015
|
//#region src/cli/review.ts
|
|
4862
|
-
/** What the
|
|
4863
|
-
const saying = (doing
|
|
5016
|
+
/** What the heartbeat says a run has got through, while it is still going. */
|
|
5017
|
+
const saying = (doing) => (since) => [
|
|
4864
5018
|
"reviewing",
|
|
4865
5019
|
count(doing.tools, "tool"),
|
|
4866
5020
|
doing.subagents === 0 ? null : count(doing.subagents, "subagent"),
|
|
@@ -4925,13 +5079,23 @@ const spending = (turn, model) => [
|
|
|
4925
5079
|
*/
|
|
4926
5080
|
const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
|
|
4927
5081
|
const { directory, launcher, model, turn } = options;
|
|
4928
|
-
|
|
5082
|
+
let doing = {
|
|
5083
|
+
tools: 0,
|
|
5084
|
+
subagents: 0
|
|
5085
|
+
};
|
|
5086
|
+
const run = yield* beating(saying(doing), (says) => reviewTurns({
|
|
4929
5087
|
launcher,
|
|
4930
5088
|
directory,
|
|
4931
5089
|
turn,
|
|
4932
5090
|
model,
|
|
4933
5091
|
jsonSchema,
|
|
4934
|
-
onTool
|
|
5092
|
+
onTool: (tool) => {
|
|
5093
|
+
doing = {
|
|
5094
|
+
tools: doing.tools + 1,
|
|
5095
|
+
subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
|
|
5096
|
+
};
|
|
5097
|
+
return says(saying(doing), ` · ${tool}`);
|
|
5098
|
+
}
|
|
4935
5099
|
}));
|
|
4936
5100
|
const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
|
|
4937
5101
|
const reported = yield* Effect.result(Effect.flatMap(answered, (output) => Schema.decodeUnknownEffect(Reported)(output)));
|
|
@@ -5014,10 +5178,7 @@ const review = Command.make("review", {
|
|
|
5014
5178
|
commandOnly: commandOnlyFlag,
|
|
5015
5179
|
force: forceFlag$1
|
|
5016
5180
|
}, Effect.fn("review")(function* ({ command, commandOnly, effort, force, model, pr, prompt, promptOnly }) {
|
|
5017
|
-
const
|
|
5018
|
-
const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
|
|
5019
|
-
const settings = settingsFor(file, repo);
|
|
5020
|
-
const launcher = launcherOf(file);
|
|
5181
|
+
const { number, repo, settings, launcher } = yield* forPr(pr);
|
|
5021
5182
|
const asked = yield* asking({
|
|
5022
5183
|
settings,
|
|
5023
5184
|
command,
|
|
@@ -5091,7 +5252,7 @@ const review = Command.make("review", {
|
|
|
5091
5252
|
yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
|
|
5092
5253
|
yield* unreported(run, number);
|
|
5093
5254
|
}).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
|
|
5094
|
-
}, Effect.catchTag(
|
|
5255
|
+
}, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
|
|
5095
5256
|
//#endregion
|
|
5096
5257
|
//#region src/cli/stamp.ts
|
|
5097
5258
|
const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
|
|
@@ -5116,8 +5277,7 @@ const stampCommand = Command.make("stamp", {
|
|
|
5116
5277
|
pr: prArgument,
|
|
5117
5278
|
withdraw: withdrawFlag
|
|
5118
5279
|
}, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
|
|
5119
|
-
const
|
|
5120
|
-
const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
|
|
5280
|
+
const { number, repo } = yield* forPr(pr);
|
|
5121
5281
|
const facts = yield* swept(repo, number);
|
|
5122
5282
|
const where = `${repo}#${number} ${short(facts.head)}`;
|
|
5123
5283
|
if (byHand) {
|
|
@@ -5157,7 +5317,7 @@ const lines = (grouped, stamped, paint) => {
|
|
|
5157
5317
|
* It sweeps first, every time: a table I read is never one I forgot to refresh.
|
|
5158
5318
|
*/
|
|
5159
5319
|
const status = Command.make("status", {}, Effect.fn("status")(function* () {
|
|
5160
|
-
const report = yield*
|
|
5320
|
+
const report = yield* sweeping;
|
|
5161
5321
|
if (report.repos.length === 0) {
|
|
5162
5322
|
yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
|
|
5163
5323
|
return;
|
|
@@ -5249,7 +5409,7 @@ const uninstall = Command.make("uninstall", {
|
|
|
5249
5409
|
* Running from source leaves the constant undeclared rather than undefined, so
|
|
5250
5410
|
* the check has to be `typeof` and the fallback is what a test reads.
|
|
5251
5411
|
*/
|
|
5252
|
-
const version = "0.
|
|
5412
|
+
const version = "0.5.1";
|
|
5253
5413
|
/** Where the project lives, printed beside the version in the header. */
|
|
5254
5414
|
const projectUrl = "github.com/dominikwozniak/dw-mc";
|
|
5255
5415
|
const subcommands = [
|