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/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/ci.ts
936
- /**
937
- * What GitHub says about a pull request's checks, and the evidence a red one
938
- * is classified on. Every read here goes through the same `gh` the rest of the
939
- * tool does; what it owns is the checks, not the boundary.
940
- */
941
- const failing = /* @__PURE__ */ new Set([
942
- "FAILURE",
943
- "TIMED_OUT",
944
- "CANCELLED",
945
- "STARTUP_FAILURE",
946
- "ACTION_REQUIRED",
947
- "ERROR"
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
- const running = /* @__PURE__ */ new Set([
950
- "QUEUED",
951
- "IN_PROGRESS",
952
- "WAITING",
953
- "PENDING",
954
- "REQUESTED",
955
- "EXPECTED"
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
- * What the rollup comes to: red when anything failed, pending only while
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
- * `ci.ignore` names the checks that do not count towards green, so a check I
965
- * have decided to live with cannot hold a PR out of Ready.
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 rollupState = (entries, ignore) => {
968
- const checks = checksThatCount(entries, ignore);
969
- if (checks.length === 0) return "none";
970
- if (checks.some(hasFailed)) return "red";
971
- if (checks.some((entry) => entry.status !== void 0 && entry.status !== "COMPLETED" || running.has(entry.state ?? ""))) return "pending";
972
- return "green";
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
- * The checks that failed and count, which are the ones there is a log to read.
1040
+ * Why a PR is mine to move when somebody has said something I have not
1041
+ * answered.
976
1042
  *
977
- * `ci.ignore` is applied here as well as in the rollup: a check that cannot
978
- * hold a PR out of Ready is not one the classifier should be explaining either.
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 failedChecks = (entries, ignore) => checksThatCount(entries, ignore).filter(hasFailed);
1048
+ const unanswered = "a comment I have not answered";
981
1049
  /**
982
- * What a check reports on, out of the URL it reports at.
983
- *
984
- * A check run details URL ends `/actions/runs/<run>/job/<job>`. The job id is
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 reportedAt = (detailsUrl) => {
991
- const found = detailsUrl?.match(/\/actions\/runs\/(\d+)\/job\/(\d+)/);
992
- return found?.[1] === void 0 || found[2] === void 0 ? null : {
993
- run: found[1],
994
- job: found[2]
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
- * The branch a repository merges into, which is the one the first flaky signal
1000
- * asks about. An empty repository has none, and `main` is the better guess than
1001
- * failing the sweep over it.
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 defaultBranch = Effect.fnUntraced(function* (repo) {
1004
- return (yield* readJson("repo view defaultBranchRef", "gh", [
1005
- "repo",
1006
- "view",
1007
- repo,
1008
- "--json",
1009
- "defaultBranchRef"
1010
- ], RepoDefaultBranch)).defaultBranchRef?.name ?? "main";
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
- * Whether `workflow` is red on `branch` right now.
1083
+ * The bucket a tracked PR sits in, and the reason for it.
1025
1084
  *
1026
- * The newest run that reached a verdict is the whole answer: a workflow that
1027
- * broke last week and was fixed since is not red, and excusing a pull request
1028
- * for it would hide a failure that is real. A handful of runs are asked for
1029
- * because the newest ones are often skipped by a path filter.
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
- * A job that failed prints what went wrong at the end, so the tail is the part
1065
- * worth classifying, and a build that logged a whole dependency tree is not
1066
- * worth holding in memory beyond it.
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 logTailBytes = 65536;
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
- * What one failing job printed, from the end.
1132
+ * A segment of nothing but dots, which no repository is called.
1071
1133
  *
1072
- * `gh api` refuses a response carrying terminal escape sequences unless it is
1073
- * told otherwise, and a runner log is full of them. Verified by running it: the
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 jobLog = Effect.fnUntraced(function* (repo, jobId) {
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 workflow runs behind the failing checks that count, each named once.
1139
+ * The pull request a reference names.
1092
1140
  *
1093
- * One broken run usually fails several jobs, and re-running it once per failing
1094
- * job would start the same run over and over.
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
- * `ci.ignore` decides which checks get a run into this list, and no more than
1097
- * that: a run is re-run whole, so an ignored job sharing a run with a counted
1098
- * one is re-run beside it. What the setting buys is that an ignored check is
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 failedRuns = (entries, ignore) => [...new Set(failedChecks(entries, ignore).flatMap((check) => {
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
- * Asks GitHub to run one workflow run's failed jobs again.
1192
+ * What the last sweep learned about one pull request, or the sentence sending
1193
+ * me to a sweep.
1107
1194
  *
1108
- * `--failed` is what makes this cheap: the jobs that passed are not run a
1109
- * second time, so a flaky job costs the minutes it costs and no more. This is a
1110
- * write to GitHub, and it is one of the three ADR 0002 allows.
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 rerunFailed = Effect.fnUntraced(function* (repo, runId) {
1113
- yield* capture("gh", [
1114
- "run",
1115
- "rerun",
1116
- runId,
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/domain/moment.ts
1174
- const isLater = Order.isGreaterThan(DateTime.Order);
1175
- /** Whether `self` happened after `other`, counting never as before anything. */
1176
- const isAfter = (self, other) => Predicate.isNotNull(self) && (other === null || isLater(self, other));
1177
- /** The later of the two. */
1178
- const later = (self, other) => isAfter(self, other) ? self : other;
1179
- /** Whether the two are the same moment, counting never as the same as never. */
1180
- const isSame = (self, other) => self === null || other === null ? self === other : DateTime.Equivalence(self, other);
1181
- /** The latest of many, or never when there are none. */
1182
- const newest = (moments) => moments.reduce(later, null);
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/domain/bucket.ts
1185
- /** How far GitHub has got towards letting a tracked PR merge. */
1186
- const Mergeability = Schema.Literals([
1187
- "mergeable",
1188
- "conflicting",
1189
- "unknown"
1190
- ]);
1191
- /** What the reviewers have decided, or that nobody is required to. */
1192
- const ReviewDecision = Schema.Literals([
1193
- "approved",
1194
- "changes-requested",
1195
- "review-required",
1196
- "none"
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
- /** What CI says about the current head. */
1199
- const ChecksState = Schema.Literals([
1200
- "green",
1201
- "red",
1202
- "pending",
1203
- "none"
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
- * Everything the bucket rules are allowed to know about a tracked PR.
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
- * It is a schema because a sweep writes it to the state directory and reads it
1209
- * back on the next one: the same facts that decide a bucket are what a quiet PR
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 Facts = Schema.Struct({
1213
- repo: Schema.String,
1214
- number: Schema.Int,
1215
- title: Schema.String,
1216
- url: Schema.String,
1217
- /** Shown, never acted on unless I ask. */
1218
- draft: Schema.Boolean,
1219
- /** The head commit every other fact here is about. */
1220
- head: Schema.String,
1221
- mergeable: Mergeability,
1222
- reviewDecision: ReviewDecision,
1223
- checks: ChecksState,
1224
- /** Why the flaky classifier excuses this red CI, or null where it does not. */
1225
- ciFlaky: Schema.NullOr(Schema.String),
1226
- /** The head a rebase onto the base conflicted at, or null where none has. */
1227
- rebaseConflictAt: Schema.NullOr(Schema.String),
1228
- /** The newest comment from a person who is not me, bots excluded. */
1229
- newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1230
- myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1231
- myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
1232
- /** The head a review run has already covered, or null where none has. */
1233
- reviewRunHead: Schema.NullOr(Schema.String),
1234
- /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
1235
- blockingFindings: Schema.Int
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.Literals([
1238
- "needs-me",
1239
- "needs-review-run",
1240
- "waiting-on-others",
1241
- "ready"
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
- * The first of the rules that makes a PR mine to move, or null when none
1252
- * does. The order is the order I would fix them in: a conflict makes every
1253
- * other signal on the PR stale, and a red build is worth more than a comment.
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 needsMe = (facts) => {
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 is actually true of a PR nothing is waiting on.
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
- * A red CI the classifier excused is said out loud, because GitHub does not
1272
- * excuse it: the check is still red, and Ready is what `dw-mc merge` reads.
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 readyReason = (facts) => {
1275
- const held = [
1276
- facts.reviewDecision === "approved" ? "approved" : null,
1277
- facts.checks === "green" ? "green" : null,
1278
- facts.mergeable === "mergeable" ? "mergeable" : null
1279
- ].filter((it) => it !== null);
1280
- const standing = held.length === 0 ? "nothing left to wait on" : held.join(", ");
1281
- return facts.checks === "red" && facts.ciFlaky !== null ? `${standing} (red CI called flaky: ${facts.ciFlaky})` : standing;
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 bucket a tracked PR sits in, and the reason for it.
1489
+ * The workflow runs behind the failing checks that count, each named once.
1285
1490
  *
1286
- * This is the single place the bucket rules exist. Every tracked PR lands in
1287
- * exactly one bucket, so the rules are tried in priority order and the first
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
- * Ready does not insist on an approval, because a repository that requires no
1292
- * reviewer never produces one. What it insists on is that nobody else has been
1293
- * asked and is yet to answer.
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 place = (facts) => {
1296
- const mine = needsMe(facts);
1297
- if (mine !== null) return {
1298
- bucket: "needs-me",
1299
- reason: mine
1300
- };
1301
- if (facts.reviewRunHead !== facts.head) return {
1302
- bucket: "needs-review-run",
1303
- reason: "no review run on this head"
1304
- };
1305
- if (facts.reviewDecision === "review-required") return {
1306
- bucket: "waiting-on-others",
1307
- reason: "a review from someone else"
1308
- };
1309
- if (facts.checks === "pending") return {
1310
- bucket: "waiting-on-others",
1311
- reason: "CI is still running"
1312
- };
1313
- return {
1314
- bucket: "ready",
1315
- reason: readyReason(facts)
1316
- };
1317
- };
1318
- const group = (facts) => {
1319
- const placed = facts.map((it) => ({
1320
- facts: it,
1321
- placement: place(it)
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* sweptFacts(repo, number);
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.1.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,