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