dw-mc 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/bin.js +702 -469
- package/dist/bin.js.map +1 -1
- package/package.json +3 -2
package/dist/bin.js
CHANGED
|
@@ -509,70 +509,6 @@ const textStoreFor = Effect.fn("store.textStoreFor")(function* (namespace) {
|
|
|
509
509
|
/** The state directory on disk. */
|
|
510
510
|
const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => KeyValueStore.layerFileSystem(directory)));
|
|
511
511
|
KeyValueStore.layerMemory;
|
|
512
|
-
//#endregion
|
|
513
|
-
//#region src/domain/reference.ts
|
|
514
|
-
/** `owner/name#12`, or `12` on its own. */
|
|
515
|
-
const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
|
|
516
|
-
/**
|
|
517
|
-
* A segment of nothing but dots, which no repository is called.
|
|
518
|
-
*
|
|
519
|
-
* The repository names a directory under the state directory before it names
|
|
520
|
-
* anything else, so `../x` would be a way out of it.
|
|
521
|
-
*/
|
|
522
|
-
const onlyDots = /^\.+$/;
|
|
523
|
-
/**
|
|
524
|
-
* The pull request a reference names.
|
|
525
|
-
*
|
|
526
|
-
* A reference that spells its repository out is taken as it is, registered or
|
|
527
|
-
* not: reviewing someone else's pull request is a thing to ask for, and the
|
|
528
|
-
* settings a repository nothing registered gets are the global defaults.
|
|
529
|
-
*/
|
|
530
|
-
const resolve$1 = (text, registered) => {
|
|
531
|
-
const found = spelled.exec(text);
|
|
532
|
-
const number = found?.[2];
|
|
533
|
-
if (number === void 0) return {
|
|
534
|
-
_tag: "unreadable",
|
|
535
|
-
text
|
|
536
|
-
};
|
|
537
|
-
const spelledRepo = found?.[1];
|
|
538
|
-
if (spelledRepo !== void 0 && spelledRepo.split("/").some((segment) => onlyDots.test(segment))) return {
|
|
539
|
-
_tag: "unreadable",
|
|
540
|
-
text
|
|
541
|
-
};
|
|
542
|
-
const repo = spelledRepo ?? (registered.length === 1 ? registered[0] : void 0);
|
|
543
|
-
if (repo === void 0) return {
|
|
544
|
-
_tag: "ambiguous",
|
|
545
|
-
repos: registered
|
|
546
|
-
};
|
|
547
|
-
return {
|
|
548
|
-
_tag: "resolved",
|
|
549
|
-
repo,
|
|
550
|
-
number: Number(number)
|
|
551
|
-
};
|
|
552
|
-
};
|
|
553
|
-
//#endregion
|
|
554
|
-
//#region src/cli/pr.ts
|
|
555
|
-
/** The pull request a command acts on, named the way I actually type it. */
|
|
556
|
-
const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
|
|
557
|
-
/** What to say about a reference that named no one pull request. */
|
|
558
|
-
const whyNothingNamed = (reference) => {
|
|
559
|
-
if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
|
|
560
|
-
const example = `${reference.repos[0] ?? "owner/name"}#28`;
|
|
561
|
-
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}.`;
|
|
562
|
-
};
|
|
563
|
-
/** The pull request the argument names, or the sentence saying why it names none. */
|
|
564
|
-
const named = (pr, registered) => {
|
|
565
|
-
const reference = resolve$1(pr, registered);
|
|
566
|
-
return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
|
|
567
|
-
};
|
|
568
|
-
/**
|
|
569
|
-
* A domain guard's word, as the command's own failure.
|
|
570
|
-
*
|
|
571
|
-
* Every guard in the tool answers the same shape - the sentence saying why not,
|
|
572
|
-
* or null - so turning that answer into a refusal is spelled once here rather
|
|
573
|
-
* than beside each command that asks one.
|
|
574
|
-
*/
|
|
575
|
-
const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
|
|
576
512
|
new TextEncoder();
|
|
577
513
|
/** A program that ran but ended badly. */
|
|
578
514
|
var CommandFailed = class extends Schema.TaggedError()("CommandFailed", {
|
|
@@ -802,7 +738,7 @@ const Comments = Schema.fromJsonString(Schema.Array(Schema.Struct({
|
|
|
802
738
|
type: Schema.String
|
|
803
739
|
}))
|
|
804
740
|
})));
|
|
805
|
-
const comments = (label, path) => readJson(label, "gh", ["api", path], Comments).pipe(Effect.map((all) => all.flatMap((comment) => comment.user === null ? [] : [{
|
|
741
|
+
const comments$1 = (label, path) => readJson(label, "gh", ["api", path], Comments).pipe(Effect.map((all) => all.flatMap((comment) => comment.user === null ? [] : [{
|
|
806
742
|
login: comment.user.login,
|
|
807
743
|
bot: comment.user.type === "Bot",
|
|
808
744
|
at: comment.created_at
|
|
@@ -819,7 +755,7 @@ const comments = (label, path) => readJson(label, "gh", ["api", path], Comments)
|
|
|
819
755
|
*/
|
|
820
756
|
const prComments = Effect.fnUntraced(function* (repo, number) {
|
|
821
757
|
const page = "per_page=100";
|
|
822
|
-
const [conversation, onDiff] = yield* Effect.all([comments("api issue comments", `repos/${repo}/issues/${number}/comments?${page}`), comments("api review comments", `repos/${repo}/pulls/${number}/comments?${page}`)], { concurrency: 2 });
|
|
758
|
+
const [conversation, onDiff] = yield* Effect.all([comments$1("api issue comments", `repos/${repo}/issues/${number}/comments?${page}`), comments$1("api review comments", `repos/${repo}/pulls/${number}/comments?${page}`)], { concurrency: 2 });
|
|
823
759
|
return [...conversation, ...onDiff];
|
|
824
760
|
});
|
|
825
761
|
const Reviews = Schema.fromJsonString(Schema.Array(Schema.Struct({
|
|
@@ -932,198 +868,343 @@ const mergePr = Effect.fnUntraced(function* (repo, number) {
|
|
|
932
868
|
}));
|
|
933
869
|
});
|
|
934
870
|
//#endregion
|
|
935
|
-
//#region src/adapters/
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
871
|
+
//#region src/adapters/conversation.ts
|
|
872
|
+
const Actor = Schema.NullOr(Schema.Struct({
|
|
873
|
+
login: Schema.String,
|
|
874
|
+
__typename: Schema.String
|
|
875
|
+
}));
|
|
876
|
+
const Said = Schema.Struct({
|
|
877
|
+
author: Actor,
|
|
878
|
+
body: Schema.String,
|
|
879
|
+
createdAt: Schema.DateTimeUtcFromString
|
|
880
|
+
});
|
|
881
|
+
const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
|
|
882
|
+
comments: Schema.Struct({ nodes: Schema.Array(Said) }),
|
|
883
|
+
reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
|
|
884
|
+
author: Actor,
|
|
885
|
+
body: Schema.String,
|
|
886
|
+
submittedAt: Schema.NullOr(Schema.DateTimeUtcFromString)
|
|
887
|
+
})) }),
|
|
888
|
+
reviewThreads: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
|
|
889
|
+
isResolved: Schema.Boolean,
|
|
890
|
+
isOutdated: Schema.Boolean,
|
|
891
|
+
path: Schema.NullOr(Schema.String),
|
|
892
|
+
line: Schema.NullOr(Schema.Int),
|
|
893
|
+
comments: Schema.Struct({ nodes: Schema.Array(Said) })
|
|
894
|
+
})) })
|
|
895
|
+
}) }) }) }));
|
|
896
|
+
const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
|
|
897
|
+
login: said.author.login,
|
|
898
|
+
bot: said.author.__typename === "Bot",
|
|
899
|
+
at,
|
|
900
|
+
body: said.body.trim()
|
|
901
|
+
}];
|
|
902
|
+
const byTime = (self, other) => DateTime.Order(self.at, other.at);
|
|
903
|
+
/**
|
|
904
|
+
* A pull request's whole conversation: the comments on it, the bodies of the
|
|
905
|
+
* reviews, and every thread on the diff with whether it is settled.
|
|
906
|
+
*
|
|
907
|
+
* GraphQL rather than the two REST endpoints a sweep reads, because resolution
|
|
908
|
+
* is not in REST at all: a review comment's payload carries `body`, `path`,
|
|
909
|
+
* `line`, `diff_hunk` and `side`, and nothing saying whether somebody closed
|
|
910
|
+
* the thread it belongs to. A thread that was settled a week ago is not
|
|
911
|
+
* something to answer, so the state that says so has to arrive with it.
|
|
912
|
+
*
|
|
913
|
+
* The pull request's own comments and the reviews' bodies come back as one
|
|
914
|
+
* strand under no path, in the order they were written: they are one
|
|
915
|
+
* conversation as it happened, and which endpoint each line came from is an
|
|
916
|
+
* accident of GitHub's model rather than anything to read.
|
|
917
|
+
*
|
|
918
|
+
* `__typename` is what says a bot is a bot, the way `user.type` does in REST.
|
|
919
|
+
*/
|
|
920
|
+
const prConversation = Effect.fnUntraced(function* (repo, number) {
|
|
921
|
+
const [owner = repo, name = repo] = repo.split("/");
|
|
922
|
+
const pr = (yield* readJson("api graphql", "gh", [
|
|
923
|
+
"api",
|
|
924
|
+
"graphql",
|
|
925
|
+
"-f",
|
|
926
|
+
`query=query($owner:String!,$name:String!,$number:Int!){
|
|
927
|
+
repository(owner:$owner,name:$name){
|
|
928
|
+
pullRequest(number:$number){
|
|
929
|
+
comments(last:100){nodes{author{login __typename} body createdAt}}
|
|
930
|
+
reviews(last:100){nodes{author{login __typename} body submittedAt}}
|
|
931
|
+
reviewThreads(last:100){nodes{
|
|
932
|
+
isResolved isOutdated path line
|
|
933
|
+
comments(first:100){nodes{author{login __typename} body createdAt}}
|
|
934
|
+
}}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}`,
|
|
938
|
+
"-F",
|
|
939
|
+
`owner=${owner}`,
|
|
940
|
+
"-F",
|
|
941
|
+
`name=${name}`,
|
|
942
|
+
"-F",
|
|
943
|
+
`number=${number}`
|
|
944
|
+
], Conversation)).data.repository.pullRequest;
|
|
945
|
+
const conversation = [...pr.comments.nodes.flatMap((it) => remark(it, it.createdAt)), ...pr.reviews.nodes.flatMap((it) => remark(it, it.submittedAt))].toSorted(byTime);
|
|
946
|
+
const threads = pr.reviewThreads.nodes.map((it) => ({
|
|
947
|
+
path: it.path,
|
|
948
|
+
line: it.line,
|
|
949
|
+
resolved: it.isResolved,
|
|
950
|
+
outdated: it.isOutdated,
|
|
951
|
+
comments: it.comments.nodes.flatMap((comment) => remark(comment, comment.createdAt)).toSorted(byTime)
|
|
952
|
+
}));
|
|
953
|
+
return [...conversation.length === 0 ? [] : [{
|
|
954
|
+
path: null,
|
|
955
|
+
line: null,
|
|
956
|
+
resolved: false,
|
|
957
|
+
outdated: false,
|
|
958
|
+
comments: conversation
|
|
959
|
+
}], ...threads];
|
|
960
|
+
});
|
|
961
|
+
//#endregion
|
|
962
|
+
//#region src/domain/moment.ts
|
|
963
|
+
const isLater = Order.isGreaterThan(DateTime.Order);
|
|
964
|
+
/** Whether `self` happened after `other`, counting never as before anything. */
|
|
965
|
+
const isAfter = (self, other) => Predicate.isNotNull(self) && (other === null || isLater(self, other));
|
|
966
|
+
/** The later of the two. */
|
|
967
|
+
const later = (self, other) => isAfter(self, other) ? self : other;
|
|
968
|
+
/** Whether the two are the same moment, counting never as the same as never. */
|
|
969
|
+
const isSame = (self, other) => self === null || other === null ? self === other : DateTime.Equivalence(self, other);
|
|
970
|
+
/** The latest of many, or never when there are none. */
|
|
971
|
+
const newest = (moments) => moments.reduce(later, null);
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/domain/bucket.ts
|
|
974
|
+
/** How far GitHub has got towards letting a tracked PR merge. */
|
|
975
|
+
const Mergeability = Schema.Literals([
|
|
976
|
+
"mergeable",
|
|
977
|
+
"conflicting",
|
|
978
|
+
"unknown"
|
|
948
979
|
]);
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
"
|
|
952
|
-
"
|
|
953
|
-
"
|
|
954
|
-
"
|
|
955
|
-
|
|
980
|
+
/** What the reviewers have decided, or that nobody is required to. */
|
|
981
|
+
const ReviewDecision = Schema.Literals([
|
|
982
|
+
"approved",
|
|
983
|
+
"changes-requested",
|
|
984
|
+
"review-required",
|
|
985
|
+
"none"
|
|
986
|
+
]);
|
|
987
|
+
/** What CI says about the current head. */
|
|
988
|
+
const ChecksState = Schema.Literals([
|
|
989
|
+
"green",
|
|
990
|
+
"red",
|
|
991
|
+
"pending",
|
|
992
|
+
"none"
|
|
956
993
|
]);
|
|
957
|
-
const nameOf = (entry) => entry.name ?? entry.context ?? "";
|
|
958
|
-
const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
|
|
959
|
-
const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
|
|
960
994
|
/**
|
|
961
|
-
*
|
|
962
|
-
* nothing has failed yet, green when every check that counts has passed.
|
|
995
|
+
* Everything the bucket rules are allowed to know about a tracked PR.
|
|
963
996
|
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
997
|
+
* It is a schema because a sweep writes it to the state directory and reads it
|
|
998
|
+
* back on the next one: the same facts that decide a bucket are what a quiet PR
|
|
999
|
+
* is recognised by.
|
|
966
1000
|
*/
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1001
|
+
const Facts = Schema.Struct({
|
|
1002
|
+
repo: Schema.String,
|
|
1003
|
+
number: Schema.Int,
|
|
1004
|
+
title: Schema.String,
|
|
1005
|
+
url: Schema.String,
|
|
1006
|
+
/** Shown, never acted on unless I ask. */
|
|
1007
|
+
draft: Schema.Boolean,
|
|
1008
|
+
/** The head commit every other fact here is about. */
|
|
1009
|
+
head: Schema.String,
|
|
1010
|
+
mergeable: Mergeability,
|
|
1011
|
+
reviewDecision: ReviewDecision,
|
|
1012
|
+
checks: ChecksState,
|
|
1013
|
+
/** Why the flaky classifier excuses this red CI, or null where it does not. */
|
|
1014
|
+
ciFlaky: Schema.NullOr(Schema.String),
|
|
1015
|
+
/** The head a rebase onto the base conflicted at, or null where none has. */
|
|
1016
|
+
rebaseConflictAt: Schema.NullOr(Schema.String),
|
|
1017
|
+
/** The newest comment from a person who is not me, bots excluded. */
|
|
1018
|
+
newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
|
|
1019
|
+
myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
|
|
1020
|
+
myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
|
|
1021
|
+
/** The head a review run has already covered, or null where none has. */
|
|
1022
|
+
reviewRunHead: Schema.NullOr(Schema.String),
|
|
1023
|
+
/** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
|
|
1024
|
+
blockingFindings: Schema.Int
|
|
1025
|
+
});
|
|
1026
|
+
Schema.Literals([
|
|
1027
|
+
"needs-me",
|
|
1028
|
+
"needs-review-run",
|
|
1029
|
+
"waiting-on-others",
|
|
1030
|
+
"ready"
|
|
1031
|
+
]);
|
|
1032
|
+
/** The buckets in the order I act on them: the top of the table is my next move. */
|
|
1033
|
+
const order = [
|
|
1034
|
+
"needs-me",
|
|
1035
|
+
"needs-review-run",
|
|
1036
|
+
"waiting-on-others",
|
|
1037
|
+
"ready"
|
|
1038
|
+
];
|
|
974
1039
|
/**
|
|
975
|
-
*
|
|
1040
|
+
* Why a PR is mine to move when somebody has said something I have not
|
|
1041
|
+
* answered.
|
|
976
1042
|
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
1043
|
+
* It is named because it is read twice: here, where it puts the PR in Needs me,
|
|
1044
|
+
* and by `dw-mc comments`, which says what settles that one branch of the
|
|
1045
|
+
* bucket. A sentence matched from the other side of the tool is a rule that
|
|
1046
|
+
* breaks on a reword.
|
|
979
1047
|
*/
|
|
980
|
-
const
|
|
1048
|
+
const unanswered = "a comment I have not answered";
|
|
981
1049
|
/**
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
* what the logs endpoint takes and the run id is what `gh run rerun` takes, so
|
|
986
|
-
* the two ids the tool needs are the two halves of one URL and are read
|
|
987
|
-
* together. A commit status points somewhere else entirely, which is null:
|
|
988
|
-
* there is no log of ours to read and no run of ours to re-run.
|
|
1050
|
+
* The first of the rules that makes a PR mine to move, or null when none
|
|
1051
|
+
* does. The order is the order I would fix them in: a conflict makes every
|
|
1052
|
+
* other signal on the PR stale, and a red build is worth more than a comment.
|
|
989
1053
|
*/
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
}
|
|
1054
|
+
const needsMe = (facts) => {
|
|
1055
|
+
if (facts.mergeable === "conflicting") return "merge conflict";
|
|
1056
|
+
if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
|
|
1057
|
+
if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
|
|
1058
|
+
if (facts.reviewDecision === "changes-requested") return "changes requested";
|
|
1059
|
+
if (facts.blockingFindings > 0) return `${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`;
|
|
1060
|
+
if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
|
|
1061
|
+
return null;
|
|
996
1062
|
};
|
|
997
|
-
const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
|
|
998
1063
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1064
|
+
* What is actually true of a PR nothing is waiting on.
|
|
1065
|
+
*
|
|
1066
|
+
* Ready is reached by having no reason not to be, so the reason says only what
|
|
1067
|
+
* holds: a repository that requires no reviewer produces no approval, and a
|
|
1068
|
+
* pull request with no CI at all is not green.
|
|
1069
|
+
*
|
|
1070
|
+
* A red CI the classifier excused is said out loud, because GitHub does not
|
|
1071
|
+
* excuse it: the check is still red, and Ready is what `dw-mc merge` reads.
|
|
1002
1072
|
*/
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
"
|
|
1006
|
-
"
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1012
|
-
const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
|
|
1013
|
-
/** How far back to look for a run that reached a verdict at all. */
|
|
1014
|
-
const recentRuns = 5;
|
|
1015
|
-
/** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
|
|
1016
|
-
const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
|
|
1017
|
-
/** A run that decided something. A skipped or cancelled run says nothing either way. */
|
|
1018
|
-
const verdicts = /* @__PURE__ */ new Set([
|
|
1019
|
-
"failure",
|
|
1020
|
-
"timed_out",
|
|
1021
|
-
"success"
|
|
1022
|
-
]);
|
|
1073
|
+
const readyReason = (facts) => {
|
|
1074
|
+
const held = [
|
|
1075
|
+
facts.reviewDecision === "approved" ? "approved" : null,
|
|
1076
|
+
facts.checks === "green" ? "green" : null,
|
|
1077
|
+
facts.mergeable === "mergeable" ? "mergeable" : null
|
|
1078
|
+
].filter((it) => it !== null);
|
|
1079
|
+
const standing = held.length === 0 ? "nothing left to wait on" : held.join(", ");
|
|
1080
|
+
return facts.checks === "red" && facts.ciFlaky !== null ? `${standing} (red CI called flaky: ${facts.ciFlaky})` : standing;
|
|
1081
|
+
};
|
|
1023
1082
|
/**
|
|
1024
|
-
*
|
|
1083
|
+
* The bucket a tracked PR sits in, and the reason for it.
|
|
1025
1084
|
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*/
|
|
1031
|
-
const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
|
|
1032
|
-
const newest = (yield* readJson("run list", "gh", [
|
|
1033
|
-
"run",
|
|
1034
|
-
"list",
|
|
1035
|
-
"--repo",
|
|
1036
|
-
repo,
|
|
1037
|
-
"--branch",
|
|
1038
|
-
branch,
|
|
1039
|
-
"--workflow",
|
|
1040
|
-
workflow,
|
|
1041
|
-
"--limit",
|
|
1042
|
-
String(recentRuns),
|
|
1043
|
-
"--json",
|
|
1044
|
-
"conclusion"
|
|
1045
|
-
], Runs)).find((run) => verdicts.has(run.conclusion));
|
|
1046
|
-
return newest !== void 0 && failedRun.has(newest.conclusion);
|
|
1047
|
-
});
|
|
1048
|
-
const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
|
|
1049
|
-
/** The repository paths a pull request changes. */
|
|
1050
|
-
const prFiles = Effect.fnUntraced(function* (repo, number) {
|
|
1051
|
-
return (yield* readJson("pr view files", "gh", [
|
|
1052
|
-
"pr",
|
|
1053
|
-
"view",
|
|
1054
|
-
String(number),
|
|
1055
|
-
"--repo",
|
|
1056
|
-
repo,
|
|
1057
|
-
"--json",
|
|
1058
|
-
"files"
|
|
1059
|
-
], PrFiles)).files.map((file) => file.path);
|
|
1060
|
-
});
|
|
1061
|
-
/**
|
|
1062
|
-
* How much of a failing job's log is kept.
|
|
1085
|
+
* This is the single place the bucket rules exist. Every tracked PR lands in
|
|
1086
|
+
* exactly one bucket, so the rules are tried in priority order and the first
|
|
1087
|
+
* that claims the PR wins: a PR that both needs a review run and has changes
|
|
1088
|
+
* requested is mine to move, not the runner's.
|
|
1063
1089
|
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1090
|
+
* Ready does not insist on an approval, because a repository that requires no
|
|
1091
|
+
* reviewer never produces one. What it insists on is that nobody else has been
|
|
1092
|
+
* asked and is yet to answer.
|
|
1067
1093
|
*/
|
|
1068
|
-
const
|
|
1094
|
+
const place = (facts) => {
|
|
1095
|
+
const mine = needsMe(facts);
|
|
1096
|
+
if (mine !== null) return {
|
|
1097
|
+
bucket: "needs-me",
|
|
1098
|
+
reason: mine
|
|
1099
|
+
};
|
|
1100
|
+
if (facts.reviewRunHead !== facts.head) return {
|
|
1101
|
+
bucket: "needs-review-run",
|
|
1102
|
+
reason: "no review run on this head"
|
|
1103
|
+
};
|
|
1104
|
+
if (facts.reviewDecision === "review-required") return {
|
|
1105
|
+
bucket: "waiting-on-others",
|
|
1106
|
+
reason: "a review from someone else"
|
|
1107
|
+
};
|
|
1108
|
+
if (facts.checks === "pending") return {
|
|
1109
|
+
bucket: "waiting-on-others",
|
|
1110
|
+
reason: "CI is still running"
|
|
1111
|
+
};
|
|
1112
|
+
return {
|
|
1113
|
+
bucket: "ready",
|
|
1114
|
+
reason: readyReason(facts)
|
|
1115
|
+
};
|
|
1116
|
+
};
|
|
1117
|
+
const group = (facts) => {
|
|
1118
|
+
const placed = facts.map((it) => ({
|
|
1119
|
+
facts: it,
|
|
1120
|
+
placement: place(it)
|
|
1121
|
+
})).toSorted((a, b) => a.facts.repo.localeCompare(b.facts.repo) || a.facts.number - b.facts.number);
|
|
1122
|
+
return order.map((bucket) => ({
|
|
1123
|
+
bucket,
|
|
1124
|
+
placed: placed.filter((it) => it.placement.bucket === bucket)
|
|
1125
|
+
})).filter((bucket) => bucket.placed.length > 0);
|
|
1126
|
+
};
|
|
1127
|
+
//#endregion
|
|
1128
|
+
//#region src/domain/reference.ts
|
|
1129
|
+
/** `owner/name#12`, or `12` on its own. */
|
|
1130
|
+
const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
|
|
1069
1131
|
/**
|
|
1070
|
-
*
|
|
1132
|
+
* A segment of nothing but dots, which no repository is called.
|
|
1071
1133
|
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
* endpoint answers with the plain log once the flag is passed.
|
|
1134
|
+
* The repository names a directory under the state directory before it names
|
|
1135
|
+
* anything else, so `../x` would be a way out of it.
|
|
1075
1136
|
*/
|
|
1076
|
-
const
|
|
1077
|
-
const log = yield* capture("gh", [
|
|
1078
|
-
"api",
|
|
1079
|
-
`repos/${repo}/actions/jobs/${jobId}/logs`,
|
|
1080
|
-
"--allow-escape-sequences"
|
|
1081
|
-
]).pipe(Effect.catchTags({
|
|
1082
|
-
PlatformError: (error) => Effect.fail(unavailable(error)),
|
|
1083
|
-
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
1084
|
-
command: "api job logs",
|
|
1085
|
-
detail: error.stderr
|
|
1086
|
-
}))
|
|
1087
|
-
}));
|
|
1088
|
-
return log.length <= logTailBytes ? log : log.slice(-65536);
|
|
1089
|
-
});
|
|
1137
|
+
const onlyDots = /^\.+$/;
|
|
1090
1138
|
/**
|
|
1091
|
-
* The
|
|
1139
|
+
* The pull request a reference names.
|
|
1092
1140
|
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1141
|
+
* A reference that spells its repository out is taken as it is, registered or
|
|
1142
|
+
* not: reviewing someone else's pull request is a thing to ask for, and the
|
|
1143
|
+
* settings a repository nothing registered gets are the global defaults.
|
|
1144
|
+
*/
|
|
1145
|
+
const resolve$1 = (text, registered) => {
|
|
1146
|
+
const found = spelled.exec(text);
|
|
1147
|
+
const number = found?.[2];
|
|
1148
|
+
if (number === void 0) return {
|
|
1149
|
+
_tag: "unreadable",
|
|
1150
|
+
text
|
|
1151
|
+
};
|
|
1152
|
+
const spelledRepo = found?.[1];
|
|
1153
|
+
if (spelledRepo !== void 0 && spelledRepo.split("/").some((segment) => onlyDots.test(segment))) return {
|
|
1154
|
+
_tag: "unreadable",
|
|
1155
|
+
text
|
|
1156
|
+
};
|
|
1157
|
+
const repo = spelledRepo ?? (registered.length === 1 ? registered[0] : void 0);
|
|
1158
|
+
if (repo === void 0) return {
|
|
1159
|
+
_tag: "ambiguous",
|
|
1160
|
+
repos: registered
|
|
1161
|
+
};
|
|
1162
|
+
return {
|
|
1163
|
+
_tag: "resolved",
|
|
1164
|
+
repo,
|
|
1165
|
+
number: Number(number)
|
|
1166
|
+
};
|
|
1167
|
+
};
|
|
1168
|
+
//#endregion
|
|
1169
|
+
//#region src/cli/pr.ts
|
|
1170
|
+
/** The pull request a command acts on, named the way I actually type it. */
|
|
1171
|
+
const prArgument = Argument.String("pr").pipe(Argument.withDescription("The pull request, as 28 or owner/name#28"));
|
|
1172
|
+
/** What to say about a reference that named no one pull request. */
|
|
1173
|
+
const whyNothingNamed = (reference) => {
|
|
1174
|
+
if (reference._tag === "unreadable") return `'${reference.text}' is not a pull request. Name one as 28, or as owner/name#28.`;
|
|
1175
|
+
const example = `${reference.repos[0] ?? "owner/name"}#28`;
|
|
1176
|
+
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}.`;
|
|
1177
|
+
};
|
|
1178
|
+
/** The pull request the argument names, or the sentence saying why it names none. */
|
|
1179
|
+
const named = (pr, registered) => {
|
|
1180
|
+
const reference = resolve$1(pr, registered);
|
|
1181
|
+
return reference._tag === "resolved" ? Effect.succeed(reference) : Effect.fail(new CliError.UserError({ cause: whyNothingNamed(reference) }));
|
|
1182
|
+
};
|
|
1183
|
+
/**
|
|
1184
|
+
* A domain guard's word, as the command's own failure.
|
|
1095
1185
|
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
* never on its own a reason to spend CI minutes.
|
|
1186
|
+
* Every guard in the tool answers the same shape - the sentence saying why not,
|
|
1187
|
+
* or null - so turning that answer into a refusal is spelled once here rather
|
|
1188
|
+
* than beside each command that asks one.
|
|
1100
1189
|
*/
|
|
1101
|
-
const
|
|
1102
|
-
const reported = reportedAt(check.detailsUrl);
|
|
1103
|
-
return reported === null ? [] : [reported.run];
|
|
1104
|
-
}))];
|
|
1190
|
+
const refuse = (why) => why === null ? Effect.void : Effect.fail(new CliError.UserError({ cause: why }));
|
|
1105
1191
|
/**
|
|
1106
|
-
*
|
|
1192
|
+
* What the last sweep learned about one pull request, or the sentence sending
|
|
1193
|
+
* me to a sweep.
|
|
1107
1194
|
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1195
|
+
* A command that reads these rather than GitHub says what the table said: the
|
|
1196
|
+
* stamp and the cutoff a conversation is measured against are both computed
|
|
1197
|
+
* from the facts a sweep wrote down, and asking GitHub again would make them a
|
|
1198
|
+
* different answer from the one `dw-mc status` printed.
|
|
1199
|
+
*
|
|
1200
|
+
* Facts this version cannot read are facts another version of them wrote, and a
|
|
1201
|
+
* sweep can write them again, so both cases say the same thing.
|
|
1111
1202
|
*/
|
|
1112
|
-
const
|
|
1113
|
-
yield*
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
"--repo",
|
|
1118
|
-
repo,
|
|
1119
|
-
"--failed"
|
|
1120
|
-
]).pipe(Effect.catchTags({
|
|
1121
|
-
PlatformError: (error) => Effect.fail(unavailable(error)),
|
|
1122
|
-
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
1123
|
-
command: "run rerun",
|
|
1124
|
-
detail: error.stderr
|
|
1125
|
-
}))
|
|
1126
|
-
}));
|
|
1203
|
+
const swept = Effect.fn("pr.swept")(function* (repo, number) {
|
|
1204
|
+
const store = yield* storeFor("prs", Facts);
|
|
1205
|
+
const facts = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
|
|
1206
|
+
if (Option.isNone(facts)) return yield* new CliError.UserError({ cause: `Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.` });
|
|
1207
|
+
return facts.value;
|
|
1127
1208
|
});
|
|
1128
1209
|
//#endregion
|
|
1129
1210
|
//#region src/cli/table.ts
|
|
@@ -1170,161 +1251,278 @@ const truncate = (text, width) => {
|
|
|
1170
1251
|
/** `n` of something, pluralised the one way English usually is. */
|
|
1171
1252
|
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
1172
1253
|
//#endregion
|
|
1173
|
-
//#region src/
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1254
|
+
//#region src/cli/row.ts
|
|
1255
|
+
/**
|
|
1256
|
+
* How one tracked PR is written down, wherever it is written down.
|
|
1257
|
+
*
|
|
1258
|
+
* The table `dw-mc status` prints and the list the picker asks me to choose
|
|
1259
|
+
* from are the same rows, so a pull request reads the same in both and neither
|
|
1260
|
+
* command owns how the other draws it.
|
|
1261
|
+
*
|
|
1262
|
+
* Colour here says one thing: which bucket the pull request is in, and so what
|
|
1263
|
+
* it waits on. Everything else on the row is either `dim`, because it is
|
|
1264
|
+
* context rather than state, or left alone. A row read with no colour at all
|
|
1265
|
+
* says the same, which is what the marker is for.
|
|
1266
|
+
*/
|
|
1267
|
+
/** The glossary's name for each bucket, which is what the heading says. */
|
|
1268
|
+
const heading = {
|
|
1269
|
+
"needs-me": "Needs me",
|
|
1270
|
+
"needs-review-run": "Needs review run",
|
|
1271
|
+
"waiting-on-others": "Waiting on others",
|
|
1272
|
+
ready: "Ready"
|
|
1273
|
+
};
|
|
1274
|
+
/**
|
|
1275
|
+
* The mark that says which bucket a row is in without being read.
|
|
1276
|
+
*
|
|
1277
|
+
* One character apiece, from the part of Unicode a terminal font has: the
|
|
1278
|
+
* padding is counted in characters, and a glyph a terminal draws double width
|
|
1279
|
+
* takes a column the count never gave it. How full the mark looks tracks how
|
|
1280
|
+
* much of the pull request is done, so the column reads at a glance even where
|
|
1281
|
+
* the colour is off.
|
|
1282
|
+
*/
|
|
1283
|
+
const marker = {
|
|
1284
|
+
"needs-me": "●",
|
|
1285
|
+
"needs-review-run": "◐",
|
|
1286
|
+
"waiting-on-others": "○",
|
|
1287
|
+
ready: "◆"
|
|
1288
|
+
};
|
|
1289
|
+
/** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
|
|
1290
|
+
const tint = (paint, bucket) => ({
|
|
1291
|
+
"needs-me": paint.red,
|
|
1292
|
+
"needs-review-run": paint.yellow,
|
|
1293
|
+
"waiting-on-others": paint.dim,
|
|
1294
|
+
ready: paint.green
|
|
1295
|
+
})[bucket];
|
|
1296
|
+
/** What sits between two columns: three columns of prose run into one another without a rule. */
|
|
1297
|
+
const rule = " │ ";
|
|
1298
|
+
/**
|
|
1299
|
+
* One row: which pull request, what it is, and what it waits on.
|
|
1300
|
+
*
|
|
1301
|
+
* A stamp is a mark beside the pull request rather than a column of its own, so
|
|
1302
|
+
* a table where nothing is stamped is exactly the table it was before: the
|
|
1303
|
+
* stamp is a thing I look for, not a thing I read every row of.
|
|
1304
|
+
*
|
|
1305
|
+
* The title is the only cell with give in it, so how much room it gets is the
|
|
1306
|
+
* caller's to say: a table printed down the screen can afford a whole commit
|
|
1307
|
+
* subject, and a row inside a prompt has a column more to carry and a frame
|
|
1308
|
+
* around it.
|
|
1309
|
+
*
|
|
1310
|
+
* A named lead carries the colour for the whole row. It is the one place a
|
|
1311
|
+
* prompt's row is coloured: a prompt counts the lines it has to erase from the
|
|
1312
|
+
* length of what it drew, escape sequences and all, so every colour on a row
|
|
1313
|
+
* costs the title characters it could have shown. The table has no such
|
|
1314
|
+
* arithmetic to keep straight, so its rows say it in more than one place.
|
|
1315
|
+
*/
|
|
1316
|
+
const cells = (placed, stamped, room, paint, lead) => {
|
|
1317
|
+
const { facts } = placed;
|
|
1318
|
+
const { bucket } = placed.placement;
|
|
1319
|
+
const say = tint(paint, bucket);
|
|
1320
|
+
const pr = `${facts.repo}#${facts.number}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
|
|
1321
|
+
return lead === "named" ? [
|
|
1322
|
+
say(`${marker[bucket]} ${heading[bucket]}`),
|
|
1323
|
+
pr,
|
|
1324
|
+
truncate(facts.title, room),
|
|
1325
|
+
placed.placement.reason
|
|
1326
|
+
] : [
|
|
1327
|
+
`${say(marker[bucket])} ${pr}`,
|
|
1328
|
+
paint.dim(truncate(facts.title, room)),
|
|
1329
|
+
say(placed.placement.reason)
|
|
1330
|
+
];
|
|
1331
|
+
};
|
|
1183
1332
|
//#endregion
|
|
1184
|
-
//#region src/
|
|
1185
|
-
/**
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
"
|
|
1194
|
-
"
|
|
1195
|
-
"
|
|
1196
|
-
"
|
|
1333
|
+
//#region src/adapters/ci.ts
|
|
1334
|
+
/**
|
|
1335
|
+
* What GitHub says about a pull request's checks, and the evidence a red one
|
|
1336
|
+
* is classified on. Every read here goes through the same `gh` the rest of the
|
|
1337
|
+
* tool does; what it owns is the checks, not the boundary.
|
|
1338
|
+
*/
|
|
1339
|
+
const failing = /* @__PURE__ */ new Set([
|
|
1340
|
+
"FAILURE",
|
|
1341
|
+
"TIMED_OUT",
|
|
1342
|
+
"CANCELLED",
|
|
1343
|
+
"STARTUP_FAILURE",
|
|
1344
|
+
"ACTION_REQUIRED",
|
|
1345
|
+
"ERROR"
|
|
1197
1346
|
]);
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
"
|
|
1201
|
-
"
|
|
1202
|
-
"
|
|
1203
|
-
"
|
|
1347
|
+
const running = /* @__PURE__ */ new Set([
|
|
1348
|
+
"QUEUED",
|
|
1349
|
+
"IN_PROGRESS",
|
|
1350
|
+
"WAITING",
|
|
1351
|
+
"PENDING",
|
|
1352
|
+
"REQUESTED",
|
|
1353
|
+
"EXPECTED"
|
|
1204
1354
|
]);
|
|
1355
|
+
const nameOf = (entry) => entry.name ?? entry.context ?? "";
|
|
1356
|
+
const checksThatCount = (entries, ignore) => (entries ?? []).filter((entry) => !ignore.includes(nameOf(entry)));
|
|
1357
|
+
const hasFailed = (entry) => failing.has(entry.conclusion ?? "") || failing.has(entry.state ?? "");
|
|
1205
1358
|
/**
|
|
1206
|
-
*
|
|
1359
|
+
* What the rollup comes to: red when anything failed, pending only while
|
|
1360
|
+
* nothing has failed yet, green when every check that counts has passed.
|
|
1207
1361
|
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* is recognised by.
|
|
1362
|
+
* `ci.ignore` names the checks that do not count towards green, so a check I
|
|
1363
|
+
* have decided to live with cannot hold a PR out of Ready.
|
|
1211
1364
|
*/
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1365
|
+
const rollupState = (entries, ignore) => {
|
|
1366
|
+
const checks = checksThatCount(entries, ignore);
|
|
1367
|
+
if (checks.length === 0) return "none";
|
|
1368
|
+
if (checks.some(hasFailed)) return "red";
|
|
1369
|
+
if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
|
|
1370
|
+
return "green";
|
|
1371
|
+
};
|
|
1372
|
+
/**
|
|
1373
|
+
* The checks that failed and count, which are the ones there is a log to read.
|
|
1374
|
+
*
|
|
1375
|
+
* `ci.ignore` is applied here as well as in the rollup: a check that cannot
|
|
1376
|
+
* hold a PR out of Ready is not one the classifier should be explaining either.
|
|
1377
|
+
*/
|
|
1378
|
+
const failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
|
|
1379
|
+
/**
|
|
1380
|
+
* What a check reports on, out of the URL it reports at.
|
|
1381
|
+
*
|
|
1382
|
+
* A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
|
|
1383
|
+
* what the logs endpoint takes and the run id is what `gh run rerun` takes, so
|
|
1384
|
+
* the two ids the tool needs are the two halves of one URL and are read
|
|
1385
|
+
* together. A commit status points somewhere else entirely, which is null:
|
|
1386
|
+
* there is no log of ours to read and no run of ours to re-run.
|
|
1387
|
+
*/
|
|
1388
|
+
const reportedAt = (detailsUrl) => {
|
|
1389
|
+
const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
|
|
1390
|
+
return found?.[1] === void 0 || found[2] === void 0 ? null : {
|
|
1391
|
+
run: found[1],
|
|
1392
|
+
job: found[2]
|
|
1393
|
+
};
|
|
1394
|
+
};
|
|
1395
|
+
const RepoDefaultBranch = Schema.fromJsonString(Schema.Struct({ defaultBranchRef: Schema.NullOr(Schema.Struct({ name: Schema.String })) }));
|
|
1396
|
+
/**
|
|
1397
|
+
* The branch a repository merges into, which is the one the first flaky signal
|
|
1398
|
+
* asks about. An empty repository has none, and `main` is the better guess than
|
|
1399
|
+
* failing the sweep over it.
|
|
1400
|
+
*/
|
|
1401
|
+
const defaultBranch = Effect.fnUntraced(function* (repo) {
|
|
1402
|
+
return (yield* readJson("repo view defaultBranchRef", "gh", [
|
|
1403
|
+
"repo",
|
|
1404
|
+
"view",
|
|
1405
|
+
repo,
|
|
1406
|
+
"--json",
|
|
1407
|
+
"defaultBranchRef"
|
|
1408
|
+
], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
|
|
1236
1409
|
});
|
|
1237
|
-
Schema.
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1410
|
+
const Runs = Schema.fromJsonString(Schema.Array(Schema.Struct({ conclusion: Schema.String })));
|
|
1411
|
+
/** How far back to look for a run that reached a verdict at all. */
|
|
1412
|
+
const recentRuns = 5;
|
|
1413
|
+
/** `gh run list` reports a conclusion in lower case, unlike every check on a PR. */
|
|
1414
|
+
const failedRun = /* @__PURE__ */ new Set(["failure", "timed_out"]);
|
|
1415
|
+
/** A run that decided something. A skipped or cancelled run says nothing either way. */
|
|
1416
|
+
const verdicts = /* @__PURE__ */ new Set([
|
|
1417
|
+
"failure",
|
|
1418
|
+
"timed_out",
|
|
1419
|
+
"success"
|
|
1242
1420
|
]);
|
|
1243
|
-
/** The buckets in the order I act on them: the top of the table is my next move. */
|
|
1244
|
-
const order = [
|
|
1245
|
-
"needs-me",
|
|
1246
|
-
"needs-review-run",
|
|
1247
|
-
"waiting-on-others",
|
|
1248
|
-
"ready"
|
|
1249
|
-
];
|
|
1250
1421
|
/**
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1422
|
+
* Whether `workflow` is red on `branch` right now.
|
|
1423
|
+
*
|
|
1424
|
+
* The newest run that reached a verdict is the whole answer: a workflow that
|
|
1425
|
+
* broke last week and was fixed since is not red, and excusing a pull request
|
|
1426
|
+
* for it would hide a failure that is real. A handful of runs are asked for
|
|
1427
|
+
* because the newest ones are often skipped by a path filter.
|
|
1428
|
+
*/
|
|
1429
|
+
const workflowFailsOn = Effect.fnUntraced(function* (repo, branch, workflow) {
|
|
1430
|
+
const newest = (yield* readJson("run list", "gh", [
|
|
1431
|
+
"run",
|
|
1432
|
+
"list",
|
|
1433
|
+
"--repo",
|
|
1434
|
+
repo,
|
|
1435
|
+
"--branch",
|
|
1436
|
+
branch,
|
|
1437
|
+
"--workflow",
|
|
1438
|
+
workflow,
|
|
1439
|
+
"--limit",
|
|
1440
|
+
String(recentRuns),
|
|
1441
|
+
"--json",
|
|
1442
|
+
"conclusion"
|
|
1443
|
+
], Runs)).find((run) => verdicts.has(run.conclusion));
|
|
1444
|
+
return newest !== void 0 && failedRun.has(newest.conclusion);
|
|
1445
|
+
});
|
|
1446
|
+
const PrFiles = Schema.fromJsonString(Schema.Struct({ files: Schema.Array(Schema.Struct({ path: Schema.String })) }));
|
|
1447
|
+
/** The repository paths a pull request changes. */
|
|
1448
|
+
const prFiles = Effect.fnUntraced(function* (repo, number) {
|
|
1449
|
+
return (yield* readJson("pr view files", "gh", [
|
|
1450
|
+
"pr",
|
|
1451
|
+
"view",
|
|
1452
|
+
String(number),
|
|
1453
|
+
"--repo",
|
|
1454
|
+
repo,
|
|
1455
|
+
"--json",
|
|
1456
|
+
"files"
|
|
1457
|
+
], PrFiles)).files.map((file) => file.path);
|
|
1458
|
+
});
|
|
1459
|
+
/**
|
|
1460
|
+
* How much of a failing job's log is kept.
|
|
1461
|
+
*
|
|
1462
|
+
* A job that failed prints what went wrong at the end, so the tail is the part
|
|
1463
|
+
* worth classifying, and a build that logged a whole dependency tree is not
|
|
1464
|
+
* worth holding in memory beyond it.
|
|
1254
1465
|
*/
|
|
1255
|
-
const
|
|
1256
|
-
if (facts.mergeable === "conflicting") return "merge conflict";
|
|
1257
|
-
if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
|
|
1258
|
-
if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
|
|
1259
|
-
if (facts.reviewDecision === "changes-requested") return "changes requested";
|
|
1260
|
-
if (facts.blockingFindings > 0) return `${facts.blockingFindings} blocking finding${facts.blockingFindings === 1 ? "" : "s"}`;
|
|
1261
|
-
if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return "a comment I have not answered";
|
|
1262
|
-
return null;
|
|
1263
|
-
};
|
|
1466
|
+
const logTailBytes = 65536;
|
|
1264
1467
|
/**
|
|
1265
|
-
* What
|
|
1266
|
-
*
|
|
1267
|
-
* Ready is reached by having no reason not to be, so the reason says only what
|
|
1268
|
-
* holds: a repository that requires no reviewer produces no approval, and a
|
|
1269
|
-
* pull request with no CI at all is not green.
|
|
1468
|
+
* What one failing job printed, from the end.
|
|
1270
1469
|
*
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1470
|
+
* `gh api` refuses a response carrying terminal escape sequences unless it is
|
|
1471
|
+
* told otherwise, and a runner log is full of them. Verified by running it: the
|
|
1472
|
+
* endpoint answers with the plain log once the flag is passed.
|
|
1273
1473
|
*/
|
|
1274
|
-
const
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
].
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1474
|
+
const jobLog = Effect.fnUntraced(function* (repo, jobId) {
|
|
1475
|
+
const log = yield* capture("gh", [
|
|
1476
|
+
"api",
|
|
1477
|
+
`repos/${repo}/actions/jobs/${jobId}/logs`,
|
|
1478
|
+
"--allow-escape-sequences"
|
|
1479
|
+
]).pipe(Effect.catchTags({
|
|
1480
|
+
PlatformError: (error) => Effect.fail(unavailable(error)),
|
|
1481
|
+
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
1482
|
+
command: "api job logs",
|
|
1483
|
+
detail: error.stderr
|
|
1484
|
+
}))
|
|
1485
|
+
}));
|
|
1486
|
+
return log.length <= logTailBytes ? log : log.slice(-65536);
|
|
1487
|
+
});
|
|
1283
1488
|
/**
|
|
1284
|
-
* The
|
|
1489
|
+
* The workflow runs behind the failing checks that count, each named once.
|
|
1285
1490
|
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
* that claims the PR wins: a PR that both needs a review run and has changes
|
|
1289
|
-
* requested is mine to move, not the runner's.
|
|
1491
|
+
* One broken run usually fails several jobs, and re-running it once per failing
|
|
1492
|
+
* job would start the same run over and over.
|
|
1290
1493
|
*
|
|
1291
|
-
*
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1494
|
+
* `ci.ignore` decides which checks get a run into this list, and no more than
|
|
1495
|
+
* that: a run is re-run whole, so an ignored job sharing a run with a counted
|
|
1496
|
+
* one is re-run beside it. What the setting buys is that an ignored check is
|
|
1497
|
+
* never on its own a reason to spend CI minutes.
|
|
1294
1498
|
*/
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
})).toSorted((a, b) => a.facts.repo.localeCompare(b.facts.repo) || a.facts.number - b.facts.number);
|
|
1323
|
-
return order.map((bucket) => ({
|
|
1324
|
-
bucket,
|
|
1325
|
-
placed: placed.filter((it) => it.placement.bucket === bucket)
|
|
1326
|
-
})).filter((bucket) => bucket.placed.length > 0);
|
|
1327
|
-
};
|
|
1499
|
+
const failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
|
|
1500
|
+
const reported = reportedAt(check.detailsUrl);
|
|
1501
|
+
return reported === null ? [] : [reported.run];
|
|
1502
|
+
}))];
|
|
1503
|
+
/**
|
|
1504
|
+
* Asks GitHub to run one workflow run's failed jobs again.
|
|
1505
|
+
*
|
|
1506
|
+
* `--failed` is what makes this cheap: the jobs that passed are not run a
|
|
1507
|
+
* second time, so a flaky job costs the minutes it costs and no more. This is a
|
|
1508
|
+
* write to GitHub, and it is one of the three ADR 0002 allows.
|
|
1509
|
+
*/
|
|
1510
|
+
const rerunFailed = Effect.fnUntraced(function* (repo, runId) {
|
|
1511
|
+
yield* capture("gh", [
|
|
1512
|
+
"run",
|
|
1513
|
+
"rerun",
|
|
1514
|
+
runId,
|
|
1515
|
+
"--repo",
|
|
1516
|
+
repo,
|
|
1517
|
+
"--failed"
|
|
1518
|
+
]).pipe(Effect.catchTags({
|
|
1519
|
+
PlatformError: (error) => Effect.fail(unavailable(error)),
|
|
1520
|
+
CommandFailed: (error) => Effect.fail(new GhReadFailed({
|
|
1521
|
+
command: "run rerun",
|
|
1522
|
+
detail: error.stderr
|
|
1523
|
+
}))
|
|
1524
|
+
}));
|
|
1525
|
+
});
|
|
1328
1526
|
//#endregion
|
|
1329
1527
|
//#region src/domain/flaky.ts
|
|
1330
1528
|
/**
|
|
@@ -1421,7 +1619,7 @@ const filterMap = (xs, f) => xs.flatMap((x) => {
|
|
|
1421
1619
|
return b === null ? [] : [b];
|
|
1422
1620
|
});
|
|
1423
1621
|
/** No evidence at all, which is what an unreadable CI comes to. */
|
|
1424
|
-
const nothing = {
|
|
1622
|
+
const nothing$1 = {
|
|
1425
1623
|
alsoRedOnDefaultBranch: [],
|
|
1426
1624
|
changedFiles: [],
|
|
1427
1625
|
log: ""
|
|
@@ -1440,7 +1638,7 @@ const evidenceFor = Effect.fn("flaky.evidenceFor")(function* (repo, number, entr
|
|
|
1440
1638
|
const workflows = [...new Set(filterMap(failed, (check) => check.workflowName ?? null))];
|
|
1441
1639
|
const jobs = filterMap(failed, (check) => reportedAt(check.detailsUrl)?.job ?? null).slice(0, loggedJobs);
|
|
1442
1640
|
const branch = yield* Effect.orElseSucceed(defaultBranch(repo), () => null);
|
|
1443
|
-
if (branch === null) return nothing;
|
|
1641
|
+
if (branch === null) return nothing$1;
|
|
1444
1642
|
const [alsoRed, changedFiles, logs] = yield* Effect.all([
|
|
1445
1643
|
Effect.forEach(workflows, (workflow) => Effect.map(Effect.orElseSucceed(workflowFailsOn(repo, branch, workflow), () => false), (red) => red ? [workflow] : [])),
|
|
1446
1644
|
Effect.orElseSucceed(prFiles(repo, number), () => []),
|
|
@@ -2069,6 +2267,136 @@ const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(functi
|
|
|
2069
2267
|
yield* printTroubles(report.troubles);
|
|
2070
2268
|
}, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
|
|
2071
2269
|
//#endregion
|
|
2270
|
+
//#region src/domain/comments.ts
|
|
2271
|
+
/**
|
|
2272
|
+
* One thread's share of a strand, cut to what is worth reading.
|
|
2273
|
+
*
|
|
2274
|
+
* A review thread is answered as a whole, so a single comment newer than my
|
|
2275
|
+
* last activity brings the whole thread with it: the follow-up on its own is a
|
|
2276
|
+
* line answering something the screen does not show, which is what sends me to
|
|
2277
|
+
* the browser.
|
|
2278
|
+
*
|
|
2279
|
+
* The pull request's own comments are not a thread but a stream, and there is
|
|
2280
|
+
* no reply to lose the question of, so they are cut comment by comment.
|
|
2281
|
+
*/
|
|
2282
|
+
const only = (thread, keep, since, all) => {
|
|
2283
|
+
const strand = thread.comments.filter((it) => keep(it.bot));
|
|
2284
|
+
const comments = all ? strand : thread.path === null ? strand.filter((it) => isAfter(it.at, since)) : strand.some((it) => isAfter(it.at, since)) ? strand : [];
|
|
2285
|
+
return comments.length === 0 ? [] : [{
|
|
2286
|
+
...thread,
|
|
2287
|
+
comments
|
|
2288
|
+
}];
|
|
2289
|
+
};
|
|
2290
|
+
/**
|
|
2291
|
+
* The threads worth putting on screen, given what I have already done.
|
|
2292
|
+
*
|
|
2293
|
+
* `since` is my last activity on the pull request - the later of my last
|
|
2294
|
+
* comment and my last commit - which is the same moment the bucket rule
|
|
2295
|
+
* measures a comment against. Showing exactly what is newer than it means the
|
|
2296
|
+
* command answers the question the bucket asked.
|
|
2297
|
+
*
|
|
2298
|
+
* A thread somebody resolved and one against code that is gone are left out:
|
|
2299
|
+
* neither is something to answer, and both are still there to read under
|
|
2300
|
+
* `--all`, which asks for the whole conversation and so measures nothing
|
|
2301
|
+
* against anything.
|
|
2302
|
+
*/
|
|
2303
|
+
const shown = (threads, options) => {
|
|
2304
|
+
const kept = options.all ? threads : threads.filter((it) => !it.resolved && !it.outdated);
|
|
2305
|
+
return {
|
|
2306
|
+
people: kept.flatMap((it) => only(it, (bot) => !bot, options.since, options.all)),
|
|
2307
|
+
bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
|
|
2308
|
+
};
|
|
2309
|
+
};
|
|
2310
|
+
//#endregion
|
|
2311
|
+
//#region src/cli/comments.ts
|
|
2312
|
+
const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
|
|
2313
|
+
/** Where a thread hangs: a line of the diff, or the pull request itself. */
|
|
2314
|
+
const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
|
|
2315
|
+
/**
|
|
2316
|
+
* What is true of a thread beyond where it hangs.
|
|
2317
|
+
*
|
|
2318
|
+
* It is only ever printed under `--all`, which is the only way a settled thread
|
|
2319
|
+
* reaches the screen at all, and it is there so that reading one is never
|
|
2320
|
+
* reading it as something still open.
|
|
2321
|
+
*/
|
|
2322
|
+
const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdated ? "outdated" : null].filter((it) => it !== null).join(", ");
|
|
2323
|
+
/**
|
|
2324
|
+
* One thread as a block: where it hangs, then everybody who said something in
|
|
2325
|
+
* it, then what they said in full.
|
|
2326
|
+
*
|
|
2327
|
+
* In full because a review comment is usually a paragraph carrying a
|
|
2328
|
+
* suggestion, and a first line is what sends me to the browser this command
|
|
2329
|
+
* exists to replace. No diff hunk with it: the code is on this machine, under
|
|
2330
|
+
* the path the heading already prints.
|
|
2331
|
+
*/
|
|
2332
|
+
const block = (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}`)])];
|
|
2333
|
+
const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
|
|
2334
|
+
/**
|
|
2335
|
+
* The conversation on screen: people first, then a rule, then the bots.
|
|
2336
|
+
*
|
|
2337
|
+
* The rule is there so the two are never read as one list. A bot's comment is
|
|
2338
|
+
* observed and never answered, which is the same asymmetry between a deciding
|
|
2339
|
+
* and a supporting runner the glossary already draws, and the bucket rules
|
|
2340
|
+
* ignore bots for exactly this reason.
|
|
2341
|
+
*
|
|
2342
|
+
* A bot is cut at the same moment I am measured against, because the window is
|
|
2343
|
+
* what has happened since I last acted rather than what is owed an answer. A
|
|
2344
|
+
* verdict older than my last push is one I have already had the chance to read,
|
|
2345
|
+
* and `--all` is where it still is.
|
|
2346
|
+
*/
|
|
2347
|
+
const lines$2 = (view, paint) => {
|
|
2348
|
+
const people = view.people.map((thread) => block(thread, paint));
|
|
2349
|
+
const bots = view.bots.map((thread) => block(thread, paint));
|
|
2350
|
+
return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
|
|
2351
|
+
};
|
|
2352
|
+
/** What to say where there is nothing to print, which depends on why there is not. */
|
|
2353
|
+
const nothing = (facts, all) => {
|
|
2354
|
+
const pr = `${facts.repo}#${facts.number}`;
|
|
2355
|
+
if (all) return [`Nothing has been said on ${pr}.`];
|
|
2356
|
+
const placement = place(facts);
|
|
2357
|
+
const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
|
|
2358
|
+
return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
|
|
2359
|
+
"Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
|
|
2360
|
+
`${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
|
|
2361
|
+
rest
|
|
2362
|
+
] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
|
|
2363
|
+
};
|
|
2364
|
+
/**
|
|
2365
|
+
* The conversation on one tracked pull request, and nothing else.
|
|
2366
|
+
*
|
|
2367
|
+
* What it shows by default is what the bucket rule measures: the comments newer
|
|
2368
|
+
* than the later of my last comment and my last commit, which are the ones that
|
|
2369
|
+
* put the pull request in Needs me. Reading it answers the question the table
|
|
2370
|
+
* asked.
|
|
2371
|
+
*
|
|
2372
|
+
* The cutoff is read off the last sweep rather than worked out again here, so
|
|
2373
|
+
* the command shows exactly what `dw-mc status` counted rather than a second
|
|
2374
|
+
* opinion about it.
|
|
2375
|
+
*
|
|
2376
|
+
* It writes nothing, here or on GitHub: no reply, no resolve, no reaction
|
|
2377
|
+
* (ADR 0002). Reading is the whole command.
|
|
2378
|
+
*/
|
|
2379
|
+
const comments = Command.make("comments", {
|
|
2380
|
+
pr: prArgument,
|
|
2381
|
+
all: allFlag
|
|
2382
|
+
}, Effect.fn("comments")(function* ({ all, pr }) {
|
|
2383
|
+
const file = Option.getOrElse(yield* read, () => ({}));
|
|
2384
|
+
const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
|
|
2385
|
+
const facts = yield* swept(repo, number);
|
|
2386
|
+
const paint = yield* Paint;
|
|
2387
|
+
const view = shown(yield* prConversation(repo, number), {
|
|
2388
|
+
since: later(facts.myLastCommentAt, facts.myLastCommitAt),
|
|
2389
|
+
all
|
|
2390
|
+
});
|
|
2391
|
+
if (view.people.length === 0 && view.bots.length === 0) {
|
|
2392
|
+
yield* Effect.forEach(nothing(facts, all), (line) => Console.log(line));
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
|
|
2396
|
+
yield* Console.log("");
|
|
2397
|
+
yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
|
|
2398
|
+
}, Effect.catchTag(["ConfigMalformed", ...userFacing], asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
|
|
2399
|
+
//#endregion
|
|
2072
2400
|
//#region src/cli/findings.ts
|
|
2073
2401
|
/** The findings as the JSON the schema defines, rather than as this file spells it. */
|
|
2074
2402
|
const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
|
|
@@ -3636,85 +3964,6 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
|
|
|
3636
3964
|
yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
|
|
3637
3965
|
}, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
|
|
3638
3966
|
//#endregion
|
|
3639
|
-
//#region src/cli/row.ts
|
|
3640
|
-
/**
|
|
3641
|
-
* How one tracked PR is written down, wherever it is written down.
|
|
3642
|
-
*
|
|
3643
|
-
* The table `dw-mc status` prints and the list the picker asks me to choose
|
|
3644
|
-
* from are the same rows, so a pull request reads the same in both and neither
|
|
3645
|
-
* command owns how the other draws it.
|
|
3646
|
-
*
|
|
3647
|
-
* Colour here says one thing: which bucket the pull request is in, and so what
|
|
3648
|
-
* it waits on. Everything else on the row is either `dim`, because it is
|
|
3649
|
-
* context rather than state, or left alone. A row read with no colour at all
|
|
3650
|
-
* says the same, which is what the marker is for.
|
|
3651
|
-
*/
|
|
3652
|
-
/** The glossary's name for each bucket, which is what the heading says. */
|
|
3653
|
-
const heading = {
|
|
3654
|
-
"needs-me": "Needs me",
|
|
3655
|
-
"needs-review-run": "Needs review run",
|
|
3656
|
-
"waiting-on-others": "Waiting on others",
|
|
3657
|
-
ready: "Ready"
|
|
3658
|
-
};
|
|
3659
|
-
/**
|
|
3660
|
-
* The mark that says which bucket a row is in without being read.
|
|
3661
|
-
*
|
|
3662
|
-
* One character apiece, from the part of Unicode a terminal font has: the
|
|
3663
|
-
* padding is counted in characters, and a glyph a terminal draws double width
|
|
3664
|
-
* takes a column the count never gave it. How full the mark looks tracks how
|
|
3665
|
-
* much of the pull request is done, so the column reads at a glance even where
|
|
3666
|
-
* the colour is off.
|
|
3667
|
-
*/
|
|
3668
|
-
const marker = {
|
|
3669
|
-
"needs-me": "●",
|
|
3670
|
-
"needs-review-run": "◐",
|
|
3671
|
-
"waiting-on-others": "○",
|
|
3672
|
-
ready: "◆"
|
|
3673
|
-
};
|
|
3674
|
-
/** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
|
|
3675
|
-
const tint = (paint, bucket) => ({
|
|
3676
|
-
"needs-me": paint.red,
|
|
3677
|
-
"needs-review-run": paint.yellow,
|
|
3678
|
-
"waiting-on-others": paint.dim,
|
|
3679
|
-
ready: paint.green
|
|
3680
|
-
})[bucket];
|
|
3681
|
-
/** What sits between two columns: three columns of prose run into one another without a rule. */
|
|
3682
|
-
const rule = " │ ";
|
|
3683
|
-
/**
|
|
3684
|
-
* One row: which pull request, what it is, and what it waits on.
|
|
3685
|
-
*
|
|
3686
|
-
* A stamp is a mark beside the pull request rather than a column of its own, so
|
|
3687
|
-
* a table where nothing is stamped is exactly the table it was before: the
|
|
3688
|
-
* stamp is a thing I look for, not a thing I read every row of.
|
|
3689
|
-
*
|
|
3690
|
-
* The title is the only cell with give in it, so how much room it gets is the
|
|
3691
|
-
* caller's to say: a table printed down the screen can afford a whole commit
|
|
3692
|
-
* subject, and a row inside a prompt has a column more to carry and a frame
|
|
3693
|
-
* around it.
|
|
3694
|
-
*
|
|
3695
|
-
* A named lead carries the colour for the whole row. It is the one place a
|
|
3696
|
-
* prompt's row is coloured: a prompt counts the lines it has to erase from the
|
|
3697
|
-
* length of what it drew, escape sequences and all, so every colour on a row
|
|
3698
|
-
* costs the title characters it could have shown. The table has no such
|
|
3699
|
-
* arithmetic to keep straight, so its rows say it in more than one place.
|
|
3700
|
-
*/
|
|
3701
|
-
const cells = (placed, stamped, room, paint, lead) => {
|
|
3702
|
-
const { facts } = placed;
|
|
3703
|
-
const { bucket } = placed.placement;
|
|
3704
|
-
const say = tint(paint, bucket);
|
|
3705
|
-
const pr = `${facts.repo}#${facts.number}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
|
|
3706
|
-
return lead === "named" ? [
|
|
3707
|
-
say(`${marker[bucket]} ${heading[bucket]}`),
|
|
3708
|
-
pr,
|
|
3709
|
-
truncate(facts.title, room),
|
|
3710
|
-
placed.placement.reason
|
|
3711
|
-
] : [
|
|
3712
|
-
`${say(marker[bucket])} ${pr}`,
|
|
3713
|
-
paint.dim(truncate(facts.title, room)),
|
|
3714
|
-
say(placed.placement.reason)
|
|
3715
|
-
];
|
|
3716
|
-
};
|
|
3717
|
-
//#endregion
|
|
3718
3967
|
//#region src/domain/pick.ts
|
|
3719
3968
|
/**
|
|
3720
3969
|
* The actions worth offering on one tracked PR, in the order I would take them.
|
|
@@ -4679,23 +4928,6 @@ const review = Command.make("review", {
|
|
|
4679
4928
|
//#region src/cli/stamp.ts
|
|
4680
4929
|
const withdrawFlag = Flag.Boolean("withdraw").pipe(Flag.withDefault(false), Flag.withDescription("Take the stamp off this pull request, until its head changes"));
|
|
4681
4930
|
/**
|
|
4682
|
-
* What the last sweep learned about one pull request, or the sentence sending
|
|
4683
|
-
* me to a sweep.
|
|
4684
|
-
*
|
|
4685
|
-
* The stamp is computed from the facts a sweep wrote down, so this command
|
|
4686
|
-
* reads them rather than GitHub: a mark that asked GitHub again would be a
|
|
4687
|
-
* different mark from the one `dw-mc status` prints.
|
|
4688
|
-
*
|
|
4689
|
-
* Facts this version cannot read are facts another version of them wrote, and a
|
|
4690
|
-
* sweep can write them again, so both cases say the same thing.
|
|
4691
|
-
*/
|
|
4692
|
-
const sweptFacts = Effect.fn("stamp.sweptFacts")(function* (repo, number) {
|
|
4693
|
-
const store = yield* storeFor("prs", Facts);
|
|
4694
|
-
const facts = yield* Effect.orElseSucceed(store.get(prKey(repo, number)), () => Option.none());
|
|
4695
|
-
if (Option.isNone(facts)) return yield* asUserError(`Nothing is known about ${repo}#${number} yet. Run dw-mc sweep first.`);
|
|
4696
|
-
return facts.value;
|
|
4697
|
-
});
|
|
4698
|
-
/**
|
|
4699
4931
|
* The stamp of one pull request, and the one way to take it off by hand.
|
|
4700
4932
|
*
|
|
4701
4933
|
* Printing it is the whole command without `--withdraw`: the mark is computed,
|
|
@@ -4718,7 +4950,7 @@ const stampCommand = Command.make("stamp", {
|
|
|
4718
4950
|
}, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
|
|
4719
4951
|
const file = Option.getOrElse(yield* read, () => ({}));
|
|
4720
4952
|
const { number, repo } = yield* named(pr, Object.keys(file.repos ?? {}).toSorted());
|
|
4721
|
-
const facts = yield*
|
|
4953
|
+
const facts = yield* swept(repo, number);
|
|
4722
4954
|
const where = `${repo}#${number} ${short(facts.head)}`;
|
|
4723
4955
|
if (byHand) {
|
|
4724
4956
|
yield* withdraw(repo, number, facts.head);
|
|
@@ -4775,12 +5007,13 @@ const status = Command.make("status", {}, Effect.fn("status")(function* () {
|
|
|
4775
5007
|
* Running from source leaves the constant undeclared rather than undefined, so
|
|
4776
5008
|
* the check has to be `typeof` and the fallback is what a test reads.
|
|
4777
5009
|
*/
|
|
4778
|
-
const version = "0.
|
|
5010
|
+
const version = "0.2.0";
|
|
4779
5011
|
/** Where the project lives, printed beside the version in the header. */
|
|
4780
5012
|
const projectUrl = "github.com/dominikwozniak/dw-mc";
|
|
4781
5013
|
const subcommands = [
|
|
4782
5014
|
init,
|
|
4783
5015
|
review,
|
|
5016
|
+
comments,
|
|
4784
5017
|
findings,
|
|
4785
5018
|
fix,
|
|
4786
5019
|
rebase,
|