dw-mc 0.5.1 → 0.7.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
@@ -547,9 +547,60 @@ const textStoreFor = Effect.fn("store.textStoreFor")(function* (namespace) {
547
547
  * bargain is struck once rather than at each of them.
548
548
  */
549
549
  const remembered = (read) => Effect.orElseSucceed(read, () => Option.none());
550
+ /**
551
+ * Every key the state directory holds, whichever namespace it sits in.
552
+ *
553
+ * A key/value store answers about a key it is given and never lists one, and
554
+ * forgetting a pull request needs the list: a review run is kept under the head
555
+ * it read, and nothing on hand names every head a pull request was reviewed at.
556
+ */
557
+ var Keys = class extends Context.Service()("dw-mc/store/Keys") {};
558
+ /**
559
+ * The key a file in the state directory was written under, and none for a
560
+ * file no key could have been written as: the file store names every file it
561
+ * writes by percent-encoding its key, so a name that does not decode is
562
+ * something else put there.
563
+ */
564
+ const keyOf$1 = (entry) => {
565
+ try {
566
+ return [decodeURIComponent(entry)];
567
+ } catch {
568
+ return [];
569
+ }
570
+ };
571
+ /**
572
+ * The keys a file store over `directory` holds: one file each, named by the
573
+ * percent-encoded key.
574
+ *
575
+ * The clones and the checkouts sit beside them as directories, and none of
576
+ * them is a key.
577
+ */
578
+ const keysOnDisk = (directory) => Layer.effect(Keys, Effect.gen(function* () {
579
+ const fs = yield* FileSystem.FileSystem;
580
+ const directories = /* @__PURE__ */ new Set([clonesIn, ...cuts]);
581
+ return { all: fs.readDirectory(directory).pipe(Effect.map((entries) => entries.filter((entry) => !directories.has(entry)).flatMap(keyOf$1)), Effect.mapError((cause) => new KeyValueStore.KeyValueStoreError({
582
+ method: "keys",
583
+ message: "Unable to list the keys",
584
+ cause
585
+ }))) };
586
+ }));
587
+ /** Every key the state directory holds. */
588
+ const allKeys = Effect.gen(function* () {
589
+ return yield* (yield* Keys).all;
590
+ });
550
591
  /** The state directory on disk. */
551
- const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => KeyValueStore.layerFileSystem(directory)));
552
- KeyValueStore.layerMemory;
592
+ const layer$1 = Layer.unwrap(Effect.map(stateDirectory, (directory) => Layer.merge(KeyValueStore.layerFileSystem(directory), keysOnDisk(directory))));
593
+ Layer.effectContext(Effect.gen(function* () {
594
+ const inner = yield* KeyValueStore.KeyValueStore;
595
+ const held = /* @__PURE__ */ new Set();
596
+ const store = KeyValueStore.make({
597
+ ...inner,
598
+ set: (key, value) => Effect.tap(inner.set(key, value), () => Effect.sync(() => held.add(key))),
599
+ remove: (key) => Effect.tap(inner.remove(key), () => Effect.sync(() => held.delete(key))),
600
+ clear: Effect.tap(inner.clear, () => Effect.sync(() => held.clear()))
601
+ });
602
+ return Context.make(KeyValueStore.KeyValueStore, store).pipe(Context.add(Keys, { all: Effect.sync(() => [...held]) }));
603
+ })).pipe(Layer.provide(KeyValueStore.layerMemory));
553
604
  /** The three directories the tool cuts a checkout into, under the state directory. */
554
605
  const cuts = [
555
606
  "worktrees",
@@ -708,6 +759,92 @@ const tidy = Effect.fn("store.tidy")(function* (directory, upTo) {
708
759
  }
709
760
  });
710
761
  //#endregion
762
+ //#region src/adapters/picker.ts
763
+ /**
764
+ * Turns quitting into an answer rather than a failure.
765
+ *
766
+ * Bailing out of a prompt gives `None`, so no caller has to catch an error to
767
+ * learn that I walked away. The prompt itself decides what a `Some` carries.
768
+ */
769
+ const orNone = (prompt) => Effect.catchTag(prompt, "QuitError", () => Effect.succeedNone);
770
+ /**
771
+ * What a prompt looks like in this tool: the marker the rows already use, and
772
+ * the same colour for the choice I am standing on.
773
+ *
774
+ * It is set here rather than at each prompt, because this module is the only
775
+ * thing that opens one and four prompts that themed themselves would be four
776
+ * looks.
777
+ */
778
+ const theme = (paint) => paint === plain ? {
779
+ prefix: "▸",
780
+ pointer: "●"
781
+ } : {
782
+ prefix: "▸",
783
+ pointer: "●",
784
+ primaryColor: "cyan",
785
+ mutedColor: "gray"
786
+ };
787
+ /**
788
+ * What the keyboard does, said under the question.
789
+ *
790
+ * It rides in the message rather than being printed above the prompt, so it
791
+ * leaves with the prompt: a hint that outlives the answer is scrollback I did
792
+ * not ask for.
793
+ */
794
+ const moves = "↑↓ move · enter choose · q quit";
795
+ const asked$1 = (paint, message) => `${message}\n${paint.dim(moves)}`;
796
+ /** Asks which one of `choices` to act on. */
797
+ const pick = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.Select({
798
+ message: asked$1(paint, message),
799
+ choices,
800
+ theme: theme(paint)
801
+ }))));
802
+ /**
803
+ * Asks which of `choices` to act on, as many as I like.
804
+ *
805
+ * Nothing is selected to begin with, so what reaches the caller is what I
806
+ * picked rather than what I failed to unpick. Quitting is not the same as
807
+ * picking nothing: it gives `None`.
808
+ */
809
+ const choose = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.MultiSelect({
810
+ message: `${message}\n${paint.dim("↑↓ move · space pick · enter confirm · q quit")}`,
811
+ choices,
812
+ theme: theme(paint)
813
+ }))));
814
+ /**
815
+ * Asks a yes-or-no question about something that cannot be taken back.
816
+ *
817
+ * It starts on no, and walking away is no as well: the answer this returns is
818
+ * the one I typed, and every other way out of the prompt leaves the thing
819
+ * undone. A confirmation that defaulted to yes would be one keystroke, which is
820
+ * exactly what it exists to stop being.
821
+ */
822
+ const confirm = (message) => Effect.flatMap(Paint, (paint) => Effect.map(orNone(Effect.asSome(Prompt.Confirm({
823
+ message,
824
+ initial: false,
825
+ theme: theme(paint)
826
+ }))), Option.getOrElse(() => false)));
827
+ /**
828
+ * Asks for a line of prose, where having nothing to say is the ordinary answer.
829
+ *
830
+ * An empty line is no note, and that is not a failure: the prompt is optional
831
+ * by design. Quitting is the one thing it does not swallow. Ctrl-C part way
832
+ * through a list of notes means I want out of the whole command, and a prompt
833
+ * that turned it into "no note" would walk me through the rest of the list and
834
+ * then act on findings I was no longer sure about.
835
+ */
836
+ const note = (message) => Effect.map(Prompt.String({ message }), (text) => text.trim() === "" ? Option.none() : Option.some(text.trim()));
837
+ /**
838
+ * How wide the screen is, or zero where there is no screen to measure.
839
+ *
840
+ * A prompt has to fit its row on one line: a row that wraps takes the list's
841
+ * alignment with it. Nothing is piping into a prompt, so zero means the writing
842
+ * is going somewhere that does not wrap either.
843
+ */
844
+ const width = Effect.gen(function* () {
845
+ return yield* (yield* Terminal.Terminal).columns;
846
+ });
847
+ //#endregion
711
848
  //#region src/adapters/heartbeat.ts
712
849
  /** The frames of the spinner, in the order they turn. */
713
850
  const frames = [
@@ -1373,92 +1510,6 @@ const prune = Effect.fn("git.prune")(function* (clone) {
1373
1510
  ]));
1374
1511
  });
1375
1512
  //#endregion
1376
- //#region src/adapters/picker.ts
1377
- /**
1378
- * Turns quitting into an answer rather than a failure.
1379
- *
1380
- * Bailing out of a prompt gives `None`, so no caller has to catch an error to
1381
- * learn that I walked away. The prompt itself decides what a `Some` carries.
1382
- */
1383
- const orNone = (prompt) => Effect.catchTag(prompt, "QuitError", () => Effect.succeedNone);
1384
- /**
1385
- * What a prompt looks like in this tool: the marker the rows already use, and
1386
- * the same colour for the choice I am standing on.
1387
- *
1388
- * It is set here rather than at each prompt, because this module is the only
1389
- * thing that opens one and four prompts that themed themselves would be four
1390
- * looks.
1391
- */
1392
- const theme = (paint) => paint === plain ? {
1393
- prefix: "▸",
1394
- pointer: "●"
1395
- } : {
1396
- prefix: "▸",
1397
- pointer: "●",
1398
- primaryColor: "cyan",
1399
- mutedColor: "gray"
1400
- };
1401
- /**
1402
- * What the keyboard does, said under the question.
1403
- *
1404
- * It rides in the message rather than being printed above the prompt, so it
1405
- * leaves with the prompt: a hint that outlives the answer is scrollback I did
1406
- * not ask for.
1407
- */
1408
- const moves = "↑↓ move · enter choose · q quit";
1409
- const asked$1 = (paint, message) => `${message}\n${paint.dim(moves)}`;
1410
- /** Asks which one of `choices` to act on. */
1411
- const pick = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.Select({
1412
- message: asked$1(paint, message),
1413
- choices,
1414
- theme: theme(paint)
1415
- }))));
1416
- /**
1417
- * Asks which of `choices` to act on, as many as I like.
1418
- *
1419
- * Nothing is selected to begin with, so what reaches the caller is what I
1420
- * picked rather than what I failed to unpick. Quitting is not the same as
1421
- * picking nothing: it gives `None`.
1422
- */
1423
- const choose = (message, choices) => Effect.flatMap(Paint, (paint) => orNone(Effect.asSome(Prompt.MultiSelect({
1424
- message: `${message}\n${paint.dim("↑↓ move · space pick · enter confirm · q quit")}`,
1425
- choices,
1426
- theme: theme(paint)
1427
- }))));
1428
- /**
1429
- * Asks a yes-or-no question about something that cannot be taken back.
1430
- *
1431
- * It starts on no, and walking away is no as well: the answer this returns is
1432
- * the one I typed, and every other way out of the prompt leaves the thing
1433
- * undone. A confirmation that defaulted to yes would be one keystroke, which is
1434
- * exactly what it exists to stop being.
1435
- */
1436
- const confirm = (message) => Effect.flatMap(Paint, (paint) => Effect.map(orNone(Effect.asSome(Prompt.Confirm({
1437
- message,
1438
- initial: false,
1439
- theme: theme(paint)
1440
- }))), Option.getOrElse(() => false)));
1441
- /**
1442
- * Asks for a line of prose, where having nothing to say is the ordinary answer.
1443
- *
1444
- * An empty line is no note, and that is not a failure: the prompt is optional
1445
- * by design. Quitting is the one thing it does not swallow. Ctrl-C part way
1446
- * through a list of notes means I want out of the whole command, and a prompt
1447
- * that turned it into "no note" would walk me through the rest of the list and
1448
- * then act on findings I was no longer sure about.
1449
- */
1450
- const note = (message) => Effect.map(Prompt.String({ message }), (text) => text.trim() === "" ? Option.none() : Option.some(text.trim()));
1451
- /**
1452
- * How wide the screen is, or zero where there is no screen to measure.
1453
- *
1454
- * A prompt has to fit its row on one line: a row that wraps takes the list's
1455
- * alignment with it. Nothing is piping into a prompt, so zero means the writing
1456
- * is going somewhere that does not wrap either.
1457
- */
1458
- const width = Effect.gen(function* () {
1459
- return yield* (yield* Terminal.Terminal).columns;
1460
- });
1461
- //#endregion
1462
1513
  //#region src/cli/table.ts
1463
1514
  /**
1464
1515
  * The rows of a table, padded so the columns line up and with the trailing
@@ -1515,7 +1566,9 @@ const standing = (inventory) => inventory.cuttings.flatMap((cutting) => {
1515
1566
  /** The checkouts a review run cut, which no run that ended still needs. */
1516
1567
  const orphaned = (inventory) => inventory.cuttings.filter((cutting) => cutting.cut === "worktrees");
1517
1568
  const sum = (sizes) => ByteSize.bytes(sizes.reduce((total, size) => total + ByteSize.toBigInt(size), BigInt(0)));
1518
- const reason = (sessions) => sessions.map((it) => `a ${it.session === "fix" ? "fix" : "resolve"} session stands on ${it.repo}#${it.number}`).join(", ");
1569
+ /** What the glossary calls the session a standing checkout was cut for. */
1570
+ const sessionName = (session) => session === "fix" ? "fix" : "resolve";
1571
+ const reason = (sessions) => sessions.map((it) => `a ${sessionName(it.session)} session stands on ${it.repo}#${it.number}`).join(", ");
1519
1572
  /**
1520
1573
  * What a cleanup would take, weighed.
1521
1574
  *
@@ -1545,7 +1598,7 @@ const plan = (inventory) => {
1545
1598
  };
1546
1599
  };
1547
1600
  /** What the whole state directory weighs: the clones, the checkouts and the records. */
1548
- const everything = (inventory) => sum([
1601
+ const everything$1 = (inventory) => sum([
1549
1602
  ...inventory.clones.map((it) => it.size),
1550
1603
  ...inventory.cuttings.map((it) => it.size),
1551
1604
  inventory.records.size
@@ -1604,7 +1657,7 @@ const lines$3 = (it, state, path, paint) => {
1604
1657
  * configuration file - and what I worked in - the worktree of a fix or resolve
1605
1658
  * session, which stands on a branch of the tool's own and holds what I
1606
1659
  * committed there. Forgetting a pull request's records is a different question
1607
- * with a different answer (#58), and it is not asked here.
1660
+ * with a different answer (`dw-mc forget`), and it is not asked here.
1608
1661
  *
1609
1662
  * A clone with a session standing on it stays with the session: a standing
1610
1663
  * worktree keeps its history inside the clone, so a clone taken from under one
@@ -1772,6 +1825,10 @@ const PrView = Schema.fromJsonString(Schema.Struct({
1772
1825
  reviewDecision: Schema.String,
1773
1826
  statusCheckRollup: Schema.NullOr(Schema.Array(CheckEntry))
1774
1827
  }));
1828
+ /**
1829
+ * The fields one `gh pr view` asks for, named so a test can spell the vector it
1830
+ * expects without copying the list and watching it drift.
1831
+ */
1775
1832
  const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
1776
1833
  /**
1777
1834
  * Everything about one pull request that arrives without paging through it:
@@ -1962,13 +2019,13 @@ const Actor = Schema.NullOr(Schema.Struct({
1962
2019
  login: Schema.String,
1963
2020
  __typename: Schema.String
1964
2021
  }));
1965
- const Said = Schema.Struct({
2022
+ const Said$1 = Schema.Struct({
1966
2023
  author: Actor,
1967
2024
  body: Schema.String,
1968
2025
  createdAt: Schema.DateTimeUtcFromString
1969
2026
  });
1970
2027
  const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
1971
- comments: Schema.Struct({ nodes: Schema.Array(Said) }),
2028
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) }),
1972
2029
  reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
1973
2030
  author: Actor,
1974
2031
  body: Schema.String,
@@ -1979,7 +2036,7 @@ const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({
1979
2036
  isOutdated: Schema.Boolean,
1980
2037
  path: Schema.NullOr(Schema.String),
1981
2038
  line: Schema.NullOr(Schema.Int),
1982
- comments: Schema.Struct({ nodes: Schema.Array(Said) })
2039
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) })
1983
2040
  })) })
1984
2041
  }) }) }) }));
1985
2042
  const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
@@ -2151,12 +2208,15 @@ const Facts = Schema.Struct({
2151
2208
  newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2152
2209
  myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2153
2210
  myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2211
+ /** The newest comment my acknowledgement covers, or null where I have made none. */
2212
+ acknowledgedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2154
2213
  /** The head a review run has already covered, or null where none has. */
2155
2214
  reviewRunHead: Schema.NullOr(Schema.String),
2156
2215
  /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
2157
2216
  blockingFindings: Schema.Int
2158
2217
  });
2159
- Schema.Literals([
2218
+ /** The one place a tracked PR sits at a time, named for what it waits on. */
2219
+ const Bucket = Schema.Literals([
2160
2220
  "needs-me",
2161
2221
  "needs-review-run",
2162
2222
  "waiting-on-others",
@@ -2189,6 +2249,16 @@ const unanswered = "a comment I have not answered";
2189
2249
  */
2190
2250
  const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2191
2251
  /**
2252
+ * How far my answer to the conversation reaches: the latest of my last comment,
2253
+ * my last commit and my acknowledgement.
2254
+ *
2255
+ * A comment is answered by a reply, by a push, or by my word that nothing in it
2256
+ * was mine to answer. It is named because it is read twice: here, where a newer
2257
+ * comment puts the PR in Needs me, and by `dw-mc comments`, which shows exactly
2258
+ * the comments newer than it.
2259
+ */
2260
+ const answeredAt = (facts) => later(later(facts.myLastCommentAt, facts.myLastCommitAt), facts.acknowledgedAt);
2261
+ /**
2192
2262
  * The first of the rules that makes a PR mine to move, or null when none
2193
2263
  * does. The order is the order I would fix them in: a conflict makes every
2194
2264
  * other signal on the PR stale, and a red build is worth more than a comment.
@@ -2199,7 +2269,7 @@ const needsMe = (facts) => {
2199
2269
  if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2200
2270
  if (facts.reviewDecision === "changes-requested") return "changes requested";
2201
2271
  if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2202
- if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2272
+ if (isAfter(facts.newestHumanCommentAt, answeredAt(facts))) return unanswered;
2203
2273
  return null;
2204
2274
  };
2205
2275
  /**
@@ -2536,7 +2606,7 @@ const skippedSince = (asked, docsOnly) => {
2536
2606
  return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2537
2607
  };
2538
2608
  /** What a run was opened on, as the report says it. */
2539
- const askedOf$1 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2609
+ const askedOf$2 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2540
2610
  /**
2541
2611
  * The report as it is written down: what it is of, then what the run said.
2542
2612
  *
@@ -2548,7 +2618,7 @@ const reportDocument = (run, title, prose) => [
2548
2618
  `# ${run.repo}#${run.number} ${title}`,
2549
2619
  "",
2550
2620
  `- head: ${run.head}`,
2551
- `- run: ${askedOf$1(run)}`,
2621
+ `- run: ${askedOf$2(run)}`,
2552
2622
  `- ran: ${DateTime.formatIso(run.ranAt)}`,
2553
2623
  "",
2554
2624
  prose.trim(),
@@ -2721,6 +2791,20 @@ const marker = {
2721
2791
  "waiting-on-others": "○",
2722
2792
  ready: "◆"
2723
2793
  };
2794
+ /**
2795
+ * What sits in front of a row: `+` for a pull request never shown, `*` for one
2796
+ * that moved since it was last shown, and a blank for one that did neither.
2797
+ *
2798
+ * It is one character from the part of Unicode every font has, for the reason
2799
+ * the marker is, and it is not coloured: the bucket is what the colour says.
2800
+ */
2801
+ const gutter = {
2802
+ new: "+",
2803
+ moved: "*",
2804
+ still: " "
2805
+ };
2806
+ /** How a row names its pull request. */
2807
+ const reference = (facts) => `${facts.repo}#${facts.number}`;
2724
2808
  /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2725
2809
  const tint = (paint, bucket) => ({
2726
2810
  "needs-me": paint.red,
@@ -2733,6 +2817,8 @@ const rule = " │ ";
2733
2817
  /**
2734
2818
  * One row: which pull request, what it is, and what it waits on.
2735
2819
  *
2820
+ * In front of it all is the gutter, which says what moved since I last looked.
2821
+ *
2736
2822
  * A stamp is a mark beside the pull request rather than a column of its own, so
2737
2823
  * a table where nothing is stamped is exactly the table it was before: the
2738
2824
  * stamp is a thing I look for, not a thing I read every row of.
@@ -2750,25 +2836,51 @@ const rule = " │ ";
2750
2836
  * straight, so its rows say it in more than one place and open the pull request
2751
2837
  * besides.
2752
2838
  */
2753
- const cells = (placed, stamped, room, paint, lead) => {
2839
+ const cells = (placed, stamped, since, room, paint, lead) => {
2754
2840
  const { facts } = placed;
2755
2841
  const { bucket } = placed.placement;
2756
2842
  const say = tint(paint, bucket);
2757
- const reference = `${facts.repo}#${facts.number}`;
2758
2843
  const named = lead === "named";
2759
- const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2844
+ const pr = `${named ? reference(facts) : paint.link(reference(facts), facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2845
+ const front = gutter[since._tag];
2760
2846
  return named ? [
2761
- say(`${marker[bucket]} ${heading[bucket]}`),
2847
+ `${front} ${say(`${marker[bucket]} ${heading[bucket]}`)}`,
2762
2848
  pr,
2763
2849
  truncate(facts.title, room),
2764
2850
  placed.placement.reason
2765
2851
  ] : [
2766
- `${say(marker[bucket])} ${pr}`,
2852
+ `${front} ${say(marker[bucket])} ${pr}`,
2767
2853
  paint.dim(truncate(facts.title, room)),
2768
2854
  say(placed.placement.reason)
2769
2855
  ];
2770
2856
  };
2771
2857
  //#endregion
2858
+ //#region src/domain/acknowledgement.ts
2859
+ /**
2860
+ * My word that I have read a pull request's conversation as far as one comment,
2861
+ * and that nothing in it is mine to answer.
2862
+ *
2863
+ * The comment's moment is the whole record, and no head is: everything else the
2864
+ * tool records is about code and lapses when the head changes, and this one is
2865
+ * about a conversation. A push is already counted as my answer to a comment, so
2866
+ * tying this to a head would only bring back a comment the push had nothing to
2867
+ * do with.
2868
+ */
2869
+ const Acknowledgement = Schema.Struct({ at: Schema.DateTimeUtcFromString });
2870
+ /** The newest comment I have acknowledged on a pull request, or null where I have acknowledged none. */
2871
+ const acknowledgedAt = Effect.fn("acknowledgement.acknowledgedAt")(function* (repo, number) {
2872
+ const store = yield* storeFor("acknowledgements", Acknowledgement);
2873
+ const acknowledgement = yield* remembered(store.get(prKey(repo, number)));
2874
+ return Option.match(acknowledgement, {
2875
+ onNone: () => null,
2876
+ onSome: (it) => it.at
2877
+ });
2878
+ });
2879
+ /** Records that I have read a pull request's conversation as far as the comment written at `at`. */
2880
+ const acknowledge = Effect.fn("acknowledgement.acknowledge")(function* (repo, number, at) {
2881
+ yield* (yield* storeFor("acknowledgements", Acknowledgement)).set(prKey(repo, number), { at });
2882
+ });
2883
+ //#endregion
2772
2884
  //#region src/domain/comments.ts
2773
2885
  /**
2774
2886
  * One thread's share of a strand, cut to what is worth reading.
@@ -2809,9 +2921,24 @@ const shown = (threads, options) => {
2809
2921
  bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2810
2922
  };
2811
2923
  };
2924
+ /**
2925
+ * The comment an acknowledgement of this conversation covers: the newest thing
2926
+ * a person said in it, whichever thread it is in.
2927
+ *
2928
+ * The whole conversation rather than what went on screen, because the bucket
2929
+ * rule counts the whole of it: a comment on a thread somebody resolved still
2930
+ * puts the pull request in Needs me, and an acknowledgement that stopped short
2931
+ * of it would settle nothing. A bot is left out for the reason the rule leaves
2932
+ * it out.
2933
+ *
2934
+ * It is a comment's own moment and never the clock's, so a comment written
2935
+ * after the conversation was read is one the acknowledgement does not cover.
2936
+ */
2937
+ const acknowledging = (threads) => newest(threads.flatMap((thread) => thread.comments.filter((it) => !it.bot).map((it) => it.at)));
2812
2938
  //#endregion
2813
2939
  //#region src/cli/comments.ts
2814
- const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2940
+ const allFlag$1 = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2941
+ const ackFlag = Flag.Boolean("ack").pipe(Flag.withDefault(false), Flag.withDescription("Record that I have read the conversation and nothing in it is mine to answer"));
2815
2942
  /** Where a thread hangs: a line of the diff, or the pull request itself. */
2816
2943
  const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2817
2944
  /**
@@ -2857,45 +2984,75 @@ const nothing$1 = (facts, all) => {
2857
2984
  const placement = place(facts);
2858
2985
  const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2859
2986
  return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
2860
- "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
2861
- `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
2987
+ "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment, commit or acknowledgement.",
2988
+ `${pr} sits in ${heading[placement.bucket]} all the same, and a reply, a push or dw-mc comments ${facts.number} --ack is what settles it.`,
2862
2989
  rest
2863
- ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
2990
+ ] : [`Nothing has been said on ${pr} since your last comment, commit or acknowledgement.`, rest];
2864
2991
  };
2865
2992
  /**
2993
+ * Records an acknowledgement of the conversation read, and says what it covers
2994
+ * and where the pull request sits with it.
2995
+ *
2996
+ * Where it sits is worked out from the last sweep with the acknowledgement laid
2997
+ * over it, which is the same answer the next sweep gives unless somebody says
2998
+ * something new in between.
2999
+ */
3000
+ const acknowledged = Effect.fn("comments.acknowledged")(function* (facts, threads) {
3001
+ const pr = `${facts.repo}#${facts.number}`;
3002
+ const at = acknowledging(threads);
3003
+ if (at === null) {
3004
+ yield* Console.log(`Nothing to acknowledge: nobody has said anything on ${pr}.`);
3005
+ return;
3006
+ }
3007
+ yield* acknowledge(facts.repo, facts.number, at);
3008
+ const placement = place({
3009
+ ...facts,
3010
+ acknowledgedAt: at
3011
+ });
3012
+ yield* Console.log(`Acknowledged everything said on ${pr} up to ${DateTime.formatIso(at)}.`);
3013
+ yield* Console.log(`${pr} sits in ${heading[placement.bucket]}: ${placement.reason}.`);
3014
+ });
3015
+ /**
2866
3016
  * The conversation on one tracked pull request, and nothing else.
2867
3017
  *
2868
3018
  * What it shows by default is what the bucket rule measures: the comments newer
2869
- * than the later of my last comment and my last commit, which are the ones that
2870
- * put the pull request in Needs me. Reading it answers the question the table
2871
- * asked.
3019
+ * than the latest of my last comment, my last commit and my acknowledgement,
3020
+ * which are the ones that put the pull request in Needs me. Reading it answers
3021
+ * the question the table asked.
2872
3022
  *
2873
3023
  * The cutoff is read off the last sweep rather than worked out again here, so
2874
3024
  * the command shows exactly what `dw-mc status` counted rather than a second
2875
3025
  * opinion about it.
2876
3026
  *
2877
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2878
- * (ADR 0002). Reading is the whole command.
3027
+ * It writes nothing to GitHub: no reply, no resolve, no reaction (ADR 0002).
3028
+ * `--ack` is the one thing it writes at all, and only on this machine: whether a
3029
+ * comment needs an answer is known after reading it, so reading cannot be what
3030
+ * decides it.
2879
3031
  */
2880
3032
  const comments = Command.make("comments", {
2881
3033
  pr: prArgument,
2882
- all: allFlag
2883
- }, Effect.fn("comments")(function* ({ all, pr }) {
3034
+ all: allFlag$1,
3035
+ ack: ackFlag
3036
+ }, Effect.fn("comments")(function* ({ ack, all, pr }) {
2884
3037
  const { number, repo } = yield* forPr(pr);
2885
3038
  const facts = yield* swept(repo, number);
2886
3039
  const paint = yield* Paint;
2887
- const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2888
- since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3040
+ const threads = yield* reading(`${repo}#${number}`, prConversation(repo, number));
3041
+ const view = shown(threads, {
3042
+ since: answeredAt(facts),
2889
3043
  all
2890
3044
  });
2891
- if (view.people.length === 0 && view.bots.length === 0) {
2892
- yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2893
- return;
3045
+ if (view.people.length === 0 && view.bots.length === 0) yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
3046
+ else {
3047
+ yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
3048
+ yield* Console.log("");
3049
+ yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2894
3050
  }
2895
- yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
2896
- yield* Console.log("");
2897
- yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2898
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
3051
+ if (ack) {
3052
+ yield* Console.log("");
3053
+ yield* acknowledged(facts, threads);
3054
+ }
3055
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request and what is waiting on me in it, and with --ack record that I read it"));
2899
3056
  //#endregion
2900
3057
  //#region src/cli/findings.ts
2901
3058
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
@@ -3484,6 +3641,83 @@ const fix = Command.make("fix", {
3484
3641
  yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3485
3642
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3486
3643
  //#endregion
3644
+ //#region src/domain/forget.ts
3645
+ /**
3646
+ * Whether a key in the state directory is about this pull request, whichever
3647
+ * namespace it sits in.
3648
+ *
3649
+ * Every namespace names a pull request by `prKey`, and the review runs add the
3650
+ * head after an `@`. Reading the key rather than a list of namespaces is what
3651
+ * lets a namespace added later be forgotten with the rest.
3652
+ */
3653
+ const isAbout = (key, repo, number) => {
3654
+ const slash = key.indexOf("/");
3655
+ if (slash < 0) return false;
3656
+ const rest = key.slice(slash + 1);
3657
+ const pr = prKey(repo, number);
3658
+ return rest === pr || rest.startsWith(`${pr}@`);
3659
+ };
3660
+ /**
3661
+ * Removes everything the state directory keeps about one pull request, and
3662
+ * says how many keys that was.
3663
+ *
3664
+ * A pull request it keeps nothing about is forgotten already, so that is none
3665
+ * rather than a failure.
3666
+ */
3667
+ const forget = Effect.fn("forget.forget")(function* (repo, number) {
3668
+ const store = yield* KeyValueStore.KeyValueStore;
3669
+ const about = (yield* allKeys).filter((key) => isAbout(key, repo, number));
3670
+ yield* Effect.forEach(about, (key) => store.remove(key), { discard: true });
3671
+ return about.length;
3672
+ });
3673
+ /**
3674
+ * The session worktrees that stand on one pull request.
3675
+ *
3676
+ * Forgetting takes none of them: each stands on a branch of the tool's own and
3677
+ * holds what I committed there, so what the tool does is say they are there.
3678
+ */
3679
+ const standingOn = (inventory, repo, number) => standing(inventory).filter((it) => it.repo === repo && it.number === number);
3680
+ //#endregion
3681
+ //#region src/cli/forget.ts
3682
+ /**
3683
+ * Forgets one pull request and says what stays: the records go, the session
3684
+ * worktrees standing on it do not.
3685
+ *
3686
+ * `deleted` is the branch the pull request stood on, where the caller has just
3687
+ * deleted it. A session there tracks a branch that is gone, which is worth
3688
+ * saying, and what it holds is still mine, so saying it is all this does.
3689
+ */
3690
+ const forgetting = Effect.fn("forgetting")(function* (repo, number, options = {}) {
3691
+ const deleted = options.deleted;
3692
+ const path = yield* Path.Path;
3693
+ const paint = yield* Paint;
3694
+ const where = `${repo}#${number}`;
3695
+ const forgot = yield* forget(repo, number);
3696
+ yield* Console.log(forgot === 0 ? `Nothing is kept about ${where}.` : `Forgot ${where}: ${count(forgot, "record")}.`);
3697
+ const found = yield* inventory;
3698
+ const sessions = standingOn(found, repo, number);
3699
+ if (sessions.length === 0) return;
3700
+ const rows = table(sessions.map((it) => [paint.dim(path.relative(found.directory, it.directory)), `a ${sessionName(it.session)} session's worktree, on ${sessionBranch(it.session, number)}` + (deleted === void 0 ? "" : `, which tracked ${deleted} - deleted with the merge`)]));
3701
+ yield* Console.log("");
3702
+ yield* Console.log("Stays");
3703
+ yield* Effect.forEach(rows, (row) => Console.log(` ${row}`));
3704
+ yield* Console.log("");
3705
+ yield* Console.log("What you committed there is yours, so nothing here takes it down.");
3706
+ });
3707
+ /**
3708
+ * Forgets a pull request that closed some other way than `dw-mc merge`.
3709
+ *
3710
+ * A sweep never does this on its own. A pull request missing from one search is
3711
+ * not one that is gone - a failed `gh search` would look the same - and a sweep
3712
+ * is built so that a failure costs a row rather than the table. Being done is
3713
+ * something I know and the tool does not, except when it merged the pull
3714
+ * request itself.
3715
+ */
3716
+ const forgetCommand = Command.make("forget", { pr: prArgument }, Effect.fn("forget")(function* ({ pr }) {
3717
+ const { number, repo } = yield* forPr(pr);
3718
+ yield* forgetting(repo, number);
3719
+ })).pipe(Command.withDescription("Forget everything kept about a pull request that is done"));
3720
+ //#endregion
3487
3721
  //#region src/cli/init.ts
3488
3722
  const effortFlag$1 = Flag.Literals("effort", [
3489
3723
  "low",
@@ -3920,6 +4154,10 @@ const decide$3 = (situation) => {
3920
4154
  *
3921
4155
  * Typing the command is the confirmation, so it takes no flag. The picker,
3922
4156
  * where a keystroke is cheaper, asks before it dispatches.
4157
+ *
4158
+ * Its last step is forgetting the pull request, in every namespace. A session
4159
+ * worktree standing on it stays, and is named, because its branch tracked the
4160
+ * one this just deleted.
3923
4161
  */
3924
4162
  const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3925
4163
  const { number, repo, settings } = yield* forPr(pr);
@@ -3940,8 +4178,48 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
3940
4178
  yield* mergePr(repo, number);
3941
4179
  yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3942
4180
  yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
4181
+ yield* forgetting(repo, number, { deleted: view.headRefName }).pipe(Effect.catch((error) => Console.log(`\nCould not forget ${repo}#${number}: ${error.message}. Run dw-mc forget ${number} to try again.`)));
3943
4182
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3944
4183
  //#endregion
4184
+ //#region src/domain/coverage.ts
4185
+ const one = (repo, registered) => ({
4186
+ _tag: "covers",
4187
+ repos: [repo],
4188
+ leftOut: registered.length - 1
4189
+ });
4190
+ /**
4191
+ * The repositories a sweep covers.
4192
+ *
4193
+ * Standing in a registered repository is asking for that one, because that is
4194
+ * the table I want while I work there. Standing anywhere else asks for nothing
4195
+ * in particular, so it gets every registered repository, as it would have
4196
+ * before a sweep could be narrowed at all.
4197
+ *
4198
+ * `here` is the repository the working directory is in, and is only read when
4199
+ * neither flag decides.
4200
+ */
4201
+ const covered = (asked, here, registered) => {
4202
+ if (asked.repo !== void 0 && asked.all) return {
4203
+ _tag: "refused",
4204
+ why: "--repo names one repository and --all asks for every one. Pass one of them."
4205
+ };
4206
+ if (asked.repo !== void 0) {
4207
+ if (registered.includes(asked.repo)) return one(asked.repo, registered);
4208
+ return {
4209
+ _tag: "refused",
4210
+ why: registered.length === 0 ? `${asked.repo} is not registered. Run dw-mc init inside it to register it.` : `${asked.repo} is not registered. Name one of ${registered.join(", ")}, or run dw-mc init inside it.`
4211
+ };
4212
+ }
4213
+ if (!asked.all && here !== void 0 && registered.includes(here)) return one(here, registered);
4214
+ return {
4215
+ _tag: "covers",
4216
+ repos: registered,
4217
+ leftOut: 0
4218
+ };
4219
+ };
4220
+ /** Whether the working directory decides the coverage, which is the one case worth asking `gh` where it is. */
4221
+ const asksWhereIAm = (asked, registered) => asked.repo === void 0 && !asked.all && registered.length > 0;
4222
+ //#endregion
3945
4223
  //#region src/domain/flaky.ts
3946
4224
  /**
3947
4225
  * The failures that are flaky wherever they appear: a machine, a network or a
@@ -4233,6 +4511,13 @@ const recordConflict = Effect.fn("rebase.recordConflict")(function* (repo, numbe
4233
4511
  });
4234
4512
  //#endregion
4235
4513
  //#region src/cli/sweep.ts
4514
+ /** What the picker asks a sweep for: every registered repository, wherever I stand. */
4515
+ const everything = {
4516
+ repo: void 0,
4517
+ all: true
4518
+ };
4519
+ /** The repository I stand in, or none where the working directory is not one. */
4520
+ const whereIAm = currentRepo.pipe(Effect.asSome, Effect.catchTag("NoRepository", () => Effect.succeedNone), Effect.map(Option.getOrUndefined));
4236
4521
  const writtenBy = (comments, login) => comments.filter((comment) => comment.login === login).map((comment) => comment.at);
4237
4522
  const byHumansOtherThan = (comments, login) => comments.filter((comment) => !comment.bot && comment.login !== login).map((comment) => comment.at);
4238
4523
  /**
@@ -4275,6 +4560,7 @@ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, sett
4275
4560
  newestHumanCommentAt,
4276
4561
  myLastCommentAt: newest(writtenBy(comments, me)),
4277
4562
  myLastCommitAt,
4563
+ acknowledgedAt: yield* acknowledgedAt(found.repo, found.number),
4278
4564
  ...reviewed
4279
4565
  };
4280
4566
  yield* store.set(key, facts);
@@ -4306,7 +4592,8 @@ const saying$1 = (swept) => (since) => [
4306
4592
  since
4307
4593
  ].join(" · ");
4308
4594
  /**
4309
- * One pass over every tracked PR, and nothing else: a sweep only ever reads.
4595
+ * One pass over the tracked PRs of the repositories `asked` covers, and nothing
4596
+ * else: a sweep only ever reads.
4310
4597
  *
4311
4598
  * Every repository and every pull request is read on its own, so one of them
4312
4599
  * failing costs me its rows and leaves the rest of the table standing. What
@@ -4316,11 +4603,15 @@ const saying$1 = (swept) => (since) => [
4316
4603
  * that is worth saying is the caller's, which is why it is handed a count and
4317
4604
  * not a sentence.
4318
4605
  */
4319
- const sweep = Effect.fn("sweep")(function* (report) {
4606
+ const sweep = Effect.fn("sweep")(function* (asked, report) {
4320
4607
  const file = Option.getOrElse(yield* read, () => ({}));
4321
- const repos = Object.keys(file.repos ?? {}).toSorted();
4608
+ const registered = Object.keys(file.repos ?? {}).toSorted();
4609
+ const coverage = covered(asked, asksWhereIAm(asked, registered) ? yield* whereIAm : void 0, registered);
4610
+ if (coverage._tag === "refused") return yield* new CliError.UserError({ cause: coverage.why });
4611
+ const { repos, leftOut } = coverage;
4322
4612
  if (repos.length === 0) return {
4323
4613
  repos,
4614
+ leftOut,
4324
4615
  facts: [],
4325
4616
  troubles: []
4326
4617
  };
@@ -4356,6 +4647,7 @@ const sweep = Effect.fn("sweep")(function* (report) {
4356
4647
  }), { concurrency }));
4357
4648
  return {
4358
4649
  repos,
4650
+ leftOut,
4359
4651
  facts: swept.got,
4360
4652
  troubles: [...found.troubles, ...swept.troubles]
4361
4653
  };
@@ -4367,7 +4659,25 @@ const sweep = Effect.fn("sweep")(function* (report) {
4367
4659
  * three times over. It gives the heartbeat no aside, so a piped `dw-mc status`
4368
4660
  * prints exactly what it printed before there was a heartbeat at all.
4369
4661
  */
4370
- const sweeping = beating((since) => `sweeping · ${since}`, (says) => sweep((swept) => says(saying$1(swept))));
4662
+ const sweeping = (asked) => beating((since) => `sweeping · ${since}`, (says) => sweep(asked, (swept) => says(saying$1(swept))));
4663
+ /** `--repo`, for a command that sweeps: the one registered repository to cover. */
4664
+ const repoFlag = Flag.String("repo").pipe(Flag.withDescription("Cover this registered repository only, as owner/name, wherever I stand"), Flag.optional);
4665
+ /** `--all`, for a command that sweeps: every registered repository, even from inside one. */
4666
+ const allFlag = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Cover every registered repository, and not only the one I stand in"));
4667
+ /** What the two flags ask a sweep to cover. */
4668
+ const askedOf$1 = (flags) => ({
4669
+ repo: Option.getOrUndefined(flags.repo),
4670
+ all: flags.all
4671
+ });
4672
+ /**
4673
+ * The word a narrowed report owes me about what it left out, so a table of one
4674
+ * repository is not read as the whole picture.
4675
+ */
4676
+ const printLeftOut = Effect.fn("sweep.printLeftOut")(function* (report) {
4677
+ if (report.leftOut === 0) return;
4678
+ yield* Console.log("");
4679
+ yield* Console.log(`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`);
4680
+ });
4371
4681
  /** What a sweep could not read, under a heading, so the table above it stands alone. */
4372
4682
  const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4373
4683
  if (troubles.length === 0) return;
@@ -4376,16 +4686,20 @@ const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4376
4686
  for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
4377
4687
  });
4378
4688
  /**
4379
- * Refreshes what mission control knows about every tracked PR.
4689
+ * Refreshes what mission control knows about the tracked PRs it covers.
4380
4690
  *
4381
4691
  * `dw-mc status` does this too, so this command is for the pass on its own:
4382
4692
  * warming the state directory, or seeing what GitHub would not answer.
4383
4693
  */
4384
- const sweepCommand = Command.make("sweep", {}, Effect.fn("sweep.command")(function* () {
4385
- const report = yield* sweeping;
4694
+ const sweepCommand = Command.make("sweep", {
4695
+ repo: repoFlag,
4696
+ all: allFlag
4697
+ }, Effect.fn("sweep.command")(function* (flags) {
4698
+ const report = yield* sweeping(askedOf$1(flags));
4386
4699
  yield* Console.log(report.repos.length === 0 ? "No repositories registered. Run dw-mc init inside a repository to register it." : `Swept ${count(report.facts.length, "pull request")} across ${repositories(report.repos.length)}`);
4700
+ yield* printLeftOut(report);
4387
4701
  yield* printTroubles(report.troubles);
4388
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about every tracked pull request"));
4702
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Refresh what mission control knows about the tracked pull requests of the repository I stand in, or of every one"));
4389
4703
  //#endregion
4390
4704
  //#region src/domain/pick.ts
4391
4705
  /**
@@ -4522,6 +4836,113 @@ const recordRerun = Effect.fn("rerun.recordRerun")(function* (repo, number, head
4522
4836
  yield* (yield* storeFor("reruns", Rerun)).set(prKey(repo, number), { head });
4523
4837
  });
4524
4838
  //#endregion
4839
+ //#region src/domain/watermark.ts
4840
+ /** What a row turns on, which is what a watermark keeps of it. */
4841
+ const Said = Schema.Struct({
4842
+ bucket: Bucket,
4843
+ mergeable: Mergeability,
4844
+ checks: ChecksState,
4845
+ /** Whether the flaky classifier excused a red CI. */
4846
+ excused: Schema.Boolean,
4847
+ reviewDecision: ReviewDecision,
4848
+ blockingFindings: Schema.Int,
4849
+ newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
4850
+ draft: Schema.Boolean
4851
+ });
4852
+ /**
4853
+ * The moment a tracked PR's row was last shown to me, and what the row said.
4854
+ *
4855
+ * It is kept on its own rather than on `Facts`, because a quiet PR carries its
4856
+ * old facts forward unchanged and a moment stored there would be about the
4857
+ * wrong thing. What it holds is only what a row turns on, so a fact the sweep
4858
+ * starts reading later does not make every row look new.
4859
+ *
4860
+ * Only a command that puts the row on screen writes one. A sweep on its own
4861
+ * shows nothing, and moving the watermark there would erase movement nobody saw.
4862
+ */
4863
+ const Watermark = Schema.Struct({
4864
+ at: Schema.DateTimeUtcFromString,
4865
+ ...Said.fields
4866
+ });
4867
+ const keyOf = ({ facts }) => prKey(facts.repo, facts.number);
4868
+ /** What a row says. */
4869
+ const said = ({ facts, placement }) => ({
4870
+ bucket: placement.bucket,
4871
+ mergeable: facts.mergeable,
4872
+ checks: facts.checks,
4873
+ excused: facts.ciFlaky !== null,
4874
+ reviewDecision: facts.reviewDecision,
4875
+ blockingFindings: facts.blockingFindings,
4876
+ newestHumanCommentAt: facts.newestHumanCommentAt,
4877
+ draft: facts.draft
4878
+ });
4879
+ /** What a row said at the moment it was shown. */
4880
+ const sighted = (placed, at) => ({
4881
+ at,
4882
+ ...said(placed)
4883
+ });
4884
+ const ci = (it) => it.checks === "red" && it.excused ? "red, called flaky" : it.checks;
4885
+ const review$1 = {
4886
+ approved: "approved",
4887
+ "changes-requested": "changes requested",
4888
+ "review-required": "review required",
4889
+ none: "no review decision"
4890
+ };
4891
+ /**
4892
+ * The facts that moved between two sightings, said the way the row would say them.
4893
+ *
4894
+ * The head is left out on purpose: it changes on every push, and a list that
4895
+ * leads with it drowns what the push did. A mergeability GitHub has not worked
4896
+ * out yet is left out too, because it flickers to unknown and back on its own.
4897
+ */
4898
+ const moved = (then, now) => [
4899
+ then.mergeable !== now.mergeable && then.mergeable !== "unknown" && now.mergeable !== "unknown" ? `${then.mergeable} → ${now.mergeable}` : null,
4900
+ ci(then) !== ci(now) ? `CI ${ci(then)} → ${ci(now)}` : null,
4901
+ then.reviewDecision !== now.reviewDecision ? `${review$1[then.reviewDecision]} → ${review$1[now.reviewDecision]}` : null,
4902
+ then.blockingFindings !== now.blockingFindings ? `${then.blockingFindings} → ${now.blockingFindings} blocking findings` : null,
4903
+ isAfter(now.newestHumanCommentAt, then.newestHumanCommentAt) ? "a new comment" : null,
4904
+ then.draft !== now.draft ? now.draft ? "back to draft" : "out of draft" : null
4905
+ ].filter((it) => it !== null);
4906
+ /**
4907
+ * What happened to a row since `watermark`, the last time it was shown.
4908
+ *
4909
+ * Movement is a bucket transition plus the facts that moved with it. Either one
4910
+ * alone misses something: every field compared drowns in the head, and a bucket
4911
+ * on its own says nothing when CI goes green on a PR still mine for another
4912
+ * reason.
4913
+ */
4914
+ const since = (watermark, placed) => {
4915
+ if (watermark === void 0) return { _tag: "new" };
4916
+ const now = said(placed);
4917
+ const what = moved(watermark, now);
4918
+ const from = watermark.bucket === now.bucket ? void 0 : watermark.bucket;
4919
+ return from === void 0 && what.length === 0 ? { _tag: "still" } : {
4920
+ _tag: "moved",
4921
+ from,
4922
+ what
4923
+ };
4924
+ };
4925
+ /**
4926
+ * What happened to each of these rows since it was last shown.
4927
+ *
4928
+ * It answers for any row, and a row it was not given is one it has no
4929
+ * watermark for, which is a row never shown.
4930
+ */
4931
+ const sinceShown = Effect.fn("watermark.sinceShown")(function* (placed) {
4932
+ const store = yield* storeFor("watermarks", Watermark);
4933
+ const found = new Map(yield* Effect.forEach(placed, (it) => {
4934
+ const key = keyOf(it);
4935
+ return Effect.map(remembered(store.get(key)), (mark) => [key, since(Option.getOrUndefined(mark), it)]);
4936
+ }));
4937
+ return (it) => found.get(keyOf(it)) ?? { _tag: "new" };
4938
+ });
4939
+ /** Records that these rows were shown to me, now, saying what they say. */
4940
+ const markShown = Effect.fn("watermark.markShown")(function* (placed) {
4941
+ const store = yield* storeFor("watermarks", Watermark);
4942
+ const now = yield* DateTime.now;
4943
+ yield* Effect.forEach(placed, (it) => store.set(keyOf(it), sighted(it, now)), { discard: true });
4944
+ });
4945
+ //#endregion
4525
4946
  //#region src/cli/pick.ts
4526
4947
  /** A title cut this short says nothing, so a row that tight loses the column instead. */
4527
4948
  const shortest = 12;
@@ -4547,9 +4968,10 @@ const screenRoom = (screen, paint) => screen === 0 ? Number.POSITIVE_INFINITY :
4547
4968
  * The cells come from there rather than being built again here, so the list I
4548
4969
  * pick from and the table I read are the same rows with the bucket moved onto
4549
4970
  * each of them. A prompt has no headings to group under, so the bucket is named
4550
- * on every row; the rows are still in the order the buckets are acted on.
4971
+ * on every row; the rows are still in the order the buckets are acted on. The
4972
+ * gutter in front says what moved since I last looked, as it does in the table.
4551
4973
  */
4552
- const cellsOf = ({ placed, stamped }, room, paint) => cells(placed, stamped, room, paint, "named");
4974
+ const cellsOf = ({ placed, stamped }, since, room, paint) => cells(placed, stamped, since, room, paint, "named");
4553
4975
  /**
4554
4976
  * Every tracked PR as something to pick, aligned down the whole list.
4555
4977
  *
@@ -4560,13 +4982,13 @@ const cellsOf = ({ placed, stamped }, room, paint) => cells(placed, stamped, roo
4560
4982
  * tells me less than the room it took. A screen too narrow for all four columns
4561
4983
  * loses the title's column rather than the reason's words.
4562
4984
  */
4563
- const choicesOf = (standings, screen, paint) => {
4564
- const measured = standings.map((it) => cellsOf(it, Number.POSITIVE_INFINITY, paint));
4985
+ const choicesOf = (standings, sinceOf, screen, paint) => {
4986
+ const measured = standings.map((it) => cellsOf(it, sinceOf(it.placed), Number.POSITIVE_INFINITY, paint));
4565
4987
  const widest = (index) => Math.max(...measured.map((row) => visible(row[index] ?? "")));
4566
4988
  const room = screenRoom(screen, paint) - (widest(0) + widest(1) + widest(3)) - 9;
4567
4989
  const told = room >= shortest;
4568
4990
  const rows = table(standings.map((it) => {
4569
- const row = cellsOf(it, told ? room : 0, paint);
4991
+ const row = cellsOf(it, sinceOf(it.placed), told ? room : 0, paint);
4570
4992
  return told ? row : [
4571
4993
  row[0] ?? "",
4572
4994
  row[1] ?? "",
@@ -4603,7 +5025,7 @@ const where = (facts) => `${facts.repo}#${facts.number}`;
4603
5025
  * and a merge must never cost only that (ADR 0008).
4604
5026
  */
4605
5027
  const picker = (dispatch) => Effect.fn("pick")(function* () {
4606
- const report = yield* sweeping;
5028
+ const report = yield* sweeping(everything);
4607
5029
  if (report.repos.length === 0) {
4608
5030
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
4609
5031
  return;
@@ -4623,7 +5045,10 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4623
5045
  yield* Console.log("No open pull requests.");
4624
5046
  return;
4625
5047
  }
4626
- const chosen = yield* pick("Which pull request?", choicesOf(standings, yield* width, yield* Paint));
5048
+ const shown = standings.map((it) => it.placed);
5049
+ const choices = choicesOf(standings, yield* sinceShown(shown), yield* width, yield* Paint);
5050
+ yield* markShown(shown);
5051
+ const chosen = yield* pick("Which pull request?", choices);
4627
5052
  if (Option.isNone(chosen)) return;
4628
5053
  const facts = chosen.value.placed.facts;
4629
5054
  const offer = yield* pick(`What do I do with ${where(facts)}?`, actionChoices(actionsFor(chosen.value)));
@@ -5290,6 +5715,13 @@ const stampCommand = Command.make("stamp", {
5290
5715
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print my stamp on one pull request, or withdraw it by hand"));
5291
5716
  //#endregion
5292
5717
  //#region src/cli/status.ts
5718
+ /** What moved a row, said on its own line above the group it now sits in. */
5719
+ const movement = (placed, since) => {
5720
+ if (since._tag !== "moved") return [];
5721
+ const from = since.from === void 0 ? "" : ` from ${heading[since.from]}`;
5722
+ const what = since.what.length === 0 ? "" : `: ${since.what.join(", ")}`;
5723
+ return [` ↳ ${reference(placed.facts)}${from}${what}`];
5724
+ };
5293
5725
  /**
5294
5726
  * Every tracked PR under the bucket it sits in, in the order I act on them.
5295
5727
  *
@@ -5297,9 +5729,12 @@ const stampCommand = Command.make("stamp", {
5297
5729
  * the whole table rather than restarting under each heading, and they are ruled
5298
5730
  * apart: three columns of prose run into one another without a rule, and the
5299
5731
  * middle one is a commit subject that can end in anything.
5732
+ *
5733
+ * What moved since I last looked is marked in the gutter the rows are indented
5734
+ * by, so a mark costs no column and a row with none reads as it always did.
5300
5735
  */
5301
- const lines = (grouped, stamped, paint) => {
5302
- const rows = table(grouped.flatMap((it) => it.placed.map((placed) => cells(placed, stamped.has(prKey(placed.facts.repo, placed.facts.number)), 56, paint, "marker"))), rule);
5736
+ const lines = (grouped, stamped, sinceOf, paint) => {
5737
+ const rows = table(grouped.flatMap((it) => it.placed.map((placed) => cells(placed, stamped.has(prKey(placed.facts.repo, placed.facts.number)), sinceOf(placed), 56, paint, "marker"))), rule);
5303
5738
  let taken = 0;
5304
5739
  return grouped.flatMap((it, index) => {
5305
5740
  const mine = rows.slice(taken, taken + it.placed.length);
@@ -5307,7 +5742,8 @@ const lines = (grouped, stamped, paint) => {
5307
5742
  return [
5308
5743
  ...index === 0 ? [] : [""],
5309
5744
  heading[it.bucket],
5310
- ...mine.map((row) => ` ${row}`)
5745
+ ...it.placed.flatMap((placed) => movement(placed, sinceOf(placed))),
5746
+ ...mine
5311
5747
  ];
5312
5748
  });
5313
5749
  };
@@ -5316,17 +5752,23 @@ const lines = (grouped, stamped, paint) => {
5316
5752
  *
5317
5753
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5318
5754
  */
5319
- const status = Command.make("status", {}, Effect.fn("status")(function* () {
5320
- const report = yield* sweeping;
5755
+ const status = Command.make("status", {
5756
+ repo: repoFlag,
5757
+ all: allFlag
5758
+ }, Effect.fn("status")(function* (flags) {
5759
+ const report = yield* sweeping(askedOf$1(flags));
5321
5760
  if (report.repos.length === 0) {
5322
5761
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
5323
5762
  return;
5324
5763
  }
5325
5764
  const grouped = group(report.facts);
5326
5765
  if (grouped.length === 0) yield* Console.log("No open pull requests.");
5327
- for (const line of lines(grouped, yield* stampedAmong(report.facts), yield* Paint)) yield* Console.log(line);
5766
+ const shown = grouped.flatMap((it) => it.placed);
5767
+ for (const line of lines(grouped, yield* stampedAmong(report.facts), yield* sinceShown(shown), yield* Paint)) yield* Console.log(line);
5768
+ yield* printLeftOut(report);
5328
5769
  yield* printTroubles(report.troubles);
5329
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Show which bucket every tracked pull request sits in, and which ones I have stamped"));
5770
+ yield* markShown(shown);
5771
+ }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Show which bucket every tracked pull request of the repository I stand in, or of every one, sits in, which ones I have stamped, and what moved since I last looked"));
5330
5772
  //#endregion
5331
5773
  //#region src/cli/uninstall.ts
5332
5774
  const configFlag = Flag.Boolean("config").pipe(Flag.withDefault(false), Flag.withDescription("Take the configuration file too, and not only the state"));
@@ -5382,7 +5824,7 @@ const uninstall = Command.make("uninstall", {
5382
5824
  }] : [])).pipe(Effect.map((found_) => found_.flat()));
5383
5825
  yield* Effect.forEach(removes({
5384
5826
  directory: found.directory,
5385
- size: weight(everything(found))
5827
+ size: weight(everything$1(found))
5386
5828
  }, alsoConfig && configured ? file : void 0, paint), (line) => Console.log(line));
5387
5829
  if (holds.length > 0) {
5388
5830
  yield* Effect.forEach(held(holds, paint), (line) => Console.log(line));
@@ -5409,7 +5851,7 @@ const uninstall = Command.make("uninstall", {
5409
5851
  * Running from source leaves the constant undeclared rather than undefined, so
5410
5852
  * the check has to be `typeof` and the fallback is what a test reads.
5411
5853
  */
5412
- const version = "0.5.1";
5854
+ const version = "0.7.0";
5413
5855
  /** Where the project lives, printed beside the version in the header. */
5414
5856
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5415
5857
  const subcommands = [
@@ -5422,6 +5864,7 @@ const subcommands = [
5422
5864
  rerun,
5423
5865
  resolve,
5424
5866
  merge,
5867
+ forgetCommand,
5425
5868
  sweepCommand,
5426
5869
  status,
5427
5870
  stampCommand,