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