dw-mc 0.6.0 → 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",
@@ -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
  *
@@ -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
@@ -1966,13 +2019,13 @@ const Actor = Schema.NullOr(Schema.Struct({
1966
2019
  login: Schema.String,
1967
2020
  __typename: Schema.String
1968
2021
  }));
1969
- const Said = Schema.Struct({
2022
+ const Said$1 = Schema.Struct({
1970
2023
  author: Actor,
1971
2024
  body: Schema.String,
1972
2025
  createdAt: Schema.DateTimeUtcFromString
1973
2026
  });
1974
2027
  const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
1975
- comments: Schema.Struct({ nodes: Schema.Array(Said) }),
2028
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) }),
1976
2029
  reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
1977
2030
  author: Actor,
1978
2031
  body: Schema.String,
@@ -1983,7 +2036,7 @@ const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({
1983
2036
  isOutdated: Schema.Boolean,
1984
2037
  path: Schema.NullOr(Schema.String),
1985
2038
  line: Schema.NullOr(Schema.Int),
1986
- comments: Schema.Struct({ nodes: Schema.Array(Said) })
2039
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) })
1987
2040
  })) })
1988
2041
  }) }) }) }));
1989
2042
  const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
@@ -2155,12 +2208,15 @@ const Facts = Schema.Struct({
2155
2208
  newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2156
2209
  myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2157
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),
2158
2213
  /** The head a review run has already covered, or null where none has. */
2159
2214
  reviewRunHead: Schema.NullOr(Schema.String),
2160
2215
  /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
2161
2216
  blockingFindings: Schema.Int
2162
2217
  });
2163
- Schema.Literals([
2218
+ /** The one place a tracked PR sits at a time, named for what it waits on. */
2219
+ const Bucket = Schema.Literals([
2164
2220
  "needs-me",
2165
2221
  "needs-review-run",
2166
2222
  "waiting-on-others",
@@ -2193,6 +2249,16 @@ const unanswered = "a comment I have not answered";
2193
2249
  */
2194
2250
  const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2195
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
+ /**
2196
2262
  * The first of the rules that makes a PR mine to move, or null when none
2197
2263
  * does. The order is the order I would fix them in: a conflict makes every
2198
2264
  * other signal on the PR stale, and a red build is worth more than a comment.
@@ -2203,7 +2269,7 @@ const needsMe = (facts) => {
2203
2269
  if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2204
2270
  if (facts.reviewDecision === "changes-requested") return "changes requested";
2205
2271
  if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2206
- if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2272
+ if (isAfter(facts.newestHumanCommentAt, answeredAt(facts))) return unanswered;
2207
2273
  return null;
2208
2274
  };
2209
2275
  /**
@@ -2725,6 +2791,20 @@ const marker = {
2725
2791
  "waiting-on-others": "○",
2726
2792
  ready: "◆"
2727
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}`;
2728
2808
  /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2729
2809
  const tint = (paint, bucket) => ({
2730
2810
  "needs-me": paint.red,
@@ -2737,6 +2817,8 @@ const rule = " │ ";
2737
2817
  /**
2738
2818
  * One row: which pull request, what it is, and what it waits on.
2739
2819
  *
2820
+ * In front of it all is the gutter, which says what moved since I last looked.
2821
+ *
2740
2822
  * A stamp is a mark beside the pull request rather than a column of its own, so
2741
2823
  * a table where nothing is stamped is exactly the table it was before: the
2742
2824
  * stamp is a thing I look for, not a thing I read every row of.
@@ -2754,25 +2836,51 @@ const rule = " │ ";
2754
2836
  * straight, so its rows say it in more than one place and open the pull request
2755
2837
  * besides.
2756
2838
  */
2757
- const cells = (placed, stamped, room, paint, lead) => {
2839
+ const cells = (placed, stamped, since, room, paint, lead) => {
2758
2840
  const { facts } = placed;
2759
2841
  const { bucket } = placed.placement;
2760
2842
  const say = tint(paint, bucket);
2761
- const reference = `${facts.repo}#${facts.number}`;
2762
2843
  const named = lead === "named";
2763
- 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];
2764
2846
  return named ? [
2765
- say(`${marker[bucket]} ${heading[bucket]}`),
2847
+ `${front} ${say(`${marker[bucket]} ${heading[bucket]}`)}`,
2766
2848
  pr,
2767
2849
  truncate(facts.title, room),
2768
2850
  placed.placement.reason
2769
2851
  ] : [
2770
- `${say(marker[bucket])} ${pr}`,
2852
+ `${front} ${say(marker[bucket])} ${pr}`,
2771
2853
  paint.dim(truncate(facts.title, room)),
2772
2854
  say(placed.placement.reason)
2773
2855
  ];
2774
2856
  };
2775
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
2776
2884
  //#region src/domain/comments.ts
2777
2885
  /**
2778
2886
  * One thread's share of a strand, cut to what is worth reading.
@@ -2813,9 +2921,24 @@ const shown = (threads, options) => {
2813
2921
  bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2814
2922
  };
2815
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)));
2816
2938
  //#endregion
2817
2939
  //#region src/cli/comments.ts
2818
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"));
2819
2942
  /** Where a thread hangs: a line of the diff, or the pull request itself. */
2820
2943
  const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2821
2944
  /**
@@ -2861,45 +2984,75 @@ const nothing$1 = (facts, all) => {
2861
2984
  const placement = place(facts);
2862
2985
  const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2863
2986
  return placement.bucket === "needs-me" && placement.reason === "a comment I have not answered" ? [
2864
- "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment or commit.",
2865
- `${pr} sits in ${heading[placement.bucket]} all the same, and a reply or a push is what settles it.`,
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.`,
2866
2989
  rest
2867
- ] : [`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];
2868
2991
  };
2869
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
+ /**
2870
3016
  * The conversation on one tracked pull request, and nothing else.
2871
3017
  *
2872
3018
  * What it shows by default is what the bucket rule measures: the comments newer
2873
- * than the later of my last comment and my last commit, which are the ones that
2874
- * put the pull request in Needs me. Reading it answers the question the table
2875
- * asked.
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.
2876
3022
  *
2877
3023
  * The cutoff is read off the last sweep rather than worked out again here, so
2878
3024
  * the command shows exactly what `dw-mc status` counted rather than a second
2879
3025
  * opinion about it.
2880
3026
  *
2881
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2882
- * (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.
2883
3031
  */
2884
3032
  const comments = Command.make("comments", {
2885
3033
  pr: prArgument,
2886
- all: allFlag$1
2887
- }, Effect.fn("comments")(function* ({ all, pr }) {
3034
+ all: allFlag$1,
3035
+ ack: ackFlag
3036
+ }, Effect.fn("comments")(function* ({ ack, all, pr }) {
2888
3037
  const { number, repo } = yield* forPr(pr);
2889
3038
  const facts = yield* swept(repo, number);
2890
3039
  const paint = yield* Paint;
2891
- const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2892
- 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),
2893
3043
  all
2894
3044
  });
2895
- if (view.people.length === 0 && view.bots.length === 0) {
2896
- yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2897
- 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));
2898
3050
  }
2899
- yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
2900
- yield* Console.log("");
2901
- yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
2902
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Print the conversation on one pull request, and what is waiting on me in it"));
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"));
2903
3056
  //#endregion
2904
3057
  //#region src/cli/findings.ts
2905
3058
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
@@ -3488,6 +3641,83 @@ const fix = Command.make("fix", {
3488
3641
  yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3489
3642
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3490
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
3491
3721
  //#region src/cli/init.ts
3492
3722
  const effortFlag$1 = Flag.Literals("effort", [
3493
3723
  "low",
@@ -3924,6 +4154,10 @@ const decide$3 = (situation) => {
3924
4154
  *
3925
4155
  * Typing the command is the confirmation, so it takes no flag. The picker,
3926
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.
3927
4161
  */
3928
4162
  const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3929
4163
  const { number, repo, settings } = yield* forPr(pr);
@@ -3944,6 +4178,7 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
3944
4178
  yield* mergePr(repo, number);
3945
4179
  yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3946
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.`)));
3947
4182
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3948
4183
  //#endregion
3949
4184
  //#region src/domain/coverage.ts
@@ -4325,6 +4560,7 @@ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, sett
4325
4560
  newestHumanCommentAt,
4326
4561
  myLastCommentAt: newest(writtenBy(comments, me)),
4327
4562
  myLastCommitAt,
4563
+ acknowledgedAt: yield* acknowledgedAt(found.repo, found.number),
4328
4564
  ...reviewed
4329
4565
  };
4330
4566
  yield* store.set(key, facts);
@@ -4600,6 +4836,113 @@ const recordRerun = Effect.fn("rerun.recordRerun")(function* (repo, number, head
4600
4836
  yield* (yield* storeFor("reruns", Rerun)).set(prKey(repo, number), { head });
4601
4837
  });
4602
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
4603
4946
  //#region src/cli/pick.ts
4604
4947
  /** A title cut this short says nothing, so a row that tight loses the column instead. */
4605
4948
  const shortest = 12;
@@ -4625,9 +4968,10 @@ const screenRoom = (screen, paint) => screen === 0 ? Number.POSITIVE_INFINITY :
4625
4968
  * The cells come from there rather than being built again here, so the list I
4626
4969
  * pick from and the table I read are the same rows with the bucket moved onto
4627
4970
  * each of them. A prompt has no headings to group under, so the bucket is named
4628
- * 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.
4629
4973
  */
4630
- 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");
4631
4975
  /**
4632
4976
  * Every tracked PR as something to pick, aligned down the whole list.
4633
4977
  *
@@ -4638,13 +4982,13 @@ const cellsOf = ({ placed, stamped }, room, paint) => cells(placed, stamped, roo
4638
4982
  * tells me less than the room it took. A screen too narrow for all four columns
4639
4983
  * loses the title's column rather than the reason's words.
4640
4984
  */
4641
- const choicesOf = (standings, screen, paint) => {
4642
- 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));
4643
4987
  const widest = (index) => Math.max(...measured.map((row) => visible(row[index] ?? "")));
4644
4988
  const room = screenRoom(screen, paint) - (widest(0) + widest(1) + widest(3)) - 9;
4645
4989
  const told = room >= shortest;
4646
4990
  const rows = table(standings.map((it) => {
4647
- const row = cellsOf(it, told ? room : 0, paint);
4991
+ const row = cellsOf(it, sinceOf(it.placed), told ? room : 0, paint);
4648
4992
  return told ? row : [
4649
4993
  row[0] ?? "",
4650
4994
  row[1] ?? "",
@@ -4701,7 +5045,10 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4701
5045
  yield* Console.log("No open pull requests.");
4702
5046
  return;
4703
5047
  }
4704
- 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);
4705
5052
  if (Option.isNone(chosen)) return;
4706
5053
  const facts = chosen.value.placed.facts;
4707
5054
  const offer = yield* pick(`What do I do with ${where(facts)}?`, actionChoices(actionsFor(chosen.value)));
@@ -5368,6 +5715,13 @@ const stampCommand = Command.make("stamp", {
5368
5715
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print my stamp on one pull request, or withdraw it by hand"));
5369
5716
  //#endregion
5370
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
+ };
5371
5725
  /**
5372
5726
  * Every tracked PR under the bucket it sits in, in the order I act on them.
5373
5727
  *
@@ -5375,9 +5729,12 @@ const stampCommand = Command.make("stamp", {
5375
5729
  * the whole table rather than restarting under each heading, and they are ruled
5376
5730
  * apart: three columns of prose run into one another without a rule, and the
5377
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.
5378
5735
  */
5379
- const lines = (grouped, stamped, paint) => {
5380
- 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);
5381
5738
  let taken = 0;
5382
5739
  return grouped.flatMap((it, index) => {
5383
5740
  const mine = rows.slice(taken, taken + it.placed.length);
@@ -5385,7 +5742,8 @@ const lines = (grouped, stamped, paint) => {
5385
5742
  return [
5386
5743
  ...index === 0 ? [] : [""],
5387
5744
  heading[it.bucket],
5388
- ...mine.map((row) => ` ${row}`)
5745
+ ...it.placed.flatMap((placed) => movement(placed, sinceOf(placed))),
5746
+ ...mine
5389
5747
  ];
5390
5748
  });
5391
5749
  };
@@ -5405,10 +5763,12 @@ const status = Command.make("status", {
5405
5763
  }
5406
5764
  const grouped = group(report.facts);
5407
5765
  if (grouped.length === 0) yield* Console.log("No open pull requests.");
5408
- 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);
5409
5768
  yield* printLeftOut(report);
5410
5769
  yield* printTroubles(report.troubles);
5411
- }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Show which bucket every tracked pull request of the repository I stand in, or of every one, sits in, and which ones I have stamped"));
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"));
5412
5772
  //#endregion
5413
5773
  //#region src/cli/uninstall.ts
5414
5774
  const configFlag = Flag.Boolean("config").pipe(Flag.withDefault(false), Flag.withDescription("Take the configuration file too, and not only the state"));
@@ -5491,7 +5851,7 @@ const uninstall = Command.make("uninstall", {
5491
5851
  * Running from source leaves the constant undeclared rather than undefined, so
5492
5852
  * the check has to be `typeof` and the fallback is what a test reads.
5493
5853
  */
5494
- const version = "0.6.0";
5854
+ const version = "0.7.0";
5495
5855
  /** Where the project lives, printed beside the version in the header. */
5496
5856
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5497
5857
  const subcommands = [
@@ -5504,6 +5864,7 @@ const subcommands = [
5504
5864
  rerun,
5505
5865
  resolve,
5506
5866
  merge,
5867
+ forgetCommand,
5507
5868
  sweepCommand,
5508
5869
  status,
5509
5870
  stampCommand,