dw-mc 0.6.0 → 0.8.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",
@@ -1503,1094 +1554,1151 @@ const truncate = (text, width) => {
1503
1554
  /** `n` of something, pluralised the one way English usually is. */
1504
1555
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1505
1556
  //#endregion
1506
- //#region src/domain/cleanup.ts
1507
- /** The checkouts that stand for a session, named by the session they stand for. */
1508
- const standing = (inventory) => inventory.cuttings.flatMap((cutting) => {
1509
- const session = sessionOf(cutting.cut);
1510
- return session === void 0 ? [] : [{
1511
- ...cutting,
1512
- session
1513
- }];
1514
- });
1515
- /** The checkouts a review run cut, which no run that ended still needs. */
1516
- const orphaned = (inventory) => inventory.cuttings.filter((cutting) => cutting.cut === "worktrees");
1517
- 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(", ");
1557
+ //#region src/domain/findings.ts
1558
+ /** Whether a review run found anything at all. */
1559
+ const Verdict = Schema.Literals(["clean", "findings"]);
1519
1560
  /**
1520
- * What a cleanup would take, weighed.
1561
+ * Every severity word a review may answer with.
1521
1562
  *
1522
- * A clone whose repository has a session standing on it stays, and that is not
1523
- * politeness: a standing worktree keeps its history inside the clone, so a
1524
- * clone removed from under one leaves a directory of files with nothing behind
1525
- * them. The worktrees of that session stay with it; the review run's own go
1526
- * either way, because they belong to a run that has ended.
1563
+ * The first three are ours, and the only ones a run is asked for. The rest are
1564
+ * the persona a run with no slash command carries, which grades in its own
1565
+ * words: a turn that comes back in them is worth reading rather than throwing
1566
+ * away.
1527
1567
  */
1528
- const plan = (inventory) => {
1529
- const sessions = standing(inventory);
1530
- const worktrees = orphaned(inventory);
1531
- const held = /* @__PURE__ */ new Map();
1532
- for (const session of sessions) held.set(session.repo, [...held.get(session.repo) ?? [], session]);
1533
- const clones = inventory.clones.filter((clone) => !held.has(clone.repo));
1534
- return {
1535
- clones,
1536
- worktrees,
1537
- kept: inventory.clones.flatMap((clone) => {
1538
- const sessionsHere = held.get(clone.repo);
1539
- return sessionsHere === void 0 ? [] : [{
1540
- clone,
1541
- because: reason(sessionsHere)
1542
- }];
1543
- }),
1544
- size: sum([...clones, ...worktrees].map((it) => it.size))
1545
- };
1546
- };
1547
- /** What the whole state directory weighs: the clones, the checkouts and the records. */
1548
- const everything$1 = (inventory) => sum([
1549
- ...inventory.clones.map((it) => it.size),
1550
- ...inventory.cuttings.map((it) => it.size),
1551
- inventory.records.size
1568
+ const Spelling = Schema.Literals([
1569
+ "error",
1570
+ "warning",
1571
+ "info",
1572
+ "Critical",
1573
+ "Required",
1574
+ "Optional",
1575
+ "Nit",
1576
+ "FYI"
1552
1577
  ]);
1553
- /** Whether a plan has anything to do at all. */
1554
- const empty = (it) => it.clones.length === 0 && it.worktrees.length === 0;
1555
- /** A size as a line says it: three digits at most, and the unit the terminal reads. */
1556
- const weight = (size) => ByteSize.format(size, {
1557
- system: "decimal",
1558
- precision: 1
1578
+ /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
1579
+ const severityOf = {
1580
+ error: "error",
1581
+ warning: "warning",
1582
+ info: "info",
1583
+ Critical: "error",
1584
+ Required: "error",
1585
+ Optional: "warning",
1586
+ Nit: "info",
1587
+ FYI: "info"
1588
+ };
1589
+ const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
1590
+ decode: (word) => severityOf[word],
1591
+ encode: (severity) => severity
1592
+ })));
1593
+ /** The fields both spellings of a finding share. Only the severity differs. */
1594
+ const shared = {
1595
+ file: Schema.String,
1596
+ line: Schema.Int,
1597
+ summary: Schema.String
1598
+ };
1599
+ /** One problem a review run reports, at a file and line. */
1600
+ const Finding = Schema.Struct({
1601
+ ...shared,
1602
+ severity: Severity
1559
1603
  });
1560
- //#endregion
1561
- //#region src/cli/cleanup.ts
1562
- const yesFlag = Flag.Boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Do it without asking, for a machine that has no terminal to ask at"));
1563
- /** A path said as the state directory's own, which is the heading it sits under. */
1564
- const inside = (path, state, directory) => path.relative(state, directory);
1565
1604
  /**
1566
- * The two blocks a cleanup writes: what it takes and what it leaves.
1605
+ * What a review run found: the shape the tool keeps, and the one a fix session
1606
+ * is later handed.
1607
+ */
1608
+ const Findings = Schema.Struct({
1609
+ verdict: Verdict,
1610
+ findings: Schema.Array(Finding)
1611
+ });
1612
+ /**
1613
+ * The same findings as a runner may spell them, which is what the second turn's
1614
+ * output is read with.
1567
1615
  *
1568
- * The weight is on every row because the whole question is whether this is
1569
- * worth doing, and the reason is on every row because a clone and a worktree
1570
- * are taken back for different reasons and both read as "a directory of mine"
1571
- * on the screen.
1616
+ * A word nothing maps fails here, and a failed read is a failure of the run:
1617
+ * findings the tool cannot weigh are not findings it can act on.
1572
1618
  */
1573
- const lines$3 = (it, state, path, paint) => {
1574
- const taking = table([...it.worktrees.map((worktree) => [
1575
- paint.dim(inside(path, state, worktree.directory)),
1576
- weight(worktree.size),
1577
- "a review worktree a run left behind"
1578
- ]), ...it.clones.map((clone) => [
1579
- paint.dim(inside(path, state, clone.directory)),
1580
- weight(clone.size),
1581
- "a bare clone, cloned again on the next run"
1582
- ])]);
1583
- const staying = table(it.kept.map((kept) => [
1584
- paint.dim(inside(path, state, kept.clone.directory)),
1585
- weight(kept.clone.size),
1586
- kept.because
1587
- ]));
1588
- return [
1589
- "Takes back",
1590
- ...taking.map((line) => ` ${line}`),
1591
- "",
1592
- ...staying.length === 0 ? [] : [
1593
- "Stays",
1594
- ...staying.map((line) => ` ${line}`),
1595
- ""
1596
- ]
1597
- ];
1598
- };
1619
+ const Reported = Schema.Struct({
1620
+ verdict: Verdict,
1621
+ findings: Schema.Array(Schema.Struct({
1622
+ ...shared,
1623
+ severity: Weighed
1624
+ }))
1625
+ });
1599
1626
  /**
1600
- * Takes back the disk the tool spent on itself, and nothing that is mine.
1627
+ * The schema every runner must satisfy, as the JSON Schema a runner is handed.
1601
1628
  *
1602
- * What it removes is what the tool builds again by itself: the bare clones and
1603
- * the worktrees a review run cut. What it never removes is what I decided - the
1604
- * configuration file - and what I worked in - the worktree of a fix or resolve
1605
- * session, which stands on a branch of the tool's own and holds what I
1606
- * committed there. Forgetting a pull request's records is a different question
1607
- * with a different answer (#58), and it is not asked here.
1629
+ * It is derived from the schema the findings are kept under rather than written
1630
+ * out beside it, so a runner is asked for exactly the shape that is persisted.
1631
+ * `Reported` is wider on purpose and only on the severity: what a runner is
1632
+ * asked for is our three words, and a persona's five are read where they arrive
1633
+ * anyway rather than being asked for.
1634
+ */
1635
+ const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
1636
+ /**
1637
+ * The findings as the Markdown a report is written in.
1608
1638
  *
1609
- * A clone with a session standing on it stays with the session: a standing
1610
- * worktree keeps its history inside the clone, so a clone taken from under one
1611
- * would leave a directory of files with nothing behind them. The clones that
1612
- * stay are pruned instead, because a worktree directory removed under `git`
1613
- * leaves the clone's record of it behind and the next session cut at that path
1614
- * is refused as already registered.
1639
+ * It is what a schema-held run's report says: with a schema in force a run
1640
+ * answers in findings and not in prose, so the report kept beside it is written
1641
+ * from the findings themselves rather than left empty.
1615
1642
  */
1616
- const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1617
- const path = yield* Path.Path;
1618
- const paint = yield* Paint;
1619
- const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1620
- const it = plan(found);
1621
- if (empty(it)) {
1622
- yield* Console.log(`Nothing to take back in ${found.directory}.`);
1623
- return;
1624
- }
1625
- yield* Effect.forEach(lines$3(it, found.directory, path, paint), (line) => Console.log(line));
1626
- if (!yes && !(yield* confirm(`Take back ${weight(it.size)}?`))) {
1627
- yield* Console.log("Nothing was removed.");
1628
- return;
1629
- }
1630
- yield* Effect.forEach([...it.worktrees, ...it.clones], (taken) => Effect.andThen(discard(taken.directory), tidy(taken.directory, found.directory)));
1631
- yield* Effect.forEach(it.kept, (kept) => prune(kept.clone.directory));
1632
- yield* Console.log(`Took back ${weight(it.size)}.`);
1633
- })).pipe(Command.withDescription("Take back the disk the tool spent on clones and review worktrees"));
1634
- //#endregion
1635
- //#region src/adapters/gh.ts
1636
- /** `gh` is on the machine but would not run. */
1637
- var GhUnavailable = class extends Schema.TaggedError()("GhUnavailable", { detail: Schema.String }) {
1638
- get message() {
1639
- return `gh could not be run: ${this.detail}\nInstall it from https://cli.github.com, then run 'gh auth login'.`;
1640
- }
1641
- };
1642
- /** `gh` runs but is not logged in, so every read of GitHub would fail. */
1643
- var GhUnauthenticated = class extends Schema.TaggedError()("GhUnauthenticated", { detail: Schema.String }) {
1644
- get message() {
1645
- return `gh is not authenticated. Run 'gh auth login'.\n${this.detail}`;
1646
- }
1647
- };
1648
- /** The working directory is not inside a repository `gh` can name. */
1649
- var NoRepository = class extends Schema.TaggedError()("NoRepository", { detail: Schema.String }) {
1650
- get message() {
1651
- return `This directory is not a GitHub repository dw-mc can register.\n${this.detail}`;
1652
- }
1653
- };
1654
- /** `gh` answered, in a shape this version of dw-mc does not know. */
1655
- var GhUnreadable = class extends Schema.TaggedError()("GhUnreadable", {
1656
- command: Schema.String,
1657
- reason: Schema.String
1658
- }) {
1659
- get message() {
1660
- return `gh ${this.command} answered with something dw-mc cannot read: ${this.reason}`;
1661
- }
1643
+ const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
1644
+ /** Where each severity sits against the others, so the bar can be compared with it. */
1645
+ const rank = {
1646
+ info: 0,
1647
+ warning: 1,
1648
+ error: 2
1662
1649
  };
1663
- /** What a `gh` that would not even start comes to. */
1664
- const unavailable = (error) => new GhUnavailable({ detail: error.reason._tag === "NotFound" ? "it is not installed" : error.message });
1665
1650
  /**
1666
- * Stops unless `gh` is installed and logged in.
1651
+ * The findings that withhold the stamp: everything at `blocksOn` or above it.
1667
1652
  *
1668
- * Every read of GitHub goes through `gh` as me, so a missing or logged-out `gh`
1669
- * is worth saying once, up front, rather than as an empty table later.
1653
+ * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
1654
+ * warnings I do not want to merge past is configured rather than coded. An
1655
+ * error blocks wherever the bar is, because nothing weighs more than one.
1670
1656
  */
1671
- const requireAuth = capture("gh", ["auth", "status"]).pipe(Effect.asVoid, Effect.catchTags({
1672
- PlatformError: (error) => Effect.fail(unavailable(error)),
1673
- CommandFailed: (error) => Effect.fail(new GhUnauthenticated({ detail: error.stderr }))
1674
- }), Effect.withSpan("gh.requireAuth"));
1675
- const RepoView = Schema.fromJsonString(Schema.Struct({ nameWithOwner: Schema.String }));
1676
- /** The `owner/repo` of the repository the working directory is in. */
1677
- const currentRepo = Effect.gen(function* () {
1678
- const json = yield* capture("gh", [
1679
- "repo",
1680
- "view",
1681
- "--json",
1682
- "nameWithOwner"
1683
- ]).pipe(Effect.catchTags({
1684
- PlatformError: (error) => Effect.fail(unavailable(error)),
1685
- CommandFailed: (error) => Effect.fail(new NoRepository({ detail: error.stderr }))
1686
- }));
1687
- return (yield* Schema.decodeEffect(RepoView)(json).pipe(Effect.mapError((error) => new GhUnreadable({
1688
- command: "repo view",
1689
- reason: error.message
1690
- })))).nameWithOwner;
1691
- }).pipe(Effect.withSpan("gh.currentRepo"));
1692
- /** A call to GitHub that `gh` itself refused, whether it was reading or writing. */
1693
- var GhReadFailed = class extends Schema.TaggedError()("GhReadFailed", {
1694
- command: Schema.String,
1695
- detail: Schema.String
1696
- }) {
1697
- get message() {
1698
- return `gh ${this.command} failed: ${this.detail}`;
1699
- }
1700
- };
1701
- /** One `gh` read, decoded, with every way it can go wrong in our words. */
1702
- const readJson = (label, command, args, schema) => capture(command, args).pipe(Effect.catchTags({
1703
- PlatformError: (error) => Effect.fail(unavailable(error)),
1704
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
1705
- command: label,
1706
- detail: error.stderr
1707
- }))
1708
- }), Effect.flatMap((json) => Schema.decodeEffect(schema)(json).pipe(Effect.mapError((error) => new GhUnreadable({
1709
- command: label,
1710
- reason: error.message
1711
- })))), Effect.withSpan(`gh.${label}`));
1712
- /** The login `gh` is authenticated as: the "me" every read is scoped to. */
1713
- const viewer = readJson("api user", "gh", ["api", "user"], Schema.fromJsonString(Schema.Struct({ login: Schema.String }))).pipe(Effect.map((user) => user.login));
1714
- const SearchResults = Schema.fromJsonString(Schema.Array(Schema.Struct({
1715
- number: Schema.Int,
1716
- repository: Schema.Struct({ nameWithOwner: Schema.String })
1717
- })));
1657
+ const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
1658
+ //#endregion
1659
+ //#region src/domain/review.ts
1718
1660
  /**
1719
- * The open pull requests I authored in `repo`.
1661
+ * What a review run came to, which is what its second turn reported.
1720
1662
  *
1721
- * One search per repository rather than one for all of them: a repository `gh`
1722
- * cannot read then costs me that repository's rows and not the whole table.
1663
+ * A failure is recorded as one and is never a clean verdict: a turn that exited
1664
+ * badly, ran out of patience or answered in a shape that does not validate has
1665
+ * found nothing, which is not the same as having found nothing wrong.
1723
1666
  */
1724
- const searchPrs = Effect.fnUntraced(function* (repo) {
1725
- return (yield* readJson("search prs", "gh", [
1726
- "search",
1727
- "prs",
1728
- "--author=@me",
1729
- "--state=open",
1730
- "--repo",
1731
- repo,
1732
- "--limit",
1733
- "100",
1734
- "--json",
1735
- "number,repository"
1736
- ], SearchResults)).map((it) => ({
1737
- repo: it.repository.nameWithOwner,
1738
- number: it.number
1739
- }));
1740
- });
1667
+ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
1668
+ verdict: Verdict,
1669
+ findings: Schema.Array(Finding)
1670
+ }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
1741
1671
  /**
1742
- * One entry of a PR's status check rollup.
1672
+ * One review run against a tracked PR at a specific head commit.
1743
1673
  *
1744
- * A rollup mixes two shapes: a `CheckRun` reports a `status` and a `conclusion`,
1745
- * a `StatusContext` an overall `state`. Every field is optional because which
1746
- * ones arrive depends on which shape it is.
1674
+ * It is a schema because a review run outlives the command that started it: the
1675
+ * state directory is where the next sweep learns that this head has been
1676
+ * reviewed, and where a fix session finds what there is to fix.
1747
1677
  */
1748
- const CheckEntry = Schema.Struct({
1749
- name: Schema.optionalKey(Schema.String),
1750
- context: Schema.optionalKey(Schema.String),
1751
- status: Schema.optionalKey(Schema.String),
1752
- conclusion: Schema.optionalKey(Schema.String),
1753
- state: Schema.optionalKey(Schema.String),
1754
- /** The workflow the check runs in. A commit status belongs to no workflow. */
1755
- workflowName: Schema.optionalKey(Schema.String),
1756
- /** Where the check reports, which is the only place its job id appears. */
1757
- detailsUrl: Schema.optionalKey(Schema.String)
1758
- });
1759
- const PrView = Schema.fromJsonString(Schema.Struct({
1678
+ const ReviewRun = Schema.Struct({
1679
+ repo: Schema.String,
1760
1680
  number: Schema.Int,
1761
- title: Schema.String,
1762
- url: Schema.String,
1763
- isDraft: Schema.Boolean,
1764
- headRefOid: Schema.String,
1765
- headRefName: Schema.String,
1766
- baseRefName: Schema.String,
1767
- /** Who opened it, which is what says whether its branch is mine to push to. */
1768
- author: Schema.NullOr(Schema.Struct({ login: Schema.String })),
1769
- /** Whether the head branch lives in a fork rather than in this repository. */
1770
- isCrossRepository: Schema.Boolean,
1771
- mergeable: Schema.String,
1772
- reviewDecision: Schema.String,
1773
- statusCheckRollup: Schema.NullOr(Schema.Array(CheckEntry))
1774
- }));
1681
+ /** The head the run covers. A run never vouches for code it did not see. */
1682
+ head: Schema.String,
1683
+ /**
1684
+ * The slash command line the run opened on, or null where it opened on the
1685
+ * tool's own prompt. A report found months later says what it was asked, and a
1686
+ * record an earlier version wrote carries no such field and is forgotten.
1687
+ */
1688
+ command: Schema.NullOr(Schema.String),
1689
+ effort: Schema.NullOr(Effort),
1690
+ /**
1691
+ * The agent session the run happened in, or null where it never reached one.
1692
+ *
1693
+ * A run that would not start or exited before it said anything has no session,
1694
+ * and the run is still recorded: a failure is recorded as what it is.
1695
+ */
1696
+ sessionId: Schema.NullOr(Schema.String),
1697
+ ranAt: Schema.DateTimeUtcFromString,
1698
+ outcome: Outcome
1699
+ });
1700
+ /** A head as it is read out loud: the seven characters git itself abbreviates to. */
1701
+ const short = (head) => head.slice(0, 7);
1775
1702
  /**
1776
- * The fields one `gh pr view` asks for, named so a test can spell the vector it
1777
- * expects without copying the list and watching it drift.
1703
+ * Where a run is kept: one key per head, so a run and the code it read cannot
1704
+ * drift apart, and a re-review replaces the run before it.
1778
1705
  */
1779
- const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
1706
+ const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
1707
+ /** Where the run's report is kept: beside the run, as the Markdown it is. */
1708
+ const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
1780
1709
  /**
1781
- * Everything about one pull request that arrives without paging through it:
1782
- * its head, what GitHub thinks of merging it, and where CI got to.
1710
+ * Which head a pull request was last reviewed at: an index beside `runKey` and
1711
+ * `reportKey` rather than a thing the glossary names.
1712
+ *
1713
+ * A run is kept under the head it read, which answers the question a sweep asks
1714
+ * of one head. The re-run rule and `dw-mc findings` ask the other one - which
1715
+ * head the last run was at - and this is where they read it, so neither has to
1716
+ * ask GitHub what is current before it can look anything up.
1783
1717
  */
1784
- const prView = Effect.fnUntraced(function* (repo, number) {
1785
- return yield* readJson("pr view", "gh", [
1786
- "pr",
1787
- "view",
1788
- String(number),
1789
- "--repo",
1790
- repo,
1791
- "--json",
1792
- viewFields
1793
- ], PrView);
1794
- });
1795
- const OpenPrs = Schema.fromJsonString(Schema.Array(Schema.Struct({
1796
- number: Schema.Int,
1797
- headRefName: Schema.String,
1798
- baseRefName: Schema.String
1799
- })));
1718
+ const LastReviewed = Schema.Struct({ head: Schema.String });
1719
+ /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
1720
+ const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
1800
1721
  /**
1801
- * Every open pull request on a repository, by branch.
1722
+ * The run at one head, or none where nothing has reviewed it.
1802
1723
  *
1803
- * Everyone's and not only mine: a stack is recognised from branches built on
1804
- * branches, and a pull request of mine can sit on one somebody else opened.
1724
+ * A head is where the question is asked - the stamp, the bucket and `dw-mc
1725
+ * findings` all ask about one commit - and one read off the disk answers it
1726
+ * without an index to keep in step.
1805
1727
  *
1806
- * The page is deep because a pull request this misses is one that looks like it
1807
- * is in no stack, and a stack the tool cannot see is one it could drive.
1728
+ * Forgetting a run costs one review.
1808
1729
  */
1809
- const openPrs = Effect.fnUntraced(function* (repo) {
1810
- return (yield* readJson("pr list", "gh", [
1811
- "pr",
1812
- "list",
1813
- "--repo",
1814
- repo,
1815
- "--state",
1816
- "open",
1817
- "--limit",
1818
- "500",
1819
- "--json",
1820
- "number,headRefName,baseRefName"
1821
- ], OpenPrs)).map((it) => ({
1822
- number: it.number,
1823
- head: it.headRefName,
1824
- base: it.baseRefName
1825
- }));
1730
+ const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
1731
+ const runs = yield* storeFor("runs", ReviewRun);
1732
+ return yield* remembered(runs.get(runKey(repo, number, head)));
1733
+ });
1734
+ /** The last review run on a pull request, or none where it has had none. */
1735
+ const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
1736
+ const heads = yield* storeFor("runs", LastReviewed);
1737
+ const at = yield* remembered(heads.get(latestKey(repo, number)));
1738
+ return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
1826
1739
  });
1827
- const Comments = Schema.fromJsonString(Schema.Array(Schema.Struct({
1828
- created_at: Schema.DateTimeUtcFromString,
1829
- user: Schema.NullOr(Schema.Struct({
1830
- login: Schema.String,
1831
- type: Schema.String
1832
- }))
1833
- })));
1834
- const comments$1 = (label, path) => readJson(label, "gh", ["api", path], Comments).pipe(Effect.map((all) => all.flatMap((comment) => comment.user === null ? [] : [{
1835
- login: comment.user.login,
1836
- bot: comment.user.type === "Bot",
1837
- at: comment.created_at
1838
- }])));
1839
1740
  /**
1840
- * Every comment on a pull request: the ones on the conversation and the ones
1841
- * left on the diff.
1741
+ * What a run reported, or null where it reported nothing at all.
1842
1742
  *
1843
- * REST is what says whether an author is a person or an app - `gh pr view`
1844
- * reports a bot's login with no sign that it is one - and the bucket rules turn
1845
- * on exactly that. Verified by running both: the endpoints ignore `direction`,
1846
- * so a page is asked for at its maximum and the newest comment is picked out of
1847
- * it rather than asked for first.
1743
+ * A failure is not a clean verdict: a run that could not report has found
1744
+ * nothing, which is not the same as having found nothing wrong. Everything that
1745
+ * reads a run's findings reads them through here, so the distinction is drawn
1746
+ * once rather than at every caller that might forget it.
1848
1747
  */
1849
- const prComments = Effect.fnUntraced(function* (repo, number) {
1850
- const page = "per_page=100";
1851
- const [conversation, onDiff] = yield* Effect.all([comments$1("api issue comments", `repos/${repo}/issues/${number}/comments?${page}`), comments$1("api review comments", `repos/${repo}/pulls/${number}/comments?${page}`)], { concurrency: 2 });
1852
- return [...conversation, ...onDiff];
1853
- });
1854
- const Reviews = Schema.fromJsonString(Schema.Array(Schema.Struct({
1855
- submitted_at: Schema.DateTimeUtcFromString,
1856
- body: Schema.String,
1857
- user: Schema.NullOr(Schema.Struct({
1858
- login: Schema.String,
1859
- type: Schema.String
1860
- }))
1861
- })));
1748
+ const reportedBy = (run) => run.outcome._tag === "reported" ? {
1749
+ verdict: run.outcome.verdict,
1750
+ findings: run.outcome.findings
1751
+ } : null;
1862
1752
  /**
1863
- * The reviews on a pull request that said something, as comments.
1753
+ * Why a run reported nothing, or null where it reported.
1864
1754
  *
1865
- * A review carries a body of its own, which is where a reviewer writes the
1866
- * sentence that is not attached to any line. An empty body is a verdict and
1867
- * nothing more, and the verdict arrives with the PR as `reviewDecision`.
1755
+ * The sibling of `reportedBy`, and here for the same reason: the two halves of
1756
+ * an outcome are read through one place each rather than re-narrowed at every
1757
+ * caller.
1868
1758
  */
1869
- const prReviews = Effect.fnUntraced(function* (repo, number) {
1870
- return (yield* readJson("api reviews", "gh", ["api", `repos/${repo}/pulls/${number}/reviews?per_page=100`], Reviews)).flatMap((review) => review.user === null || review.body.trim() === "" ? [] : [{
1871
- login: review.user.login,
1872
- bot: review.user.type === "Bot",
1873
- at: review.submitted_at
1874
- }]);
1875
- });
1876
- const Compare = Schema.fromJsonString(Schema.Struct({ files: Schema.optionalKey(Schema.Array(Schema.Struct({ filename: Schema.String }))) }));
1759
+ const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
1877
1760
  /**
1878
- * The repository paths that changed between two commits.
1879
- *
1880
- * GitHub compares them rather than git, because the commit a run was recorded
1881
- * against is not one the tool's own clone is promised to still have: a force
1882
- * push moves the pull request's ref and the old commit goes with it, where
1883
- * GitHub keeps both sides of the comparison. A comparison of a commit with
1884
- * itself reports no files at all, and so does one of two commits with nothing
1885
- * between them, which is why the key is optional.
1761
+ * Whether the files changed since the last run are worth paying for another.
1886
1762
  *
1887
- * `base...head` measures from where the two commits last agreed, so two heads
1888
- * on one branch report what was pushed between them, and a branch rebased since
1889
- * reports its whole diff. The second is the right answer for a caller deciding
1890
- * whether the code has moved: after a rebase it has, all of it.
1763
+ * The question is deliberately about what changed rather than how much: one
1764
+ * line outside the `docs_only` globs is code nobody has reviewed, and a
1765
+ * thousand lines inside them are still prose.
1891
1766
  */
1892
- const comparedFiles = Effect.fnUntraced(function* (repo, base, head) {
1893
- return ((yield* readJson("api compare", "gh", ["api", `repos/${repo}/compare/${base}...${head}`], Compare)).files ?? []).map((file) => file.filename);
1894
- });
1895
- const Commits = Schema.fromJsonString(Schema.Struct({ commits: Schema.Array(Schema.Struct({
1896
- committedDate: Schema.DateTimeUtcFromString,
1897
- authors: Schema.Array(Schema.Struct({ login: Schema.NullOr(Schema.String) }))
1898
- })) }));
1767
+ const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
1899
1768
  /**
1900
- * The commits on a pull request.
1769
+ * The re-run rule: the head this run is skipped against, or null where it runs.
1901
1770
  *
1902
- * This is the expensive read of the three: `gh` returns every commit with its
1903
- * whole message, so a sweep only asks for it when something about the PR has
1904
- * actually moved.
1771
+ * A review costs real money and minutes of my attention, and a typo fix is not
1772
+ * worth either. Four things are never skipped, because the rule is here to save
1773
+ * me a review and not to stand between me and one I asked for: a pull request
1774
+ * with no run behind it, a run that reported nothing, a comparison GitHub would
1775
+ * not answer, and anything that changed outside the globs. A head that has
1776
+ * already had a run changed nothing at all, which is the one case that needs no
1777
+ * comparison to decide.
1905
1778
  */
1906
- const prCommits = Effect.fnUntraced(function* (repo, number) {
1907
- return (yield* readJson("pr view commits", "gh", [
1908
- "pr",
1909
- "view",
1910
- String(number),
1911
- "--repo",
1912
- repo,
1913
- "--json",
1914
- "commits"
1915
- ], Commits)).commits.map((commit) => ({
1916
- logins: commit.authors.flatMap((author) => author.login === null ? [] : [author.login]),
1917
- at: commit.committedDate
1918
- }));
1919
- });
1779
+ const skippedSince = (asked, docsOnly) => {
1780
+ if (asked.last === null || reportedBy(asked.last) === null) return null;
1781
+ const changed = asked.last.head === asked.head ? [] : asked.changed;
1782
+ return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
1783
+ };
1784
+ /** What a run was opened on, as the report says it. */
1785
+ const askedOf$2 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
1920
1786
  /**
1921
- * What `gh` says about merging, in our words. Anything else is `unknown`:
1922
- * GitHub answers that too, for a PR whose mergeability it is still computing.
1787
+ * The report as it is written down: what it is of, then what the run said.
1923
1788
  *
1924
- * `Match.withReturnType` comes first in the pipeline or the return type is not
1925
- * enforced: a handler's literal widens to `string` on its own.
1789
+ * The heading is the whole point of writing it rather than storing the prose
1790
+ * alone - a file found months later says which pull request, which commit and
1791
+ * what the run was asked, without anything else having to be open.
1926
1792
  */
1927
- const mergeabilityOf = (raw) => Match.value(raw).pipe(Match.withReturnType(), Match.when("MERGEABLE", () => "mergeable"), Match.when("CONFLICTING", () => "conflicting"), Match.orElse(() => "unknown"));
1793
+ const reportDocument = (run, title, prose) => [
1794
+ `# ${run.repo}#${run.number} ${title}`,
1795
+ "",
1796
+ `- head: ${run.head}`,
1797
+ `- run: ${askedOf$2(run)}`,
1798
+ `- ran: ${DateTime.formatIso(run.ranAt)}`,
1799
+ "",
1800
+ prose.trim(),
1801
+ ""
1802
+ ].join("\n");
1928
1803
  /**
1929
- * What `gh` says the reviewers decided, in our words. A repository that requires
1930
- * no reviewer reports an empty string, which is `none` rather than pending.
1804
+ * Whether `head` has the review it needs.
1805
+ *
1806
+ * A run that reported nothing does not count, which is the same rule
1807
+ * `reportedBy` draws everywhere else: a failure has found nothing, not found
1808
+ * nothing wrong.
1931
1809
  */
1932
- const reviewDecisionOf = (raw) => Match.value(raw).pipe(Match.withReturnType(), Match.when("APPROVED", () => "approved"), Match.when("CHANGES_REQUESTED", () => "changes-requested"), Match.when("REVIEW_REQUIRED", () => "review-required"), Match.orElse(() => "none"));
1810
+ const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
1811
+ /** The findings at one head that withhold the stamp. */
1812
+ const blockingIn = (run, blocksOn) => {
1813
+ const found = run === null ? null : reportedBy(run);
1814
+ return found === null ? [] : blocking(found.findings, blocksOn);
1815
+ };
1933
1816
  /**
1934
- * Squash-merges a pull request and deletes the branch it stood on.
1817
+ * What the review runs on `head` say about it, for the stamp to rest on.
1935
1818
  *
1936
- * The one write the tool makes that no reflog of mine undoes, and the whole of
1937
- * it: a squash, because that is how the repository lands a pull request and the
1938
- * squash subject is its title, and the branch, because squashing kills it
1939
- * anyway. No `--auto`, which would hand GitHub a merge to make at a head
1940
- * nothing here has read (ADR 0008).
1819
+ * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
1820
+ * recorded against one head, and a head with no run of its own has not been
1821
+ * reviewed however many sweeps have seen the pull request. A run that could not
1822
+ * report findings does not count either: its verdict is what takes a pull
1823
+ * request out of Needs review run, and it reached none.
1941
1824
  *
1942
- * Whether this pull request is one to merge is decided before we get here, and
1943
- * `gh` still has the last word: a branch protection this machine cannot see
1944
- * comes back as a failure and is printed as one.
1825
+ * It is one function because the two callers are a sweep and `dw-mc merge`, and
1826
+ * the second exists to land what the first only describes: two spellings of
1827
+ * this would be two answers to whether a head has been reviewed.
1945
1828
  */
1946
- const mergePr = Effect.fnUntraced(function* (repo, number) {
1947
- yield* capture("gh", [
1948
- "pr",
1949
- "merge",
1950
- String(number),
1951
- "--repo",
1952
- repo,
1953
- "--squash",
1954
- "--delete-branch"
1955
- ]).pipe(Effect.catchTags({
1956
- PlatformError: (error) => Effect.fail(unavailable(error)),
1957
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
1958
- command: "pr merge",
1959
- detail: error.stderr
1960
- }))
1961
- }));
1829
+ const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
1830
+ const run = Option.getOrNull(yield* runAt(repo, number, head));
1831
+ return {
1832
+ reviewRunHead: reviewedBy(run) ? head : null,
1833
+ blockingFindings: blockingIn(run, blocksOn).length
1834
+ };
1962
1835
  });
1963
1836
  //#endregion
1964
- //#region src/adapters/conversation.ts
1965
- const Actor = Schema.NullOr(Schema.Struct({
1966
- login: Schema.String,
1967
- __typename: Schema.String
1968
- }));
1969
- const Said = Schema.Struct({
1970
- author: Actor,
1971
- body: Schema.String,
1972
- createdAt: Schema.DateTimeUtcFromString
1973
- });
1974
- const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
1975
- comments: Schema.Struct({ nodes: Schema.Array(Said) }),
1976
- reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
1977
- author: Actor,
1978
- body: Schema.String,
1979
- submittedAt: Schema.NullOr(Schema.DateTimeUtcFromString)
1980
- })) }),
1981
- reviewThreads: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
1982
- isResolved: Schema.Boolean,
1983
- isOutdated: Schema.Boolean,
1984
- path: Schema.NullOr(Schema.String),
1985
- line: Schema.NullOr(Schema.Int),
1986
- comments: Schema.Struct({ nodes: Schema.Array(Said) })
1987
- })) })
1988
- }) }) }) }));
1989
- const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
1990
- login: said.author.login,
1991
- bot: said.author.__typename === "Bot",
1992
- at,
1993
- body: said.body.trim()
1994
- }];
1995
- const byTime = (self, other) => DateTime.Order(self.at, other.at);
1837
+ //#region src/cli/block.ts
1996
1838
  /**
1997
- * A pull request's whole conversation: the comments on it, the bodies of the
1998
- * reviews, and every thread on the diff with whether it is settled.
1999
- *
2000
- * GraphQL rather than the two REST endpoints a sweep reads, because resolution
2001
- * is not in REST at all: a review comment's payload carries `body`, `path`,
2002
- * `line`, `diff_hunk` and `side`, and nothing saying whether somebody closed
2003
- * the thread it belongs to. A thread that was settled a week ago is not
2004
- * something to answer, so the state that says so has to arrive with it.
1839
+ * How a command that reports rather than tabulates writes its page: in blocks,
1840
+ * each a heading with its body indented under it, and a blank line before the
1841
+ * next one (ADR 0007).
2005
1842
  *
2006
- * The pull request's own comments and the reviews' bodies come back as one
2007
- * strand under no path, in the order they were written: they are one
2008
- * conversation as it happened, and which endpoint each line came from is an
2009
- * accident of GitHub's model rather than anything to read.
1843
+ * What a command says is its own. The shape is here, so a report reads the
1844
+ * same whichever command wrote it, and so do the three lines more than one of
1845
+ * them writes: the opener, the files a rebase stopped on, and a command to
1846
+ * retype.
2010
1847
  *
2011
- * `__typename` is what says a bot is a bot, the way `user.type` does in REST.
1848
+ * A block is its lines, and a line of prose on its own is a block with no
1849
+ * body. Nothing here colours a heading or a line of prose.
2012
1850
  */
2013
- const prConversation = Effect.fnUntraced(function* (repo, number) {
2014
- const [owner = repo, name = repo] = repo.split("/");
2015
- const pr = (yield* readJson("api graphql", "gh", [
2016
- "api",
2017
- "graphql",
2018
- "-f",
2019
- `query=query($owner:String!,$name:String!,$number:Int!){
2020
- repository(owner:$owner,name:$name){
2021
- pullRequest(number:$number){
2022
- comments(last:100){nodes{author{login __typename} body createdAt}}
2023
- reviews(last:100){nodes{author{login __typename} body submittedAt}}
2024
- reviewThreads(last:100){nodes{
2025
- isResolved isOutdated path line
2026
- comments(first:100){nodes{author{login __typename} body createdAt}}
2027
- }}
2028
- }
2029
- }
2030
- }`,
2031
- "-F",
2032
- `owner=${owner}`,
2033
- "-F",
2034
- `name=${name}`,
2035
- "-F",
2036
- `number=${number}`
2037
- ], Conversation)).data.repository.pullRequest;
2038
- const conversation = [...pr.comments.nodes.flatMap((it) => remark(it, it.createdAt)), ...pr.reviews.nodes.flatMap((it) => remark(it, it.submittedAt))].toSorted(byTime);
2039
- const threads = pr.reviewThreads.nodes.map((it) => ({
2040
- path: it.path,
2041
- line: it.line,
2042
- resolved: it.isResolved,
2043
- outdated: it.isOutdated,
2044
- comments: it.comments.nodes.flatMap((comment) => remark(comment, comment.createdAt)).toSorted(byTime)
2045
- }));
2046
- return [...conversation.length === 0 ? [] : [{
2047
- path: null,
2048
- line: null,
2049
- resolved: false,
2050
- outdated: false,
2051
- comments: conversation
2052
- }], ...threads];
2053
- });
2054
- //#endregion
2055
- //#region src/cli/exit.ts
2056
1851
  /**
2057
- * The failures a command owes me a sentence for rather than a stack.
1852
+ * A line of a block's body, under its heading. A blank line stays blank.
2058
1853
  *
2059
- * Every one of them is a machine or a file that needs fixing, and the message
2060
- * says what to fix. Anything not named here is a fault of the tool's own, and a
2061
- * stack is what I want to see for those.
1854
+ * On its own it is for a body written a line at a time, under a heading
1855
+ * already on the screen, while the command is still finding out what it says.
2062
1856
  */
2063
- const userFacing = [
2064
- "ConfigMalformed",
2065
- "GhUnavailable",
2066
- "GhReadFailed",
2067
- "GhUnreadable"
2068
- ];
2069
- /** The same, for a command that also runs `git` against the tool's own clone. */
2070
- const userFacingAndGit = [...userFacing, "GitFailed"];
1857
+ const indent = (line) => line === "" ? "" : ` ${line}`;
1858
+ /** One block: the heading, then the body indented under it. */
1859
+ const block = (heading, body) => [heading, ...body.map(indent)];
1860
+ const nonEmpty = (lines) => lines.length > 0;
2071
1861
  /**
2072
- * The same, for a command that cuts a standing worktree and opens an agent
2073
- * session in it.
2074
- *
2075
- * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2076
- * because they do the same thing to different findings: a worktree that holds
2077
- * work of mine and an agent that would not run are the session's failures, not
2078
- * either command's.
1862
+ * The blocks of one page, a blank line apart. A block that came to nothing
1863
+ * leaves no gap where it would have been.
2079
1864
  */
2080
- const userFacingAndSession = [
2081
- ...userFacing,
2082
- "GitFailed",
2083
- "WorktreeHeld",
2084
- "AgentFailed"
2085
- ];
2086
- /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2087
- const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2088
- //#endregion
2089
- //#region src/domain/moment.ts
2090
- const isLater = Order.isGreaterThan(DateTime.Order);
2091
- /** Whether `self` happened after `other`, counting never as before anything. */
2092
- const isAfter = (self, other) => Predicate.isNotNull(self) && (other === null || isLater(self, other));
2093
- /** The later of the two. */
2094
- const later = (self, other) => isAfter(self, other) ? self : other;
2095
- /** Whether the two are the same moment, counting never as the same as never. */
2096
- const isSame = (self, other) => self === null || other === null ? self === other : DateTime.Equivalence(self, other);
2097
- /** The latest of many, or never when there are none. */
2098
- const newest = (moments) => moments.reduce(later, null);
2099
- //#endregion
2100
- //#region src/terms/pr.ts
1865
+ const separated = (blocks) => blocks.filter(nonEmpty).flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
2101
1866
  /**
2102
- * What GitHub says about a pull request, in this tool's words.
2103
- *
2104
- * The three of them are here because both sides need the same one: `gh` and the
2105
- * checks adapter answer in these words, and the bucket rules decide on them. A
2106
- * union restated on each side is a case that goes unreachable the day the other
2107
- * side gains a member.
1867
+ * Blocks that follow what is already on the screen - a line written before a
1868
+ * session, a prompt or a heartbeat took the terminal - so each of them opens
1869
+ * on its blank line, the first as well.
2108
1870
  */
2109
- /** How far GitHub has got towards letting a tracked PR merge. */
2110
- const Mergeability = Schema.Literals([
2111
- "mergeable",
2112
- "conflicting",
2113
- "unknown"
2114
- ]);
2115
- /** What the reviewers have decided, or that nobody is required to. */
2116
- const ReviewDecision = Schema.Literals([
2117
- "approved",
2118
- "changes-requested",
2119
- "review-required",
2120
- "none"
2121
- ]);
2122
- /** What CI says about the current head. */
2123
- const ChecksState = Schema.Literals([
2124
- "green",
2125
- "red",
2126
- "pending",
2127
- "none"
2128
- ]);
1871
+ const following = (blocks) => blocks.filter(nonEmpty).flatMap((lines) => ["", ...lines]);
1872
+ /** A page's lines, printed one to a line. */
1873
+ const print = (lines) => Effect.forEach(lines, (line) => Console.log(line), { discard: true });
1874
+ /**
1875
+ * The line a report on one pull request opens on: which one, at which head,
1876
+ * and what happened to it. The head is context, so it is dim.
1877
+ */
1878
+ const opener = (paint, repo, number, head, what) => `${repo}#${number} ${typeof head === "string" ? paint.dim(short(head)) : `${paint.dim(short(head.before))} → ${paint.dim(short(head.after))}`} ${what}`;
1879
+ /** The files a rebase stopped on, under their count, or nothing where none is known. */
1880
+ const stoppedOn = (paint, paths) => paths.length === 0 ? [] : block(`It stopped on ${count(paths.length, "file")}:`, paths.map(paint.dim));
1881
+ /**
1882
+ * Commands I am meant to retype, one to a line, as a block of their own so
1883
+ * they stand clear of the prose around them. Cyan is the one colour no state
1884
+ * uses, so it says this and nothing else.
1885
+ */
1886
+ const retype = (paint, ...commands) => commands.map((command) => indent(paint.cyan(command)));
2129
1887
  //#endregion
2130
- //#region src/domain/bucket.ts
1888
+ //#region src/domain/cleanup.ts
1889
+ /** The checkouts that stand for a session, named by the session they stand for. */
1890
+ const standing$1 = (inventory) => inventory.cuttings.flatMap((cutting) => {
1891
+ const session = sessionOf(cutting.cut);
1892
+ return session === void 0 ? [] : [{
1893
+ ...cutting,
1894
+ session
1895
+ }];
1896
+ });
1897
+ /** The checkouts a review run cut, which no run that ended still needs. */
1898
+ const orphaned = (inventory) => inventory.cuttings.filter((cutting) => cutting.cut === "worktrees");
1899
+ const sum = (sizes) => ByteSize.bytes(sizes.reduce((total, size) => total + ByteSize.toBigInt(size), BigInt(0)));
1900
+ /** What the glossary calls the session a standing checkout was cut for. */
1901
+ const sessionName = (session) => session === "fix" ? "fix" : "resolve";
1902
+ const reason = (sessions) => sessions.map((it) => `a ${sessionName(it.session)} session stands on ${it.repo}#${it.number}`).join(", ");
2131
1903
  /**
2132
- * Everything the bucket rules are allowed to know about a tracked PR.
1904
+ * What a cleanup would take, weighed.
2133
1905
  *
2134
- * It is a schema because a sweep writes it to the state directory and reads it
2135
- * back on the next one: the same facts that decide a bucket are what a quiet PR
2136
- * is recognised by.
1906
+ * A clone whose repository has a session standing on it stays, and that is not
1907
+ * politeness: a standing worktree keeps its history inside the clone, so a
1908
+ * clone removed from under one leaves a directory of files with nothing behind
1909
+ * them. The worktrees of that session stay with it; the review run's own go
1910
+ * either way, because they belong to a run that has ended.
2137
1911
  */
2138
- const Facts = Schema.Struct({
2139
- repo: Schema.String,
2140
- number: Schema.Int,
2141
- title: Schema.String,
2142
- url: Schema.String,
2143
- /** Shown, never acted on unless I ask. */
2144
- draft: Schema.Boolean,
2145
- /** The head commit every other fact here is about. */
2146
- head: Schema.String,
2147
- mergeable: Mergeability,
2148
- reviewDecision: ReviewDecision,
2149
- checks: ChecksState,
2150
- /** Why the flaky classifier excuses this red CI, or null where it does not. */
2151
- ciFlaky: Schema.NullOr(Schema.String),
2152
- /** The head a rebase onto the base conflicted at, or null where none has. */
2153
- rebaseConflictAt: Schema.NullOr(Schema.String),
2154
- /** The newest comment from a person who is not me, bots excluded. */
2155
- newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2156
- myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2157
- myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2158
- /** The head a review run has already covered, or null where none has. */
2159
- reviewRunHead: Schema.NullOr(Schema.String),
2160
- /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
2161
- blockingFindings: Schema.Int
2162
- });
2163
- Schema.Literals([
2164
- "needs-me",
2165
- "needs-review-run",
2166
- "waiting-on-others",
2167
- "ready"
1912
+ const plan = (inventory) => {
1913
+ const sessions = standing$1(inventory);
1914
+ const worktrees = orphaned(inventory);
1915
+ const held = /* @__PURE__ */ new Map();
1916
+ for (const session of sessions) held.set(session.repo, [...held.get(session.repo) ?? [], session]);
1917
+ const clones = inventory.clones.filter((clone) => !held.has(clone.repo));
1918
+ return {
1919
+ clones,
1920
+ worktrees,
1921
+ kept: inventory.clones.flatMap((clone) => {
1922
+ const sessionsHere = held.get(clone.repo);
1923
+ return sessionsHere === void 0 ? [] : [{
1924
+ clone,
1925
+ because: reason(sessionsHere)
1926
+ }];
1927
+ }),
1928
+ size: sum([...clones, ...worktrees].map((it) => it.size))
1929
+ };
1930
+ };
1931
+ /** What the whole state directory weighs: the clones, the checkouts and the records. */
1932
+ const everything$1 = (inventory) => sum([
1933
+ ...inventory.clones.map((it) => it.size),
1934
+ ...inventory.cuttings.map((it) => it.size),
1935
+ inventory.records.size
2168
1936
  ]);
2169
- /** The buckets in the order I act on them: the top of the table is my next move. */
2170
- const order = [
2171
- "needs-me",
2172
- "needs-review-run",
2173
- "waiting-on-others",
2174
- "ready"
2175
- ];
1937
+ /** Whether a plan has anything to do at all. */
1938
+ const empty = (it) => it.clones.length === 0 && it.worktrees.length === 0;
1939
+ /** A size as a line says it: three digits at most, and the unit the terminal reads. */
1940
+ const weight = (size) => ByteSize.format(size, {
1941
+ system: "decimal",
1942
+ precision: 1
1943
+ });
1944
+ //#endregion
1945
+ //#region src/cli/cleanup.ts
1946
+ const yesFlag = Flag.Boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Do it without asking, for a machine that has no terminal to ask at"));
1947
+ /** A path said as the state directory's own, which is the heading it sits under. */
1948
+ const inside = (path, state, directory) => path.relative(state, directory);
2176
1949
  /**
2177
- * Why a PR is mine to move when somebody has said something I have not
2178
- * answered.
1950
+ * The two blocks a cleanup writes: what it takes and what it leaves.
2179
1951
  *
2180
- * It is named because it is read twice: here, where it puts the PR in Needs me,
2181
- * and by `dw-mc comments`, which says what settles that one branch of the
2182
- * bucket. A sentence matched from the other side of the tool is a rule that
2183
- * breaks on a reword.
1952
+ * The weight is on every row because the whole question is whether this is
1953
+ * worth doing, and the reason is on every row because a clone and a worktree
1954
+ * are taken back for different reasons and both read as "a directory of mine"
1955
+ * on the screen.
2184
1956
  */
2185
- const unanswered = "a comment I have not answered";
1957
+ const lines$2 = (it, state, path, paint) => {
1958
+ const taking = table([...it.worktrees.map((worktree) => [
1959
+ paint.dim(inside(path, state, worktree.directory)),
1960
+ weight(worktree.size),
1961
+ "a review worktree a run left behind"
1962
+ ]), ...it.clones.map((clone) => [
1963
+ paint.dim(inside(path, state, clone.directory)),
1964
+ weight(clone.size),
1965
+ "a bare clone, cloned again on the next run"
1966
+ ])]);
1967
+ const staying = table(it.kept.map((kept) => [
1968
+ paint.dim(inside(path, state, kept.clone.directory)),
1969
+ weight(kept.clone.size),
1970
+ kept.because
1971
+ ]));
1972
+ return separated([block("Takes back", taking), staying.length === 0 ? [] : block("Stays", staying)]);
1973
+ };
2186
1974
  /**
2187
- * Why a PR is mine to move when a review run found something that withholds
2188
- * the stamp.
1975
+ * Takes back the disk the tool spent on itself, and nothing that is mine.
2189
1976
  *
2190
- * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2191
- * sentence about this same number, and two spellings of it would be two
2192
- * answers to what a blocking finding is worth.
1977
+ * What it removes is what the tool builds again by itself: the bare clones and
1978
+ * the worktrees a review run cut. What it never removes is what I decided - the
1979
+ * configuration file - and what I worked in - the worktree of a fix or resolve
1980
+ * session, which stands on a branch of the tool's own and holds what I
1981
+ * committed there. Forgetting a pull request's records is a different question
1982
+ * with a different answer (`dw-mc forget`), and it is not asked here.
1983
+ *
1984
+ * A clone with a session standing on it stays with the session: a standing
1985
+ * worktree keeps its history inside the clone, so a clone taken from under one
1986
+ * would leave a directory of files with nothing behind them. The clones that
1987
+ * stay are pruned instead, because a worktree directory removed under `git`
1988
+ * leaves the clone's record of it behind and the next session cut at that path
1989
+ * is refused as already registered.
2193
1990
  */
2194
- const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
1991
+ const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1992
+ const path = yield* Path.Path;
1993
+ const paint = yield* Paint;
1994
+ const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1995
+ const it = plan(found);
1996
+ if (empty(it)) {
1997
+ yield* Console.log(`Nothing to take back in ${found.directory}.`);
1998
+ return;
1999
+ }
2000
+ yield* print([...lines$2(it, found.directory, path, paint), ""]);
2001
+ if (!yes && !(yield* confirm(`Take back ${weight(it.size)}?`))) {
2002
+ yield* Console.log("Nothing was removed.");
2003
+ return;
2004
+ }
2005
+ yield* Effect.forEach([...it.worktrees, ...it.clones], (taken) => Effect.andThen(discard(taken.directory), tidy(taken.directory, found.directory)));
2006
+ yield* Effect.forEach(it.kept, (kept) => prune(kept.clone.directory));
2007
+ yield* Console.log(`Took back ${weight(it.size)}.`);
2008
+ })).pipe(Command.withDescription("Take back the disk the tool spent on clones and review worktrees"));
2009
+ //#endregion
2010
+ //#region src/adapters/gh.ts
2011
+ /** `gh` is on the machine but would not run. */
2012
+ var GhUnavailable = class extends Schema.TaggedError()("GhUnavailable", { detail: Schema.String }) {
2013
+ get message() {
2014
+ return `gh could not be run: ${this.detail}\nInstall it from https://cli.github.com, then run 'gh auth login'.`;
2015
+ }
2016
+ };
2017
+ /** `gh` runs but is not logged in, so every read of GitHub would fail. */
2018
+ var GhUnauthenticated = class extends Schema.TaggedError()("GhUnauthenticated", { detail: Schema.String }) {
2019
+ get message() {
2020
+ return `gh is not authenticated. Run 'gh auth login'.\n${this.detail}`;
2021
+ }
2022
+ };
2023
+ /** The working directory is not inside a repository `gh` can name. */
2024
+ var NoRepository = class extends Schema.TaggedError()("NoRepository", { detail: Schema.String }) {
2025
+ get message() {
2026
+ return `This directory is not a GitHub repository dw-mc can register.\n${this.detail}`;
2027
+ }
2028
+ };
2029
+ /** `gh` answered, in a shape this version of dw-mc does not know. */
2030
+ var GhUnreadable = class extends Schema.TaggedError()("GhUnreadable", {
2031
+ command: Schema.String,
2032
+ reason: Schema.String
2033
+ }) {
2034
+ get message() {
2035
+ return `gh ${this.command} answered with something dw-mc cannot read: ${this.reason}`;
2036
+ }
2037
+ };
2038
+ /** What a `gh` that would not even start comes to. */
2039
+ const unavailable = (error) => new GhUnavailable({ detail: error.reason._tag === "NotFound" ? "it is not installed" : error.message });
2195
2040
  /**
2196
- * The first of the rules that makes a PR mine to move, or null when none
2197
- * does. The order is the order I would fix them in: a conflict makes every
2198
- * other signal on the PR stale, and a red build is worth more than a comment.
2041
+ * Stops unless `gh` is installed and logged in.
2042
+ *
2043
+ * Every read of GitHub goes through `gh` as me, so a missing or logged-out `gh`
2044
+ * is worth saying once, up front, rather than as an empty table later.
2199
2045
  */
2200
- const needsMe = (facts) => {
2201
- if (facts.mergeable === "conflicting") return "merge conflict";
2202
- if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2203
- if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2204
- if (facts.reviewDecision === "changes-requested") return "changes requested";
2205
- if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2206
- if (isAfter(facts.newestHumanCommentAt, later(facts.myLastCommentAt, facts.myLastCommitAt))) return unanswered;
2207
- return null;
2046
+ const requireAuth = capture("gh", ["auth", "status"]).pipe(Effect.asVoid, Effect.catchTags({
2047
+ PlatformError: (error) => Effect.fail(unavailable(error)),
2048
+ CommandFailed: (error) => Effect.fail(new GhUnauthenticated({ detail: error.stderr }))
2049
+ }), Effect.withSpan("gh.requireAuth"));
2050
+ const RepoView = Schema.fromJsonString(Schema.Struct({ nameWithOwner: Schema.String }));
2051
+ /** The `owner/repo` of the repository the working directory is in. */
2052
+ const currentRepo = Effect.gen(function* () {
2053
+ const json = yield* capture("gh", [
2054
+ "repo",
2055
+ "view",
2056
+ "--json",
2057
+ "nameWithOwner"
2058
+ ]).pipe(Effect.catchTags({
2059
+ PlatformError: (error) => Effect.fail(unavailable(error)),
2060
+ CommandFailed: (error) => Effect.fail(new NoRepository({ detail: error.stderr }))
2061
+ }));
2062
+ return (yield* Schema.decodeEffect(RepoView)(json).pipe(Effect.mapError((error) => new GhUnreadable({
2063
+ command: "repo view",
2064
+ reason: error.message
2065
+ })))).nameWithOwner;
2066
+ }).pipe(Effect.withSpan("gh.currentRepo"));
2067
+ /** A call to GitHub that `gh` itself refused, whether it was reading or writing. */
2068
+ var GhReadFailed = class extends Schema.TaggedError()("GhReadFailed", {
2069
+ command: Schema.String,
2070
+ detail: Schema.String
2071
+ }) {
2072
+ get message() {
2073
+ return `gh ${this.command} failed: ${this.detail}`;
2074
+ }
2208
2075
  };
2076
+ /** One `gh` read, decoded, with every way it can go wrong in our words. */
2077
+ const readJson = (label, command, args, schema) => capture(command, args).pipe(Effect.catchTags({
2078
+ PlatformError: (error) => Effect.fail(unavailable(error)),
2079
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
2080
+ command: label,
2081
+ detail: error.stderr
2082
+ }))
2083
+ }), Effect.flatMap((json) => Schema.decodeEffect(schema)(json).pipe(Effect.mapError((error) => new GhUnreadable({
2084
+ command: label,
2085
+ reason: error.message
2086
+ })))), Effect.withSpan(`gh.${label}`));
2087
+ /** The login `gh` is authenticated as: the "me" every read is scoped to. */
2088
+ const viewer = readJson("api user", "gh", ["api", "user"], Schema.fromJsonString(Schema.Struct({ login: Schema.String }))).pipe(Effect.map((user) => user.login));
2089
+ const SearchResults = Schema.fromJsonString(Schema.Array(Schema.Struct({
2090
+ number: Schema.Int,
2091
+ repository: Schema.Struct({ nameWithOwner: Schema.String })
2092
+ })));
2209
2093
  /**
2210
- * What is actually true of a PR nothing is waiting on.
2094
+ * The open pull requests I authored in `repo`.
2211
2095
  *
2212
- * Ready is reached by having no reason not to be, so the reason says only what
2213
- * holds: a repository that requires no reviewer produces no approval, and a
2214
- * pull request with no CI at all is not green.
2096
+ * One search per repository rather than one for all of them: a repository `gh`
2097
+ * cannot read then costs me that repository's rows and not the whole table.
2098
+ */
2099
+ const searchPrs = Effect.fnUntraced(function* (repo) {
2100
+ return (yield* readJson("search prs", "gh", [
2101
+ "search",
2102
+ "prs",
2103
+ "--author=@me",
2104
+ "--state=open",
2105
+ "--repo",
2106
+ repo,
2107
+ "--limit",
2108
+ "100",
2109
+ "--json",
2110
+ "number,repository"
2111
+ ], SearchResults)).map((it) => ({
2112
+ repo: it.repository.nameWithOwner,
2113
+ number: it.number
2114
+ }));
2115
+ });
2116
+ /**
2117
+ * One entry of a PR's status check rollup.
2215
2118
  *
2216
- * A red CI the classifier excused is said out loud, because GitHub does not
2217
- * excuse it: the check is still red, and Ready is what `dw-mc merge` reads.
2119
+ * A rollup mixes two shapes: a `CheckRun` reports a `status` and a `conclusion`,
2120
+ * a `StatusContext` an overall `state`. Every field is optional because which
2121
+ * ones arrive depends on which shape it is.
2218
2122
  */
2219
- const readyReason = (facts) => {
2220
- const held = [
2221
- facts.reviewDecision === "approved" ? "approved" : null,
2222
- facts.checks === "green" ? "green" : null,
2223
- facts.mergeable === "mergeable" ? "mergeable" : null
2224
- ].filter((it) => it !== null);
2225
- const standing = held.length === 0 ? "nothing left to wait on" : held.join(", ");
2226
- return facts.checks === "red" && facts.ciFlaky !== null ? `${standing} (red CI called flaky: ${facts.ciFlaky})` : standing;
2227
- };
2123
+ const CheckEntry = Schema.Struct({
2124
+ name: Schema.optionalKey(Schema.String),
2125
+ context: Schema.optionalKey(Schema.String),
2126
+ status: Schema.optionalKey(Schema.String),
2127
+ conclusion: Schema.optionalKey(Schema.String),
2128
+ state: Schema.optionalKey(Schema.String),
2129
+ /** The workflow the check runs in. A commit status belongs to no workflow. */
2130
+ workflowName: Schema.optionalKey(Schema.String),
2131
+ /** Where the check reports, which is the only place its job id appears. */
2132
+ detailsUrl: Schema.optionalKey(Schema.String)
2133
+ });
2134
+ const PrView = Schema.fromJsonString(Schema.Struct({
2135
+ number: Schema.Int,
2136
+ title: Schema.String,
2137
+ url: Schema.String,
2138
+ isDraft: Schema.Boolean,
2139
+ headRefOid: Schema.String,
2140
+ headRefName: Schema.String,
2141
+ baseRefName: Schema.String,
2142
+ /** Who opened it, which is what says whether its branch is mine to push to. */
2143
+ author: Schema.NullOr(Schema.Struct({ login: Schema.String })),
2144
+ /** Whether the head branch lives in a fork rather than in this repository. */
2145
+ isCrossRepository: Schema.Boolean,
2146
+ mergeable: Schema.String,
2147
+ reviewDecision: Schema.String,
2148
+ statusCheckRollup: Schema.NullOr(Schema.Array(CheckEntry))
2149
+ }));
2228
2150
  /**
2229
- * The bucket a tracked PR sits in, and the reason for it.
2151
+ * The fields one `gh pr view` asks for, named so a test can spell the vector it
2152
+ * expects without copying the list and watching it drift.
2153
+ */
2154
+ const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
2155
+ /**
2156
+ * Everything about one pull request that arrives without paging through it:
2157
+ * its head, what GitHub thinks of merging it, and where CI got to.
2158
+ */
2159
+ const prView = Effect.fnUntraced(function* (repo, number) {
2160
+ return yield* readJson("pr view", "gh", [
2161
+ "pr",
2162
+ "view",
2163
+ String(number),
2164
+ "--repo",
2165
+ repo,
2166
+ "--json",
2167
+ viewFields
2168
+ ], PrView);
2169
+ });
2170
+ const OpenPrs = Schema.fromJsonString(Schema.Array(Schema.Struct({
2171
+ number: Schema.Int,
2172
+ headRefName: Schema.String,
2173
+ baseRefName: Schema.String
2174
+ })));
2175
+ /**
2176
+ * Every open pull request on a repository, by branch.
2230
2177
  *
2231
- * This is the single place the bucket rules exist. Every tracked PR lands in
2232
- * exactly one bucket, so the rules are tried in priority order and the first
2233
- * that claims the PR wins: a PR that both needs a review run and has changes
2234
- * requested is mine to move, not the review's.
2178
+ * Everyone's and not only mine: a stack is recognised from branches built on
2179
+ * branches, and a pull request of mine can sit on one somebody else opened.
2235
2180
  *
2236
- * Ready does not insist on an approval, because a repository that requires no
2237
- * reviewer never produces one. What it insists on is that nobody else has been
2238
- * asked and is yet to answer.
2181
+ * The page is deep because a pull request this misses is one that looks like it
2182
+ * is in no stack, and a stack the tool cannot see is one it could drive.
2239
2183
  */
2240
- const place = (facts) => {
2241
- const mine = needsMe(facts);
2242
- if (mine !== null) return {
2243
- bucket: "needs-me",
2244
- reason: mine
2245
- };
2246
- if (facts.reviewRunHead !== facts.head) return {
2247
- bucket: "needs-review-run",
2248
- reason: "no review run on this head"
2249
- };
2250
- if (facts.reviewDecision === "review-required") return {
2251
- bucket: "waiting-on-others",
2252
- reason: "a review from someone else"
2253
- };
2254
- if (facts.checks === "pending") return {
2255
- bucket: "waiting-on-others",
2256
- reason: "CI is still running"
2257
- };
2258
- return {
2259
- bucket: "ready",
2260
- reason: readyReason(facts)
2261
- };
2262
- };
2263
- const group = (facts) => {
2264
- const placed = facts.map((it) => ({
2265
- facts: it,
2266
- placement: place(it)
2267
- })).toSorted((a, b) => a.facts.repo.localeCompare(b.facts.repo) || a.facts.number - b.facts.number);
2268
- return order.map((bucket) => ({
2269
- bucket,
2270
- placed: placed.filter((it) => it.placement.bucket === bucket)
2271
- })).filter((bucket) => bucket.placed.length > 0);
2272
- };
2273
- //#endregion
2274
- //#region src/domain/reference.ts
2275
- /** `owner/name#12`, or `12` on its own. */
2276
- const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
2184
+ const openPrs = Effect.fnUntraced(function* (repo) {
2185
+ return (yield* readJson("pr list", "gh", [
2186
+ "pr",
2187
+ "list",
2188
+ "--repo",
2189
+ repo,
2190
+ "--state",
2191
+ "open",
2192
+ "--limit",
2193
+ "500",
2194
+ "--json",
2195
+ "number,headRefName,baseRefName"
2196
+ ], OpenPrs)).map((it) => ({
2197
+ number: it.number,
2198
+ head: it.headRefName,
2199
+ base: it.baseRefName
2200
+ }));
2201
+ });
2202
+ const Comments = Schema.fromJsonString(Schema.Array(Schema.Struct({
2203
+ created_at: Schema.DateTimeUtcFromString,
2204
+ user: Schema.NullOr(Schema.Struct({
2205
+ login: Schema.String,
2206
+ type: Schema.String
2207
+ }))
2208
+ })));
2209
+ const comments$1 = (label, path) => readJson(label, "gh", ["api", path], Comments).pipe(Effect.map((all) => all.flatMap((comment) => comment.user === null ? [] : [{
2210
+ login: comment.user.login,
2211
+ bot: comment.user.type === "Bot",
2212
+ at: comment.created_at
2213
+ }])));
2214
+ /**
2215
+ * Every comment on a pull request: the ones on the conversation and the ones
2216
+ * left on the diff.
2217
+ *
2218
+ * REST is what says whether an author is a person or an app - `gh pr view`
2219
+ * reports a bot's login with no sign that it is one - and the bucket rules turn
2220
+ * on exactly that. Verified by running both: the endpoints ignore `direction`,
2221
+ * so a page is asked for at its maximum and the newest comment is picked out of
2222
+ * it rather than asked for first.
2223
+ */
2224
+ const prComments = Effect.fnUntraced(function* (repo, number) {
2225
+ const page = "per_page=100";
2226
+ const [conversation, onDiff] = yield* Effect.all([comments$1("api issue comments", `repos/${repo}/issues/${number}/comments?${page}`), comments$1("api review comments", `repos/${repo}/pulls/${number}/comments?${page}`)], { concurrency: 2 });
2227
+ return [...conversation, ...onDiff];
2228
+ });
2229
+ const Reviews = Schema.fromJsonString(Schema.Array(Schema.Struct({
2230
+ submitted_at: Schema.DateTimeUtcFromString,
2231
+ body: Schema.String,
2232
+ user: Schema.NullOr(Schema.Struct({
2233
+ login: Schema.String,
2234
+ type: Schema.String
2235
+ }))
2236
+ })));
2237
+ /**
2238
+ * The reviews on a pull request that said something, as comments.
2239
+ *
2240
+ * A review carries a body of its own, which is where a reviewer writes the
2241
+ * sentence that is not attached to any line. An empty body is a verdict and
2242
+ * nothing more, and the verdict arrives with the PR as `reviewDecision`.
2243
+ */
2244
+ const prReviews = Effect.fnUntraced(function* (repo, number) {
2245
+ return (yield* readJson("api reviews", "gh", ["api", `repos/${repo}/pulls/${number}/reviews?per_page=100`], Reviews)).flatMap((review) => review.user === null || review.body.trim() === "" ? [] : [{
2246
+ login: review.user.login,
2247
+ bot: review.user.type === "Bot",
2248
+ at: review.submitted_at
2249
+ }]);
2250
+ });
2251
+ const Compare = Schema.fromJsonString(Schema.Struct({ files: Schema.optionalKey(Schema.Array(Schema.Struct({ filename: Schema.String }))) }));
2277
2252
  /**
2278
- * A segment of nothing but dots, which no repository is called.
2253
+ * The repository paths that changed between two commits.
2279
2254
  *
2280
- * The repository names a directory under the state directory before it names
2281
- * anything else, so `../x` would be a way out of it.
2255
+ * GitHub compares them rather than git, because the commit a run was recorded
2256
+ * against is not one the tool's own clone is promised to still have: a force
2257
+ * push moves the pull request's ref and the old commit goes with it, where
2258
+ * GitHub keeps both sides of the comparison. A comparison of a commit with
2259
+ * itself reports no files at all, and so does one of two commits with nothing
2260
+ * between them, which is why the key is optional.
2261
+ *
2262
+ * `base...head` measures from where the two commits last agreed, so two heads
2263
+ * on one branch report what was pushed between them, and a branch rebased since
2264
+ * reports its whole diff. The second is the right answer for a caller deciding
2265
+ * whether the code has moved: after a rebase it has, all of it.
2282
2266
  */
2283
- const onlyDots = /^\.+$/;
2267
+ const comparedFiles = Effect.fnUntraced(function* (repo, base, head) {
2268
+ return ((yield* readJson("api compare", "gh", ["api", `repos/${repo}/compare/${base}...${head}`], Compare)).files ?? []).map((file) => file.filename);
2269
+ });
2270
+ const Commits = Schema.fromJsonString(Schema.Struct({ commits: Schema.Array(Schema.Struct({
2271
+ committedDate: Schema.DateTimeUtcFromString,
2272
+ authors: Schema.Array(Schema.Struct({ login: Schema.NullOr(Schema.String) }))
2273
+ })) }));
2284
2274
  /**
2285
- * The pull request a reference names.
2275
+ * The commits on a pull request.
2286
2276
  *
2287
- * A reference that spells its repository out is taken as it is, registered or
2288
- * not: reviewing someone else's pull request is a thing to ask for, and the
2289
- * settings a repository nothing registered gets are the global defaults.
2277
+ * This is the expensive read of the three: `gh` returns every commit with its
2278
+ * whole message, so a sweep only asks for it when something about the PR has
2279
+ * actually moved.
2290
2280
  */
2291
- const resolve$1 = (text, registered) => {
2292
- const found = spelled.exec(text);
2293
- const number = found?.[2];
2294
- if (number === void 0) return {
2295
- _tag: "unreadable",
2296
- text
2297
- };
2298
- const spelledRepo = found?.[1];
2299
- if (spelledRepo !== void 0 && spelledRepo.split("/").some((segment) => onlyDots.test(segment))) return {
2300
- _tag: "unreadable",
2301
- text
2302
- };
2303
- const repo = spelledRepo ?? (registered.length === 1 ? registered[0] : void 0);
2304
- if (repo === void 0) return {
2305
- _tag: "ambiguous",
2306
- repos: registered
2307
- };
2308
- return {
2309
- _tag: "resolved",
2281
+ const prCommits = Effect.fnUntraced(function* (repo, number) {
2282
+ return (yield* readJson("pr view commits", "gh", [
2283
+ "pr",
2284
+ "view",
2285
+ String(number),
2286
+ "--repo",
2310
2287
  repo,
2311
- number: Number(number)
2312
- };
2313
- };
2314
- //#endregion
2315
- //#region src/domain/findings.ts
2316
- /** Whether a review run found anything at all. */
2317
- const Verdict = Schema.Literals(["clean", "findings"]);
2288
+ "--json",
2289
+ "commits"
2290
+ ], Commits)).commits.map((commit) => ({
2291
+ logins: commit.authors.flatMap((author) => author.login === null ? [] : [author.login]),
2292
+ at: commit.committedDate
2293
+ }));
2294
+ });
2318
2295
  /**
2319
- * Every severity word a review may answer with.
2296
+ * What `gh` says about merging, in our words. Anything else is `unknown`:
2297
+ * GitHub answers that too, for a PR whose mergeability it is still computing.
2320
2298
  *
2321
- * The first three are ours, and the only ones a run is asked for. The rest are
2322
- * the persona a run with no slash command carries, which grades in its own
2323
- * words: a turn that comes back in them is worth reading rather than throwing
2324
- * away.
2299
+ * `Match.withReturnType` comes first in the pipeline or the return type is not
2300
+ * enforced: a handler's literal widens to `string` on its own.
2325
2301
  */
2326
- const Spelling = Schema.Literals([
2327
- "error",
2328
- "warning",
2329
- "info",
2330
- "Critical",
2331
- "Required",
2332
- "Optional",
2333
- "Nit",
2334
- "FYI"
2335
- ]);
2336
- /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2337
- const severityOf = {
2338
- error: "error",
2339
- warning: "warning",
2340
- info: "info",
2341
- Critical: "error",
2342
- Required: "error",
2343
- Optional: "warning",
2344
- Nit: "info",
2345
- FYI: "info"
2346
- };
2347
- const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2348
- decode: (word) => severityOf[word],
2349
- encode: (severity) => severity
2350
- })));
2351
- /** The fields both spellings of a finding share. Only the severity differs. */
2352
- const shared = {
2353
- file: Schema.String,
2354
- line: Schema.Int,
2355
- summary: Schema.String
2356
- };
2357
- /** One problem a review run reports, at a file and line. */
2358
- const Finding = Schema.Struct({
2359
- ...shared,
2360
- severity: Severity
2361
- });
2302
+ const mergeabilityOf = (raw) => Match.value(raw).pipe(Match.withReturnType(), Match.when("MERGEABLE", () => "mergeable"), Match.when("CONFLICTING", () => "conflicting"), Match.orElse(() => "unknown"));
2362
2303
  /**
2363
- * What a review run found: the shape the tool keeps, and the one a fix session
2364
- * is later handed.
2304
+ * What `gh` says the reviewers decided, in our words. A repository that requires
2305
+ * no reviewer reports an empty string, which is `none` rather than pending.
2365
2306
  */
2366
- const Findings = Schema.Struct({
2367
- verdict: Verdict,
2368
- findings: Schema.Array(Finding)
2369
- });
2307
+ const reviewDecisionOf = (raw) => Match.value(raw).pipe(Match.withReturnType(), Match.when("APPROVED", () => "approved"), Match.when("CHANGES_REQUESTED", () => "changes-requested"), Match.when("REVIEW_REQUIRED", () => "review-required"), Match.orElse(() => "none"));
2370
2308
  /**
2371
- * The same findings as a runner may spell them, which is what the second turn's
2372
- * output is read with.
2309
+ * Squash-merges a pull request and deletes the branch it stood on.
2373
2310
  *
2374
- * A word nothing maps fails here, and a failed read is a failure of the run:
2375
- * findings the tool cannot weigh are not findings it can act on.
2311
+ * The one write the tool makes that no reflog of mine undoes, and the whole of
2312
+ * it: a squash, because that is how the repository lands a pull request and the
2313
+ * squash subject is its title, and the branch, because squashing kills it
2314
+ * anyway. No `--auto`, which would hand GitHub a merge to make at a head
2315
+ * nothing here has read (ADR 0008).
2316
+ *
2317
+ * Whether this pull request is one to merge is decided before we get here, and
2318
+ * `gh` still has the last word: a branch protection this machine cannot see
2319
+ * comes back as a failure and is printed as one.
2376
2320
  */
2377
- const Reported = Schema.Struct({
2378
- verdict: Verdict,
2379
- findings: Schema.Array(Schema.Struct({
2380
- ...shared,
2381
- severity: Weighed
2382
- }))
2321
+ const mergePr = Effect.fnUntraced(function* (repo, number) {
2322
+ yield* capture("gh", [
2323
+ "pr",
2324
+ "merge",
2325
+ String(number),
2326
+ "--repo",
2327
+ repo,
2328
+ "--squash",
2329
+ "--delete-branch"
2330
+ ]).pipe(Effect.catchTags({
2331
+ PlatformError: (error) => Effect.fail(unavailable(error)),
2332
+ CommandFailed: (error) => Effect.fail(new GhReadFailed({
2333
+ command: "pr merge",
2334
+ detail: error.stderr
2335
+ }))
2336
+ }));
2337
+ });
2338
+ //#endregion
2339
+ //#region src/adapters/conversation.ts
2340
+ const Actor = Schema.NullOr(Schema.Struct({
2341
+ login: Schema.String,
2342
+ __typename: Schema.String
2343
+ }));
2344
+ const Said$1 = Schema.Struct({
2345
+ author: Actor,
2346
+ body: Schema.String,
2347
+ createdAt: Schema.DateTimeUtcFromString
2383
2348
  });
2349
+ const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
2350
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) }),
2351
+ reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
2352
+ author: Actor,
2353
+ body: Schema.String,
2354
+ submittedAt: Schema.NullOr(Schema.DateTimeUtcFromString)
2355
+ })) }),
2356
+ reviewThreads: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
2357
+ isResolved: Schema.Boolean,
2358
+ isOutdated: Schema.Boolean,
2359
+ path: Schema.NullOr(Schema.String),
2360
+ line: Schema.NullOr(Schema.Int),
2361
+ comments: Schema.Struct({ nodes: Schema.Array(Said$1) })
2362
+ })) })
2363
+ }) }) }) }));
2364
+ const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
2365
+ login: said.author.login,
2366
+ bot: said.author.__typename === "Bot",
2367
+ at,
2368
+ body: said.body.trim()
2369
+ }];
2370
+ const byTime = (self, other) => DateTime.Order(self.at, other.at);
2384
2371
  /**
2385
- * The schema every runner must satisfy, as the JSON Schema a runner is handed.
2372
+ * A pull request's whole conversation: the comments on it, the bodies of the
2373
+ * reviews, and every thread on the diff with whether it is settled.
2386
2374
  *
2387
- * It is derived from the schema the findings are kept under rather than written
2388
- * out beside it, so a runner is asked for exactly the shape that is persisted.
2389
- * `Reported` is wider on purpose and only on the severity: what a runner is
2390
- * asked for is our three words, and a persona's five are read where they arrive
2391
- * anyway rather than being asked for.
2375
+ * GraphQL rather than the two REST endpoints a sweep reads, because resolution
2376
+ * is not in REST at all: a review comment's payload carries `body`, `path`,
2377
+ * `line`, `diff_hunk` and `side`, and nothing saying whether somebody closed
2378
+ * the thread it belongs to. A thread that was settled a week ago is not
2379
+ * something to answer, so the state that says so has to arrive with it.
2380
+ *
2381
+ * The pull request's own comments and the reviews' bodies come back as one
2382
+ * strand under no path, in the order they were written: they are one
2383
+ * conversation as it happened, and which endpoint each line came from is an
2384
+ * accident of GitHub's model rather than anything to read.
2385
+ *
2386
+ * `__typename` is what says a bot is a bot, the way `user.type` does in REST.
2392
2387
  */
2393
- const jsonSchema = JSON.stringify(SchemaRepresentation.toJsonSchemaDocument(SchemaRepresentation.toRepresentation(Findings.ast)).schema);
2388
+ const prConversation = Effect.fnUntraced(function* (repo, number) {
2389
+ const [owner = repo, name = repo] = repo.split("/");
2390
+ const pr = (yield* readJson("api graphql", "gh", [
2391
+ "api",
2392
+ "graphql",
2393
+ "-f",
2394
+ `query=query($owner:String!,$name:String!,$number:Int!){
2395
+ repository(owner:$owner,name:$name){
2396
+ pullRequest(number:$number){
2397
+ comments(last:100){nodes{author{login __typename} body createdAt}}
2398
+ reviews(last:100){nodes{author{login __typename} body submittedAt}}
2399
+ reviewThreads(last:100){nodes{
2400
+ isResolved isOutdated path line
2401
+ comments(first:100){nodes{author{login __typename} body createdAt}}
2402
+ }}
2403
+ }
2404
+ }
2405
+ }`,
2406
+ "-F",
2407
+ `owner=${owner}`,
2408
+ "-F",
2409
+ `name=${name}`,
2410
+ "-F",
2411
+ `number=${number}`
2412
+ ], Conversation)).data.repository.pullRequest;
2413
+ const conversation = [...pr.comments.nodes.flatMap((it) => remark(it, it.createdAt)), ...pr.reviews.nodes.flatMap((it) => remark(it, it.submittedAt))].toSorted(byTime);
2414
+ const threads = pr.reviewThreads.nodes.map((it) => ({
2415
+ path: it.path,
2416
+ line: it.line,
2417
+ resolved: it.isResolved,
2418
+ outdated: it.isOutdated,
2419
+ comments: it.comments.nodes.flatMap((comment) => remark(comment, comment.createdAt)).toSorted(byTime)
2420
+ }));
2421
+ return [...conversation.length === 0 ? [] : [{
2422
+ path: null,
2423
+ line: null,
2424
+ resolved: false,
2425
+ outdated: false,
2426
+ comments: conversation
2427
+ }], ...threads];
2428
+ });
2429
+ //#endregion
2430
+ //#region src/cli/exit.ts
2394
2431
  /**
2395
- * The findings as the Markdown a report is written in.
2432
+ * The failures a command owes me a sentence for rather than a stack.
2396
2433
  *
2397
- * It is what a schema-held run's report says: with a schema in force a run
2398
- * answers in findings and not in prose, so the report kept beside it is written
2399
- * from the findings themselves rather than left empty.
2434
+ * Every one of them is a machine or a file that needs fixing, and the message
2435
+ * says what to fix. Anything not named here is a fault of the tool's own, and a
2436
+ * stack is what I want to see for those.
2400
2437
  */
2401
- const asMarkdown = (found) => found.findings.length === 0 ? "Clean: the run found nothing to report." : found.findings.map((finding) => `- \`${finding.file}:${finding.line}\` ${finding.severity}: ${finding.summary}`).join("\n");
2402
- /** Where each severity sits against the others, so the bar can be compared with it. */
2403
- const rank = {
2404
- info: 0,
2405
- warning: 1,
2406
- error: 2
2407
- };
2438
+ const userFacing = [
2439
+ "ConfigMalformed",
2440
+ "GhUnavailable",
2441
+ "GhReadFailed",
2442
+ "GhUnreadable"
2443
+ ];
2444
+ /** The same, for a command that also runs `git` against the tool's own clone. */
2445
+ const userFacingAndGit = [...userFacing, "GitFailed"];
2408
2446
  /**
2409
- * The findings that withhold the stamp: everything at `blocksOn` or above it.
2447
+ * The same, for a command that cuts a standing worktree and opens an agent
2448
+ * session in it.
2410
2449
  *
2411
- * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2412
- * warnings I do not want to merge past is configured rather than coded. An
2413
- * error blocks wherever the bar is, because nothing weighs more than one.
2450
+ * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2451
+ * because they do the same thing to different findings: a worktree that holds
2452
+ * work of mine and an agent that would not run are the session's failures, not
2453
+ * either command's.
2414
2454
  */
2415
- const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
2455
+ const userFacingAndSession = [
2456
+ ...userFacing,
2457
+ "GitFailed",
2458
+ "WorktreeHeld",
2459
+ "AgentFailed"
2460
+ ];
2461
+ /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2462
+ const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2416
2463
  //#endregion
2417
- //#region src/domain/review.ts
2464
+ //#region src/domain/moment.ts
2465
+ const isLater = Order.isGreaterThan(DateTime.Order);
2466
+ /** Whether `self` happened after `other`, counting never as before anything. */
2467
+ const isAfter = (self, other) => Predicate.isNotNull(self) && (other === null || isLater(self, other));
2468
+ /** The later of the two. */
2469
+ const later = (self, other) => isAfter(self, other) ? self : other;
2470
+ /** Whether the two are the same moment, counting never as the same as never. */
2471
+ const isSame = (self, other) => self === null || other === null ? self === other : DateTime.Equivalence(self, other);
2472
+ /** The latest of many, or never when there are none. */
2473
+ const newest = (moments) => moments.reduce(later, null);
2474
+ //#endregion
2475
+ //#region src/terms/pr.ts
2418
2476
  /**
2419
- * What a review run came to, which is what its second turn reported.
2477
+ * What GitHub says about a pull request, in this tool's words.
2420
2478
  *
2421
- * A failure is recorded as one and is never a clean verdict: a turn that exited
2422
- * badly, ran out of patience or answered in a shape that does not validate has
2423
- * found nothing, which is not the same as having found nothing wrong.
2479
+ * The three of them are here because both sides need the same one: `gh` and the
2480
+ * checks adapter answer in these words, and the bucket rules decide on them. A
2481
+ * union restated on each side is a case that goes unreachable the day the other
2482
+ * side gains a member.
2424
2483
  */
2425
- const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2426
- verdict: Verdict,
2427
- findings: Schema.Array(Finding)
2428
- }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
2484
+ /** How far GitHub has got towards letting a tracked PR merge. */
2485
+ const Mergeability = Schema.Literals([
2486
+ "mergeable",
2487
+ "conflicting",
2488
+ "unknown"
2489
+ ]);
2490
+ /** What the reviewers have decided, or that nobody is required to. */
2491
+ const ReviewDecision = Schema.Literals([
2492
+ "approved",
2493
+ "changes-requested",
2494
+ "review-required",
2495
+ "none"
2496
+ ]);
2497
+ /** What CI says about the current head. */
2498
+ const ChecksState = Schema.Literals([
2499
+ "green",
2500
+ "red",
2501
+ "pending",
2502
+ "none"
2503
+ ]);
2504
+ //#endregion
2505
+ //#region src/domain/bucket.ts
2429
2506
  /**
2430
- * One review run against a tracked PR at a specific head commit.
2507
+ * Everything the bucket rules are allowed to know about a tracked PR.
2431
2508
  *
2432
- * It is a schema because a review run outlives the command that started it: the
2433
- * state directory is where the next sweep learns that this head has been
2434
- * reviewed, and where a fix session finds what there is to fix.
2509
+ * It is a schema because a sweep writes it to the state directory and reads it
2510
+ * back on the next one: the same facts that decide a bucket are what a quiet PR
2511
+ * is recognised by.
2435
2512
  */
2436
- const ReviewRun = Schema.Struct({
2513
+ const Facts = Schema.Struct({
2437
2514
  repo: Schema.String,
2438
2515
  number: Schema.Int,
2439
- /** The head the run covers. A run never vouches for code it did not see. */
2516
+ title: Schema.String,
2517
+ url: Schema.String,
2518
+ /** Shown, never acted on unless I ask. */
2519
+ draft: Schema.Boolean,
2520
+ /** The head commit every other fact here is about. */
2440
2521
  head: Schema.String,
2441
- /**
2442
- * The slash command line the run opened on, or null where it opened on the
2443
- * tool's own prompt. A report found months later says what it was asked, and a
2444
- * record an earlier version wrote carries no such field and is forgotten.
2445
- */
2446
- command: Schema.NullOr(Schema.String),
2447
- effort: Schema.NullOr(Effort),
2448
- /**
2449
- * The agent session the run happened in, or null where it never reached one.
2450
- *
2451
- * A run that would not start or exited before it said anything has no session,
2452
- * and the run is still recorded: a failure is recorded as what it is.
2453
- */
2454
- sessionId: Schema.NullOr(Schema.String),
2455
- ranAt: Schema.DateTimeUtcFromString,
2456
- outcome: Outcome
2457
- });
2458
- /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2459
- const short = (head) => head.slice(0, 7);
2460
- /**
2461
- * Where a run is kept: one key per head, so a run and the code it read cannot
2462
- * drift apart, and a re-review replaces the run before it.
2463
- */
2464
- const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2465
- /** Where the run's report is kept: beside the run, as the Markdown it is. */
2466
- const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2467
- /**
2468
- * Which head a pull request was last reviewed at: an index beside `runKey` and
2469
- * `reportKey` rather than a thing the glossary names.
2470
- *
2471
- * A run is kept under the head it read, which answers the question a sweep asks
2472
- * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2473
- * head the last run was at - and this is where they read it, so neither has to
2474
- * ask GitHub what is current before it can look anything up.
2475
- */
2476
- const LastReviewed = Schema.Struct({ head: Schema.String });
2477
- /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2478
- const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2479
- /**
2480
- * The run at one head, or none where nothing has reviewed it.
2481
- *
2482
- * A head is where the question is asked - the stamp, the bucket and `dw-mc
2483
- * findings` all ask about one commit - and one read off the disk answers it
2484
- * without an index to keep in step.
2485
- *
2486
- * Forgetting a run costs one review.
2487
- */
2488
- const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2489
- const runs = yield* storeFor("runs", ReviewRun);
2490
- return yield* remembered(runs.get(runKey(repo, number, head)));
2491
- });
2492
- /** The last review run on a pull request, or none where it has had none. */
2493
- const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2494
- const heads = yield* storeFor("runs", LastReviewed);
2495
- const at = yield* remembered(heads.get(latestKey(repo, number)));
2496
- return Option.isNone(at) ? Option.none() : yield* runAt(repo, number, at.value.head);
2522
+ mergeable: Mergeability,
2523
+ reviewDecision: ReviewDecision,
2524
+ checks: ChecksState,
2525
+ /** Why the flaky classifier excuses this red CI, or null where it does not. */
2526
+ ciFlaky: Schema.NullOr(Schema.String),
2527
+ /** The head a rebase onto the base conflicted at, or null where none has. */
2528
+ rebaseConflictAt: Schema.NullOr(Schema.String),
2529
+ /** The newest comment from a person who is not me, bots excluded. */
2530
+ newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2531
+ myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2532
+ myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2533
+ /** The newest comment my acknowledgement covers, or null where I have made none. */
2534
+ acknowledgedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2535
+ /** The head a review run has already covered, or null where none has. */
2536
+ reviewRunHead: Schema.NullOr(Schema.String),
2537
+ /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
2538
+ blockingFindings: Schema.Int
2497
2539
  });
2540
+ /** The one place a tracked PR sits at a time, named for what it waits on. */
2541
+ const Bucket = Schema.Literals([
2542
+ "needs-me",
2543
+ "needs-review-run",
2544
+ "waiting-on-others",
2545
+ "ready"
2546
+ ]);
2547
+ /** The buckets in the order I act on them: the top of the table is my next move. */
2548
+ const order = [
2549
+ "needs-me",
2550
+ "needs-review-run",
2551
+ "waiting-on-others",
2552
+ "ready"
2553
+ ];
2498
2554
  /**
2499
- * What a run reported, or null where it reported nothing at all.
2555
+ * Why a PR is mine to move when somebody has said something I have not
2556
+ * answered.
2500
2557
  *
2501
- * A failure is not a clean verdict: a run that could not report has found
2502
- * nothing, which is not the same as having found nothing wrong. Everything that
2503
- * reads a run's findings reads them through here, so the distinction is drawn
2504
- * once rather than at every caller that might forget it.
2558
+ * It is named because it is read twice: here, where it puts the PR in Needs me,
2559
+ * and by `dw-mc comments`, which says what settles that one branch of the
2560
+ * bucket. A sentence matched from the other side of the tool is a rule that
2561
+ * breaks on a reword.
2505
2562
  */
2506
- const reportedBy = (run) => run.outcome._tag === "reported" ? {
2507
- verdict: run.outcome.verdict,
2508
- findings: run.outcome.findings
2509
- } : null;
2563
+ const unanswered = "a comment I have not answered";
2510
2564
  /**
2511
- * Why a run reported nothing, or null where it reported.
2565
+ * Why a PR is mine to move when a review run found something that withholds
2566
+ * the stamp.
2512
2567
  *
2513
- * The sibling of `reportedBy`, and here for the same reason: the two halves of
2514
- * an outcome are read through one place each rather than re-narrowed at every
2515
- * caller.
2568
+ * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2569
+ * sentence about this same number, and two spellings of it would be two
2570
+ * answers to what a blocking finding is worth.
2516
2571
  */
2517
- const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2572
+ const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2518
2573
  /**
2519
- * Whether the files changed since the last run are worth paying for another.
2574
+ * How far my answer to the conversation reaches: the latest of my last comment,
2575
+ * my last commit and my acknowledgement.
2520
2576
  *
2521
- * The question is deliberately about what changed rather than how much: one
2522
- * line outside the `docs_only` globs is code nobody has reviewed, and a
2523
- * thousand lines inside them are still prose.
2577
+ * A comment is answered by a reply, by a push, or by my word that nothing in it
2578
+ * was mine to answer. It is named because it is read twice: here, where a newer
2579
+ * comment puts the PR in Needs me, and by `dw-mc comments`, which shows exactly
2580
+ * the comments newer than it.
2524
2581
  */
2525
- const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
2582
+ const answeredAt = (facts) => later(later(facts.myLastCommentAt, facts.myLastCommitAt), facts.acknowledgedAt);
2526
2583
  /**
2527
- * The re-run rule: the head this run is skipped against, or null where it runs.
2528
- *
2529
- * A review costs real money and minutes of my attention, and a typo fix is not
2530
- * worth either. Four things are never skipped, because the rule is here to save
2531
- * me a review and not to stand between me and one I asked for: a pull request
2532
- * with no run behind it, a run that reported nothing, a comparison GitHub would
2533
- * not answer, and anything that changed outside the globs. A head that has
2534
- * already had a run changed nothing at all, which is the one case that needs no
2535
- * comparison to decide.
2584
+ * The first of the rules that makes a PR mine to move, or null when none
2585
+ * does. The order is the order I would fix them in: a conflict makes every
2586
+ * other signal on the PR stale, and a red build is worth more than a comment.
2536
2587
  */
2537
- const skippedSince = (asked, docsOnly) => {
2538
- if (asked.last === null || reportedBy(asked.last) === null) return null;
2539
- const changed = asked.last.head === asked.head ? [] : asked.changed;
2540
- return changed === null || worthRerunning(changed, docsOnly) ? null : asked.last.head;
2588
+ const needsMe = (facts) => {
2589
+ if (facts.mergeable === "conflicting") return "merge conflict";
2590
+ if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2591
+ if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2592
+ if (facts.reviewDecision === "changes-requested") return "changes requested";
2593
+ if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2594
+ if (isAfter(facts.newestHumanCommentAt, answeredAt(facts))) return unanswered;
2595
+ return null;
2541
2596
  };
2542
- /** What a run was opened on, as the report says it. */
2543
- const askedOf$2 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2544
2597
  /**
2545
- * The report as it is written down: what it is of, then what the run said.
2598
+ * What is actually true of a PR nothing is waiting on.
2599
+ *
2600
+ * Ready is reached by having no reason not to be, so the reason says only what
2601
+ * holds: a repository that requires no reviewer produces no approval, and a
2602
+ * pull request with no CI at all is not green.
2546
2603
  *
2547
- * The heading is the whole point of writing it rather than storing the prose
2548
- * alone - a file found months later says which pull request, which commit and
2549
- * what the run was asked, without anything else having to be open.
2604
+ * A red CI the classifier excused is said out loud, because GitHub does not
2605
+ * excuse it: the check is still red, and Ready is what `dw-mc merge` reads.
2550
2606
  */
2551
- const reportDocument = (run, title, prose) => [
2552
- `# ${run.repo}#${run.number} ${title}`,
2553
- "",
2554
- `- head: ${run.head}`,
2555
- `- run: ${askedOf$2(run)}`,
2556
- `- ran: ${DateTime.formatIso(run.ranAt)}`,
2557
- "",
2558
- prose.trim(),
2559
- ""
2560
- ].join("\n");
2607
+ const readyReason = (facts) => {
2608
+ const held = [
2609
+ facts.reviewDecision === "approved" ? "approved" : null,
2610
+ facts.checks === "green" ? "green" : null,
2611
+ facts.mergeable === "mergeable" ? "mergeable" : null
2612
+ ].filter((it) => it !== null);
2613
+ const standing = held.length === 0 ? "nothing left to wait on" : held.join(", ");
2614
+ return facts.checks === "red" && facts.ciFlaky !== null ? `${standing} (red CI called flaky: ${facts.ciFlaky})` : standing;
2615
+ };
2561
2616
  /**
2562
- * Whether `head` has the review it needs.
2617
+ * The bucket a tracked PR sits in, and the reason for it.
2563
2618
  *
2564
- * A run that reported nothing does not count, which is the same rule
2565
- * `reportedBy` draws everywhere else: a failure has found nothing, not found
2566
- * nothing wrong.
2619
+ * This is the single place the bucket rules exist. Every tracked PR lands in
2620
+ * exactly one bucket, so the rules are tried in priority order and the first
2621
+ * that claims the PR wins: a PR that both needs a review run and has changes
2622
+ * requested is mine to move, not the review's.
2623
+ *
2624
+ * Ready does not insist on an approval, because a repository that requires no
2625
+ * reviewer never produces one. What it insists on is that nobody else has been
2626
+ * asked and is yet to answer.
2567
2627
  */
2568
- const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2569
- /** The findings at one head that withhold the stamp. */
2570
- const blockingIn = (run, blocksOn) => {
2571
- const found = run === null ? null : reportedBy(run);
2572
- return found === null ? [] : blocking(found.findings, blocksOn);
2628
+ const place = (facts) => {
2629
+ const mine = needsMe(facts);
2630
+ if (mine !== null) return {
2631
+ bucket: "needs-me",
2632
+ reason: mine
2633
+ };
2634
+ if (facts.reviewRunHead !== facts.head) return {
2635
+ bucket: "needs-review-run",
2636
+ reason: "no review run on this head"
2637
+ };
2638
+ if (facts.reviewDecision === "review-required") return {
2639
+ bucket: "waiting-on-others",
2640
+ reason: "a review from someone else"
2641
+ };
2642
+ if (facts.checks === "pending") return {
2643
+ bucket: "waiting-on-others",
2644
+ reason: "CI is still running"
2645
+ };
2646
+ return {
2647
+ bucket: "ready",
2648
+ reason: readyReason(facts)
2649
+ };
2650
+ };
2651
+ const group = (facts) => {
2652
+ const placed = facts.map((it) => ({
2653
+ facts: it,
2654
+ placement: place(it)
2655
+ })).toSorted((a, b) => a.facts.repo.localeCompare(b.facts.repo) || a.facts.number - b.facts.number);
2656
+ return order.map((bucket) => ({
2657
+ bucket,
2658
+ placed: placed.filter((it) => it.placement.bucket === bucket)
2659
+ })).filter((bucket) => bucket.placed.length > 0);
2573
2660
  };
2661
+ //#endregion
2662
+ //#region src/domain/reference.ts
2663
+ /** `owner/name#12`, or `12` on its own. */
2664
+ const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
2574
2665
  /**
2575
- * What the review runs on `head` say about it, for the stamp to rest on.
2666
+ * A segment of nothing but dots, which no repository is called.
2576
2667
  *
2577
- * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
2578
- * recorded against one head, and a head with no run of its own has not been
2579
- * reviewed however many sweeps have seen the pull request. A run that could not
2580
- * report findings does not count either: its verdict is what takes a pull
2581
- * request out of Needs review run, and it reached none.
2668
+ * The repository names a directory under the state directory before it names
2669
+ * anything else, so `../x` would be a way out of it.
2670
+ */
2671
+ const onlyDots = /^\.+$/;
2672
+ /**
2673
+ * The pull request a reference names.
2582
2674
  *
2583
- * It is one function because the two callers are a sweep and `dw-mc merge`, and
2584
- * the second exists to land what the first only describes: two spellings of
2585
- * this would be two answers to whether a head has been reviewed.
2675
+ * A reference that spells its repository out is taken as it is, registered or
2676
+ * not: reviewing someone else's pull request is a thing to ask for, and the
2677
+ * settings a repository nothing registered gets are the global defaults.
2586
2678
  */
2587
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
2588
- const run = Option.getOrNull(yield* runAt(repo, number, head));
2679
+ const resolve$1 = (text, registered) => {
2680
+ const found = spelled.exec(text);
2681
+ const number = found?.[2];
2682
+ if (number === void 0) return {
2683
+ _tag: "unreadable",
2684
+ text
2685
+ };
2686
+ const spelledRepo = found?.[1];
2687
+ if (spelledRepo !== void 0 && spelledRepo.split("/").some((segment) => onlyDots.test(segment))) return {
2688
+ _tag: "unreadable",
2689
+ text
2690
+ };
2691
+ const repo = spelledRepo ?? (registered.length === 1 ? registered[0] : void 0);
2692
+ if (repo === void 0) return {
2693
+ _tag: "ambiguous",
2694
+ repos: registered
2695
+ };
2589
2696
  return {
2590
- reviewRunHead: reviewedBy(run) ? head : null,
2591
- blockingFindings: blockingIn(run, blocksOn).length
2697
+ _tag: "resolved",
2698
+ repo,
2699
+ number: Number(number)
2592
2700
  };
2593
- });
2701
+ };
2594
2702
  //#endregion
2595
2703
  //#region src/cli/pr.ts
2596
2704
  /** The pull request a command acts on, named the way I actually type it. */
@@ -2725,6 +2833,20 @@ const marker = {
2725
2833
  "waiting-on-others": "○",
2726
2834
  ready: "◆"
2727
2835
  };
2836
+ /**
2837
+ * What sits in front of a row: `+` for a pull request never shown, `*` for one
2838
+ * that moved since it was last shown, and a blank for one that did neither.
2839
+ *
2840
+ * It is one character from the part of Unicode every font has, for the reason
2841
+ * the marker is, and it is not coloured: the bucket is what the colour says.
2842
+ */
2843
+ const gutter = {
2844
+ new: "+",
2845
+ moved: "*",
2846
+ still: " "
2847
+ };
2848
+ /** How a row names its pull request. */
2849
+ const reference = (facts) => `${facts.repo}#${facts.number}`;
2728
2850
  /** The colour a bucket is said in: red is mine, yellow is next, green is done, dim is not my turn. */
2729
2851
  const tint = (paint, bucket) => ({
2730
2852
  "needs-me": paint.red,
@@ -2737,6 +2859,8 @@ const rule = " │ ";
2737
2859
  /**
2738
2860
  * One row: which pull request, what it is, and what it waits on.
2739
2861
  *
2862
+ * In front of it all is the gutter, which says what moved since I last looked.
2863
+ *
2740
2864
  * A stamp is a mark beside the pull request rather than a column of its own, so
2741
2865
  * a table where nothing is stamped is exactly the table it was before: the
2742
2866
  * stamp is a thing I look for, not a thing I read every row of.
@@ -2754,25 +2878,51 @@ const rule = " │ ";
2754
2878
  * straight, so its rows say it in more than one place and open the pull request
2755
2879
  * besides.
2756
2880
  */
2757
- const cells = (placed, stamped, room, paint, lead) => {
2881
+ const cells = (placed, stamped, since, room, paint, lead) => {
2758
2882
  const { facts } = placed;
2759
2883
  const { bucket } = placed.placement;
2760
2884
  const say = tint(paint, bucket);
2761
- const reference = `${facts.repo}#${facts.number}`;
2762
2885
  const named = lead === "named";
2763
- const pr = `${named ? reference : paint.link(reference, facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2886
+ const pr = `${named ? reference(facts) : paint.link(reference(facts), facts.url)}${facts.draft ? paint.dim(" (draft)") : ""}${stamped ? ` ${paint.green("✓")}` : ""}`;
2887
+ const front = gutter[since._tag];
2764
2888
  return named ? [
2765
- say(`${marker[bucket]} ${heading[bucket]}`),
2889
+ `${front} ${say(`${marker[bucket]} ${heading[bucket]}`)}`,
2766
2890
  pr,
2767
2891
  truncate(facts.title, room),
2768
2892
  placed.placement.reason
2769
2893
  ] : [
2770
- `${say(marker[bucket])} ${pr}`,
2894
+ `${front} ${say(marker[bucket])} ${pr}`,
2771
2895
  paint.dim(truncate(facts.title, room)),
2772
2896
  say(placed.placement.reason)
2773
2897
  ];
2774
2898
  };
2775
2899
  //#endregion
2900
+ //#region src/domain/acknowledgement.ts
2901
+ /**
2902
+ * My word that I have read a pull request's conversation as far as one comment,
2903
+ * and that nothing in it is mine to answer.
2904
+ *
2905
+ * The comment's moment is the whole record, and no head is: everything else the
2906
+ * tool records is about code and lapses when the head changes, and this one is
2907
+ * about a conversation. A push is already counted as my answer to a comment, so
2908
+ * tying this to a head would only bring back a comment the push had nothing to
2909
+ * do with.
2910
+ */
2911
+ const Acknowledgement = Schema.Struct({ at: Schema.DateTimeUtcFromString });
2912
+ /** The newest comment I have acknowledged on a pull request, or null where I have acknowledged none. */
2913
+ const acknowledgedAt = Effect.fn("acknowledgement.acknowledgedAt")(function* (repo, number) {
2914
+ const store = yield* storeFor("acknowledgements", Acknowledgement);
2915
+ const acknowledgement = yield* remembered(store.get(prKey(repo, number)));
2916
+ return Option.match(acknowledgement, {
2917
+ onNone: () => null,
2918
+ onSome: (it) => it.at
2919
+ });
2920
+ });
2921
+ /** Records that I have read a pull request's conversation as far as the comment written at `at`. */
2922
+ const acknowledge = Effect.fn("acknowledgement.acknowledge")(function* (repo, number, at) {
2923
+ yield* (yield* storeFor("acknowledgements", Acknowledgement)).set(prKey(repo, number), { at });
2924
+ });
2925
+ //#endregion
2776
2926
  //#region src/domain/comments.ts
2777
2927
  /**
2778
2928
  * One thread's share of a strand, cut to what is worth reading.
@@ -2813,9 +2963,24 @@ const shown = (threads, options) => {
2813
2963
  bots: kept.flatMap((it) => only(it, (bot) => bot, options.since, options.all))
2814
2964
  };
2815
2965
  };
2966
+ /**
2967
+ * The comment an acknowledgement of this conversation covers: the newest thing
2968
+ * a person said in it, whichever thread it is in.
2969
+ *
2970
+ * The whole conversation rather than what went on screen, because the bucket
2971
+ * rule counts the whole of it: a comment on a thread somebody resolved still
2972
+ * puts the pull request in Needs me, and an acknowledgement that stopped short
2973
+ * of it would settle nothing. A bot is left out for the reason the rule leaves
2974
+ * it out.
2975
+ *
2976
+ * It is a comment's own moment and never the clock's, so a comment written
2977
+ * after the conversation was read is one the acknowledgement does not cover.
2978
+ */
2979
+ const acknowledging = (threads) => newest(threads.flatMap((thread) => thread.comments.filter((it) => !it.bot).map((it) => it.at)));
2816
2980
  //#endregion
2817
2981
  //#region src/cli/comments.ts
2818
2982
  const allFlag$1 = Flag.Boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Print the whole conversation, including what is resolved, outdated and already answered"));
2983
+ 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
2984
  /** Where a thread hangs: a line of the diff, or the pull request itself. */
2820
2985
  const where$1 = (thread) => thread.path === null ? "Conversation" : thread.line === null ? thread.path : `${thread.path}:${thread.line}`;
2821
2986
  /**
@@ -2835,8 +3000,7 @@ const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdate
2835
3000
  * exists to replace. No diff hunk with it: the code is on this machine, under
2836
3001
  * the path the heading already prints.
2837
3002
  */
2838
- const block$1 = (thread, paint) => [`${paint.bold(where$1(thread))}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, ...thread.comments.flatMap((comment) => [` ${paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`)}`, ...comment.body.split("\n").map((line) => ` ${line}`)])];
2839
- const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
3003
+ const threadBlock = (thread, paint) => block(`${where$1(thread)}${settled(thread) === "" ? "" : paint.dim(` (${settled(thread)})`)}`, thread.comments.flatMap((comment) => [paint.dim(`@${comment.login} ${DateTime.formatIso(comment.at)}`), ...comment.body.split("\n").map(indent)]));
2840
3004
  /**
2841
3005
  * The conversation on screen: people first, then a rule, then the bots.
2842
3006
  *
@@ -2849,10 +3013,10 @@ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lin
2849
3013
  * verdict older than my last push is one I have already had the chance to read,
2850
3014
  * and `--all` is where it still is.
2851
3015
  */
2852
- const lines$2 = (view, paint) => {
2853
- const people = view.people.map((thread) => block$1(thread, paint));
2854
- const bots = view.bots.map((thread) => block$1(thread, paint));
2855
- return separated([...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]]);
3016
+ const blocks = (view, paint) => {
3017
+ const people = view.people.map((thread) => threadBlock(thread, paint));
3018
+ const bots = view.bots.map((thread) => threadBlock(thread, paint));
3019
+ return [...people, ...bots.length === 0 ? [] : [[paint.dim("── bots ──")], ...bots]];
2856
3020
  };
2857
3021
  /** What to say where there is nothing to print, which depends on why there is not. */
2858
3022
  const nothing$1 = (facts, all) => {
@@ -2861,50 +3025,68 @@ const nothing$1 = (facts, all) => {
2861
3025
  const placement = place(facts);
2862
3026
  const rest = `dw-mc comments ${facts.number} --all prints the whole conversation.`;
2863
3027
  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.`,
3028
+ "Nothing here is waiting on you: every thread is resolved, outdated, or older than your last comment, commit or acknowledgement.",
3029
+ `${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
3030
  rest
2867
- ] : [`Nothing has been said on ${pr} since your last comment or commit.`, rest];
3031
+ ] : [`Nothing has been said on ${pr} since your last comment, commit or acknowledgement.`, rest];
2868
3032
  };
2869
3033
  /**
3034
+ * Records an acknowledgement of the conversation read, and says what it covers
3035
+ * and where the pull request sits with it.
3036
+ *
3037
+ * Where it sits is worked out from the last sweep with the acknowledgement laid
3038
+ * over it, which is the same answer the next sweep gives unless somebody says
3039
+ * something new in between.
3040
+ */
3041
+ const acknowledged = Effect.fn("comments.acknowledged")(function* (facts, threads) {
3042
+ const pr = `${facts.repo}#${facts.number}`;
3043
+ const at = acknowledging(threads);
3044
+ if (at === null) return [`Nothing to acknowledge: nobody has said anything on ${pr}.`];
3045
+ yield* acknowledge(facts.repo, facts.number, at);
3046
+ const placement = place({
3047
+ ...facts,
3048
+ acknowledgedAt: at
3049
+ });
3050
+ return [`Acknowledged everything said on ${pr} up to ${DateTime.formatIso(at)}.`, `${pr} sits in ${heading[placement.bucket]}: ${placement.reason}.`];
3051
+ });
3052
+ /**
2870
3053
  * The conversation on one tracked pull request, and nothing else.
2871
3054
  *
2872
3055
  * 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.
3056
+ * than the latest of my last comment, my last commit and my acknowledgement,
3057
+ * which are the ones that put the pull request in Needs me. Reading it answers
3058
+ * the question the table asked.
2876
3059
  *
2877
3060
  * The cutoff is read off the last sweep rather than worked out again here, so
2878
3061
  * the command shows exactly what `dw-mc status` counted rather than a second
2879
3062
  * opinion about it.
2880
3063
  *
2881
- * It writes nothing, here or on GitHub: no reply, no resolve, no reaction
2882
- * (ADR 0002). Reading is the whole command.
3064
+ * It writes nothing to GitHub: no reply, no resolve, no reaction (ADR 0002).
3065
+ * `--ack` is the one thing it writes at all, and only on this machine: whether a
3066
+ * comment needs an answer is known after reading it, so reading cannot be what
3067
+ * decides it.
2883
3068
  */
2884
3069
  const comments = Command.make("comments", {
2885
3070
  pr: prArgument,
2886
- all: allFlag$1
2887
- }, Effect.fn("comments")(function* ({ all, pr }) {
3071
+ all: allFlag$1,
3072
+ ack: ackFlag
3073
+ }, Effect.fn("comments")(function* ({ ack, all, pr }) {
2888
3074
  const { number, repo } = yield* forPr(pr);
2889
3075
  const facts = yield* swept(repo, number);
2890
3076
  const paint = yield* Paint;
2891
- const view = shown(yield* reading(`${repo}#${number}`, prConversation(repo, number)), {
2892
- since: later(facts.myLastCommentAt, facts.myLastCommitAt),
3077
+ const threads = yield* reading(`${repo}#${number}`, prConversation(repo, number));
3078
+ const view = shown(threads, {
3079
+ since: answeredAt(facts),
2893
3080
  all
2894
3081
  });
2895
- if (view.people.length === 0 && view.bots.length === 0) {
2896
- yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
2897
- return;
2898
- }
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"));
3082
+ yield* print(view.people.length === 0 && view.bots.length === 0 ? nothing$1(facts, all) : separated([[`${repo}#${number} ${paint.dim(facts.title)}`], ...blocks(view, paint)]));
3083
+ if (ack) yield* print(following([yield* acknowledged(facts, threads)]));
3084
+ }, 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
3085
  //#endregion
2904
3086
  //#region src/cli/findings.ts
2905
3087
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
2906
- const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
2907
- const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
3088
+ const asJson$3 = Schema.encodeEffect(Schema.fromJsonString(Findings));
3089
+ const jsonFlag$1 = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the findings as the JSON a fix session is handed"));
2908
3090
  /** What a run's findings come to in one line, against the bar that blocks. */
2909
3091
  const summary = (found, blocksOn) => {
2910
3092
  if (found.findings.length === 0) return "clean, nothing to fix";
@@ -2912,14 +3094,21 @@ const summary = (found, blocksOn) => {
2912
3094
  return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
2913
3095
  };
2914
3096
  /** Which run these findings are, and what they come to: the line above the list. */
2915
- const header$1 = (run, found, blocksOn) => `${run.repo}#${run.number} ${short(run.head)} ${summary(found, blocksOn)}`;
3097
+ const header$1 = (run, found, blocksOn, paint) => opener(paint, run.repo, run.number, run.head, summary(found, blocksOn));
3098
+ /** The bucket whose colour a severity is said in, so one colour means one thing everywhere (ADR 0007). */
3099
+ const colourOf = {
3100
+ error: "needs-me",
3101
+ warning: "needs-review-run",
3102
+ info: "waiting-on-others"
3103
+ };
2916
3104
  /**
2917
3105
  * The findings one to a line, in the order the run reported them, ruled so the
2918
- * three columns read apart.
3106
+ * three columns read apart. The place is context and the severity is state;
3107
+ * what the finding says is prose.
2919
3108
  */
2920
- const lines$1 = (found) => table(found.findings.map((finding) => [
2921
- `${finding.file}:${finding.line}`,
2922
- finding.severity,
3109
+ const lines$1 = (found, paint) => table(found.findings.map((finding) => [
3110
+ paint.dim(`${finding.file}:${finding.line}`),
3111
+ tint(paint, colourOf[finding.severity])(finding.severity),
2923
3112
  finding.summary
2924
3113
  ]), rule);
2925
3114
  /**
@@ -2945,17 +3134,17 @@ const whatItFound = (run) => {
2945
3134
  */
2946
3135
  const findings = Command.make("findings", {
2947
3136
  pr: prArgument,
2948
- json: jsonFlag
3137
+ json: jsonFlag$1
2949
3138
  }, Effect.fn("findings")(function* ({ json, pr }) {
2950
3139
  const { number, repo, settings } = yield* forPr(pr);
2951
3140
  const run = yield* currentRun(repo, number);
2952
3141
  const found = yield* whatItFound(run);
2953
3142
  if (json) {
2954
- yield* Console.log(yield* asJson$2(found));
3143
+ yield* Console.log(yield* asJson$3(found));
2955
3144
  return;
2956
3145
  }
2957
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
2958
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
3146
+ const paint = yield* Paint;
3147
+ yield* print(block(header$1(run, found, settings.stamp.blocks_on, paint), lines$1(found, paint)));
2959
3148
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
2960
3149
  //#endregion
2961
3150
  //#region src/adapters/agent.ts
@@ -3355,7 +3544,7 @@ const Selection = Schema.Struct({
3355
3544
  findings: Schema.Array(Chosen)
3356
3545
  });
3357
3546
  /** The selection as the JSON the schema defines, rather than as this file spells it. */
3358
- const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3547
+ const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3359
3548
  /**
3360
3549
  * The prompt a fix session opens on: what these findings are, and the findings
3361
3550
  * themselves as JSON.
@@ -3370,7 +3559,7 @@ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3370
3559
  * inside the worktree is my call, made once in `fix.commits` or for one session
3371
3560
  * with the flag.
3372
3561
  */
3373
- const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3562
+ const promptFor$1 = (selection, commits) => Effect.map(asJson$2(selection), (json) => [
3374
3563
  `These are the findings I picked from a dw-mc review run on ${selection.repo}#${selection.number}, at ${short(selection.head)}, the commit their lines are counted from.`,
3375
3564
  "Work through them one at a time. Where a finding carries a note, the note is mine and outranks the finding's own summary; where it carries none, the summary is the whole brief.",
3376
3565
  commits ? `Commit what you change, one logical change to a commit. Do not push: I read the commits and push them myself.` : `Do not commit and do not push: I do both myself when I have read what you changed.`,
@@ -3396,10 +3585,11 @@ const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this se
3396
3585
  * The rows come from there rather than being built again here, so the list I
3397
3586
  * pick from and the list I read are the same list. A row that does not fit the
3398
3587
  * screen is cut: a prompt draws its own frame around the row, and a row that
3399
- * wraps takes the whole list's alignment with it.
3588
+ * wraps takes the whole list's alignment with it. They are plain, because a
3589
+ * prompt counts the colour it erases as rows (ADR 0007).
3400
3590
  */
3401
3591
  const choicesOf$1 = (found, screen) => {
3402
- const rows = lines$1(found);
3592
+ const rows = lines$1(found, plain);
3403
3593
  const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3404
3594
  return found.findings.map((finding, index) => ({
3405
3595
  title: truncate(rows[index] ?? finding.summary, room),
@@ -3446,11 +3636,12 @@ const fix = Command.make("fix", {
3446
3636
  pr: prArgument,
3447
3637
  commit: commitFlag,
3448
3638
  print: printFlag$1
3449
- }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3639
+ }, Effect.fn("fix")(function* ({ commit, pr, print: promptOnly }) {
3450
3640
  const { number, repo, settings, launcher } = yield* forPr(pr);
3641
+ const paint = yield* Paint;
3451
3642
  const run = yield* currentRun(repo, number);
3452
3643
  const found = yield* whatItFound(run);
3453
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3644
+ yield* print([header$1(run, found, settings.stamp.blocks_on, paint)]);
3454
3645
  if (found.findings.length === 0) return;
3455
3646
  const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3456
3647
  yield* refuse(staleAt(number, run.head, view.headRefOid));
@@ -3461,7 +3652,7 @@ const fix = Command.make("fix", {
3461
3652
  return;
3462
3653
  }
3463
3654
  const commits = Option.getOrElse(commit, () => settings.fix.commits);
3464
- if (print) {
3655
+ if (promptOnly) {
3465
3656
  yield* Console.log(yield* promptFor$1({
3466
3657
  repo,
3467
3658
  number,
@@ -3471,8 +3662,7 @@ const fix = Command.make("fix", {
3471
3662
  return;
3472
3663
  }
3473
3664
  const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3474
- yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3475
- yield* Console.log(` ${worktree.directory}, pushing to ${view.headRefName}`);
3665
+ yield* print([indent(`${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`), indent(`${paint.dim(worktree.directory)}, pushing to ${view.headRefName}`)]);
3476
3666
  const ended = yield* steeredSession({
3477
3667
  launcher,
3478
3668
  directory: worktree.directory,
@@ -3483,11 +3673,90 @@ const fix = Command.make("fix", {
3483
3673
  findings: chosen
3484
3674
  }, commits)
3485
3675
  });
3486
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3487
- yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3488
- yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
3676
+ yield* print(following([[
3677
+ ended === 0 ? "The session is over." : `The session ended with ${ended}.`,
3678
+ `${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${paint.dim(worktree.directory)}.`,
3679
+ `Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`
3680
+ ]]));
3489
3681
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3490
3682
  //#endregion
3683
+ //#region src/domain/forget.ts
3684
+ /**
3685
+ * Whether a key in the state directory is about this pull request, whichever
3686
+ * namespace it sits in.
3687
+ *
3688
+ * Every namespace names a pull request by `prKey`, and the review runs add the
3689
+ * head after an `@`. Reading the key rather than a list of namespaces is what
3690
+ * lets a namespace added later be forgotten with the rest.
3691
+ */
3692
+ const isAbout = (key, repo, number) => {
3693
+ const slash = key.indexOf("/");
3694
+ if (slash < 0) return false;
3695
+ const rest = key.slice(slash + 1);
3696
+ const pr = prKey(repo, number);
3697
+ return rest === pr || rest.startsWith(`${pr}@`);
3698
+ };
3699
+ /**
3700
+ * Removes everything the state directory keeps about one pull request, and
3701
+ * says how many keys that was.
3702
+ *
3703
+ * A pull request it keeps nothing about is forgotten already, so that is none
3704
+ * rather than a failure.
3705
+ */
3706
+ const forget = Effect.fn("forget.forget")(function* (repo, number) {
3707
+ const store = yield* KeyValueStore.KeyValueStore;
3708
+ const about = (yield* allKeys).filter((key) => isAbout(key, repo, number));
3709
+ yield* Effect.forEach(about, (key) => store.remove(key), { discard: true });
3710
+ return about.length;
3711
+ });
3712
+ /**
3713
+ * The session worktrees that stand on one pull request.
3714
+ *
3715
+ * Forgetting takes none of them: each stands on a branch of the tool's own and
3716
+ * holds what I committed there, so what the tool does is say they are there.
3717
+ */
3718
+ const standingOn = (inventory, repo, number) => standing$1(inventory).filter((it) => it.repo === repo && it.number === number);
3719
+ //#endregion
3720
+ //#region src/cli/forget.ts
3721
+ /**
3722
+ * Forgets one pull request, and answers with the blocks that say what stays:
3723
+ * the records go, the session worktrees standing on it do not.
3724
+ *
3725
+ * `deleted` is the branch the pull request stood on, where the caller has just
3726
+ * deleted it. A session there tracks a branch that is gone, which is worth
3727
+ * saying, and what it holds is still mine, so saying it is all this does.
3728
+ */
3729
+ const forgetting = Effect.fn("forgetting")(function* (repo, number, options = {}) {
3730
+ const deleted = options.deleted;
3731
+ const path = yield* Path.Path;
3732
+ const paint = yield* Paint;
3733
+ const where = `${repo}#${number}`;
3734
+ const forgot = yield* forget(repo, number);
3735
+ const forgotten = [forgot === 0 ? `Nothing is kept about ${where}.` : `Forgot ${where}: ${count(forgot, "record")}.`];
3736
+ const found = yield* inventory;
3737
+ const sessions = standingOn(found, repo, number);
3738
+ if (sessions.length === 0) return [forgotten];
3739
+ 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`)]));
3740
+ return [
3741
+ forgotten,
3742
+ block("Stays", rows),
3743
+ ["What you committed there is yours, so nothing here takes it down."]
3744
+ ];
3745
+ });
3746
+ /**
3747
+ * Forgets a pull request that closed some other way than `dw-mc merge`.
3748
+ *
3749
+ * A sweep never does this on its own. A pull request missing from one search is
3750
+ * not one that is gone - a failed `gh search` would look the same - and a sweep
3751
+ * is built so that a failure costs a row rather than the table. Being done is
3752
+ * something I know and the tool does not, except when it merged the pull
3753
+ * request itself.
3754
+ */
3755
+ const forgetCommand = Command.make("forget", { pr: prArgument }, Effect.fn("forget")(function* ({ pr }) {
3756
+ const { number, repo } = yield* forPr(pr);
3757
+ yield* print(separated(yield* forgetting(repo, number)));
3758
+ })).pipe(Command.withDescription("Forget everything kept about a pull request that is done"));
3759
+ //#endregion
3491
3760
  //#region src/cli/init.ts
3492
3761
  const effortFlag$1 = Flag.Literals("effort", [
3493
3762
  "low",
@@ -3542,10 +3811,13 @@ const init = Command.make("init", {
3542
3811
  const overrides = asked(base, effort);
3543
3812
  const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3544
3813
  if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3545
- yield* Console.log(row("review", opening(written.defaults ?? {})));
3546
- yield* Console.log(row("config", config.path));
3547
- yield* Console.log(row("state", state));
3548
- yield* Console.log(Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`));
3814
+ const paint = yield* Paint;
3815
+ yield* print([
3816
+ row("review", opening(written.defaults ?? {})),
3817
+ row("config", paint.dim(config.path)),
3818
+ row("state", paint.dim(state)),
3819
+ Option.isNone(repo) ? row("repository", "none here - run dw-mc init inside a repository to register it") : row("repository", `${repo.value} (${file.repos?.[repo.value] === void 0 ? "registered" : "already registered"})`)
3820
+ ]);
3549
3821
  }, Effect.catchTag([
3550
3822
  "ConfigMalformed",
3551
3823
  "GhUnauthenticated",
@@ -3924,6 +4196,10 @@ const decide$3 = (situation) => {
3924
4196
  *
3925
4197
  * Typing the command is the confirmation, so it takes no flag. The picker,
3926
4198
  * where a keystroke is cheaper, asks before it dispatches.
4199
+ *
4200
+ * Its last step is forgetting the pull request, in every namespace. A session
4201
+ * worktree standing on it stays, and is named, because its branch tracked the
4202
+ * one this just deleted.
3927
4203
  */
3928
4204
  const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(function* ({ pr }) {
3929
4205
  const { number, repo, settings } = yield* forPr(pr);
@@ -3942,8 +4218,10 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
3942
4218
  withdrawnAt: yield* withdrawnAt(repo, number)
3943
4219
  }));
3944
4220
  yield* mergePr(repo, number);
3945
- yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
3946
- yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
4221
+ const paint = yield* Paint;
4222
+ yield* print(separated([[opener(paint, repo, number, head, `squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`)], [`The squash subject is the pull request title: ${paint.dim(view.title)}`]]));
4223
+ const forgot = yield* forgetting(repo, number, { deleted: view.headRefName }).pipe(Effect.catch((error) => Effect.succeed([[`Could not forget ${repo}#${number}: ${error.message}. Run dw-mc forget ${number} to try again.`]])));
4224
+ yield* print(following(forgot));
3947
4225
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
3948
4226
  //#endregion
3949
4227
  //#region src/domain/coverage.ts
@@ -4325,6 +4603,7 @@ const sweepPr = Effect.fn("sweep.pullRequest")(function* (store, me, found, sett
4325
4603
  newestHumanCommentAt,
4326
4604
  myLastCommentAt: newest(writtenBy(comments, me)),
4327
4605
  myLastCommitAt,
4606
+ acknowledgedAt: yield* acknowledgedAt(found.repo, found.number),
4328
4607
  ...reviewed
4329
4608
  };
4330
4609
  yield* store.set(key, facts);
@@ -4439,15 +4718,12 @@ const askedOf$1 = (flags) => ({
4439
4718
  */
4440
4719
  const printLeftOut = Effect.fn("sweep.printLeftOut")(function* (report) {
4441
4720
  if (report.leftOut === 0) return;
4442
- yield* Console.log("");
4443
- yield* Console.log(`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`);
4721
+ yield* print(following([[`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`]]));
4444
4722
  });
4445
4723
  /** What a sweep could not read, under a heading, so the table above it stands alone. */
4446
4724
  const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4447
4725
  if (troubles.length === 0) return;
4448
- yield* Console.log("");
4449
- yield* Console.log("Could not load");
4450
- for (const trouble of troubles) yield* Console.log(` ${trouble.where} ${trouble.detail}`);
4726
+ yield* print(following([block("Could not load", troubles.map((trouble) => `${trouble.where} ${trouble.detail}`))]));
4451
4727
  });
4452
4728
  /**
4453
4729
  * Refreshes what mission control knows about the tracked PRs it covers.
@@ -4600,6 +4876,113 @@ const recordRerun = Effect.fn("rerun.recordRerun")(function* (repo, number, head
4600
4876
  yield* (yield* storeFor("reruns", Rerun)).set(prKey(repo, number), { head });
4601
4877
  });
4602
4878
  //#endregion
4879
+ //#region src/domain/watermark.ts
4880
+ /** What a row turns on, which is what a watermark keeps of it. */
4881
+ const Said = Schema.Struct({
4882
+ bucket: Bucket,
4883
+ mergeable: Mergeability,
4884
+ checks: ChecksState,
4885
+ /** Whether the flaky classifier excused a red CI. */
4886
+ excused: Schema.Boolean,
4887
+ reviewDecision: ReviewDecision,
4888
+ blockingFindings: Schema.Int,
4889
+ newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
4890
+ draft: Schema.Boolean
4891
+ });
4892
+ /**
4893
+ * The moment a tracked PR's row was last shown to me, and what the row said.
4894
+ *
4895
+ * It is kept on its own rather than on `Facts`, because a quiet PR carries its
4896
+ * old facts forward unchanged and a moment stored there would be about the
4897
+ * wrong thing. What it holds is only what a row turns on, so a fact the sweep
4898
+ * starts reading later does not make every row look new.
4899
+ *
4900
+ * Only a command that puts the row on screen writes one. A sweep on its own
4901
+ * shows nothing, and moving the watermark there would erase movement nobody saw.
4902
+ */
4903
+ const Watermark = Schema.Struct({
4904
+ at: Schema.DateTimeUtcFromString,
4905
+ ...Said.fields
4906
+ });
4907
+ const keyOf = ({ facts }) => prKey(facts.repo, facts.number);
4908
+ /** What a row says. */
4909
+ const said = ({ facts, placement }) => ({
4910
+ bucket: placement.bucket,
4911
+ mergeable: facts.mergeable,
4912
+ checks: facts.checks,
4913
+ excused: facts.ciFlaky !== null,
4914
+ reviewDecision: facts.reviewDecision,
4915
+ blockingFindings: facts.blockingFindings,
4916
+ newestHumanCommentAt: facts.newestHumanCommentAt,
4917
+ draft: facts.draft
4918
+ });
4919
+ /** What a row said at the moment it was shown. */
4920
+ const sighted = (placed, at) => ({
4921
+ at,
4922
+ ...said(placed)
4923
+ });
4924
+ const ci = (it) => it.checks === "red" && it.excused ? "red, called flaky" : it.checks;
4925
+ const review$1 = {
4926
+ approved: "approved",
4927
+ "changes-requested": "changes requested",
4928
+ "review-required": "review required",
4929
+ none: "no review decision"
4930
+ };
4931
+ /**
4932
+ * The facts that moved between two sightings, said the way the row would say them.
4933
+ *
4934
+ * The head is left out on purpose: it changes on every push, and a list that
4935
+ * leads with it drowns what the push did. A mergeability GitHub has not worked
4936
+ * out yet is left out too, because it flickers to unknown and back on its own.
4937
+ */
4938
+ const moved = (then, now) => [
4939
+ then.mergeable !== now.mergeable && then.mergeable !== "unknown" && now.mergeable !== "unknown" ? `${then.mergeable} → ${now.mergeable}` : null,
4940
+ ci(then) !== ci(now) ? `CI ${ci(then)} → ${ci(now)}` : null,
4941
+ then.reviewDecision !== now.reviewDecision ? `${review$1[then.reviewDecision]} → ${review$1[now.reviewDecision]}` : null,
4942
+ then.blockingFindings !== now.blockingFindings ? `${then.blockingFindings} → ${now.blockingFindings} blocking findings` : null,
4943
+ isAfter(now.newestHumanCommentAt, then.newestHumanCommentAt) ? "a new comment" : null,
4944
+ then.draft !== now.draft ? now.draft ? "back to draft" : "out of draft" : null
4945
+ ].filter((it) => it !== null);
4946
+ /**
4947
+ * What happened to a row since `watermark`, the last time it was shown.
4948
+ *
4949
+ * Movement is a bucket transition plus the facts that moved with it. Either one
4950
+ * alone misses something: every field compared drowns in the head, and a bucket
4951
+ * on its own says nothing when CI goes green on a PR still mine for another
4952
+ * reason.
4953
+ */
4954
+ const since = (watermark, placed) => {
4955
+ if (watermark === void 0) return { _tag: "new" };
4956
+ const now = said(placed);
4957
+ const what = moved(watermark, now);
4958
+ const from = watermark.bucket === now.bucket ? void 0 : watermark.bucket;
4959
+ return from === void 0 && what.length === 0 ? { _tag: "still" } : {
4960
+ _tag: "moved",
4961
+ from,
4962
+ what
4963
+ };
4964
+ };
4965
+ /**
4966
+ * What happened to each of these rows since it was last shown.
4967
+ *
4968
+ * It answers for any row, and a row it was not given is one it has no
4969
+ * watermark for, which is a row never shown.
4970
+ */
4971
+ const sinceShown = Effect.fn("watermark.sinceShown")(function* (placed) {
4972
+ const store = yield* storeFor("watermarks", Watermark);
4973
+ const found = new Map(yield* Effect.forEach(placed, (it) => {
4974
+ const key = keyOf(it);
4975
+ return Effect.map(remembered(store.get(key)), (mark) => [key, since(Option.getOrUndefined(mark), it)]);
4976
+ }));
4977
+ return (it) => found.get(keyOf(it)) ?? { _tag: "new" };
4978
+ });
4979
+ /** Records that these rows were shown to me, now, saying what they say. */
4980
+ const markShown = Effect.fn("watermark.markShown")(function* (placed) {
4981
+ const store = yield* storeFor("watermarks", Watermark);
4982
+ const now = yield* DateTime.now;
4983
+ yield* Effect.forEach(placed, (it) => store.set(keyOf(it), sighted(it, now)), { discard: true });
4984
+ });
4985
+ //#endregion
4603
4986
  //#region src/cli/pick.ts
4604
4987
  /** A title cut this short says nothing, so a row that tight loses the column instead. */
4605
4988
  const shortest = 12;
@@ -4625,9 +5008,10 @@ const screenRoom = (screen, paint) => screen === 0 ? Number.POSITIVE_INFINITY :
4625
5008
  * The cells come from there rather than being built again here, so the list I
4626
5009
  * pick from and the table I read are the same rows with the bucket moved onto
4627
5010
  * 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.
5011
+ * on every row; the rows are still in the order the buckets are acted on. The
5012
+ * gutter in front says what moved since I last looked, as it does in the table.
4629
5013
  */
4630
- const cellsOf = ({ placed, stamped }, room, paint) => cells(placed, stamped, room, paint, "named");
5014
+ const cellsOf = ({ placed, stamped }, since, room, paint) => cells(placed, stamped, since, room, paint, "named");
4631
5015
  /**
4632
5016
  * Every tracked PR as something to pick, aligned down the whole list.
4633
5017
  *
@@ -4638,13 +5022,13 @@ const cellsOf = ({ placed, stamped }, room, paint) => cells(placed, stamped, roo
4638
5022
  * tells me less than the room it took. A screen too narrow for all four columns
4639
5023
  * loses the title's column rather than the reason's words.
4640
5024
  */
4641
- const choicesOf = (standings, screen, paint) => {
4642
- const measured = standings.map((it) => cellsOf(it, Number.POSITIVE_INFINITY, paint));
5025
+ const choicesOf = (standings, sinceOf, screen, paint) => {
5026
+ const measured = standings.map((it) => cellsOf(it, sinceOf(it.placed), Number.POSITIVE_INFINITY, paint));
4643
5027
  const widest = (index) => Math.max(...measured.map((row) => visible(row[index] ?? "")));
4644
5028
  const room = screenRoom(screen, paint) - (widest(0) + widest(1) + widest(3)) - 9;
4645
5029
  const told = room >= shortest;
4646
5030
  const rows = table(standings.map((it) => {
4647
- const row = cellsOf(it, told ? room : 0, paint);
5031
+ const row = cellsOf(it, sinceOf(it.placed), told ? room : 0, paint);
4648
5032
  return told ? row : [
4649
5033
  row[0] ?? "",
4650
5034
  row[1] ?? "",
@@ -4701,7 +5085,10 @@ const picker = (dispatch) => Effect.fn("pick")(function* () {
4701
5085
  yield* Console.log("No open pull requests.");
4702
5086
  return;
4703
5087
  }
4704
- const chosen = yield* pick("Which pull request?", choicesOf(standings, yield* width, yield* Paint));
5088
+ const shown = standings.map((it) => it.placed);
5089
+ const choices = choicesOf(standings, yield* sinceShown(shown), yield* width, yield* Paint);
5090
+ yield* markShown(shown);
5091
+ const chosen = yield* pick("Which pull request?", choices);
4705
5092
  if (Option.isNone(chosen)) return;
4706
5093
  const facts = chosen.value.placed.facts;
4707
5094
  const offer = yield* pick(`What do I do with ${where(facts)}?`, actionChoices(actionsFor(chosen.value)));
@@ -4761,28 +5148,26 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
4761
5148
  checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
4762
5149
  stack: stackOf(number, open)
4763
5150
  }));
4764
- const where = `${repo}#${number}`;
5151
+ const paint = yield* Paint;
4765
5152
  const done = yield* rebaseOnto(repo, number, view.baseRefName, view.headRefName);
4766
5153
  if (done._tag === "up-to-date") {
4767
- yield* Console.log(`${where} ${short(view.headRefOid)} already on ${view.baseRefName}`);
5154
+ yield* print([opener(paint, repo, number, view.headRefOid, `already on ${view.baseRefName}`)]);
4768
5155
  return;
4769
5156
  }
4770
5157
  if (done._tag === "conflicted") {
4771
5158
  yield* recordConflict(repo, number, view.headRefOid, done.paths);
4772
- yield* Console.log(`${where} ${short(view.headRefOid)} the rebase onto ${view.baseRefName} conflicted, so it was aborted and nothing was pushed.`);
4773
- if (done.paths.length > 0) {
4774
- yield* Console.log(`It stopped on ${count(done.paths.length, "file")}:`);
4775
- yield* Effect.forEach(done.paths, (path) => Console.log(` ${path}`));
4776
- }
4777
- yield* Effect.forEach([
4778
- ``,
4779
- ` dw-mc resolve ${number}`,
4780
- ``
4781
- ], (line) => Console.log(line));
4782
- yield* Console.log("That opens a session on the conflict, in a worktree of your own. The next sweep puts it in Needs me, and it stays there until the branch moves.");
5159
+ yield* print(separated([
5160
+ [opener(paint, repo, number, view.headRefOid, `the rebase onto ${view.baseRefName} conflicted, so it was aborted and nothing was pushed.`)],
5161
+ stoppedOn(paint, done.paths),
5162
+ retype(paint, `dw-mc resolve ${number}`),
5163
+ ["That opens a session on the conflict, in a worktree of your own. The next sweep puts it in Needs me, and it stays there until the branch moves."]
5164
+ ]));
4783
5165
  return;
4784
5166
  }
4785
- yield* Console.log(`${where} ${short(done.before)} ${short(done.after)} rebased ${count(done.behind, "commit")} of ${view.baseRefName} and pushed with a lease`);
5167
+ yield* print([opener(paint, repo, number, {
5168
+ before: done.before,
5169
+ after: done.after
5170
+ }, `rebased ${count(done.behind, "commit")} of ${view.baseRefName} and pushed with a lease`)]);
4786
5171
  }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
4787
5172
  //#endregion
4788
5173
  //#region src/cli/rerun.ts
@@ -4824,10 +5209,8 @@ const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(funct
4824
5209
  }));
4825
5210
  yield* recordRerun(repo, number, view.headRefOid);
4826
5211
  yield* Effect.forEach(unclassified.runs, (run) => rerunFailed(repo, run));
4827
- const where = `${repo}#${number}`;
4828
- yield* Console.log(`${where} ${short(view.headRefOid)} re-ran the failed jobs of ${count(unclassified.runs.length, "workflow run")}`);
4829
- yield* Console.log(`It is flaky because ${flaky}.`);
4830
- yield* Console.log(`This head gets no second re-run; if it fails again, the failure is yours.`);
5212
+ const paint = yield* Paint;
5213
+ yield* print(separated([[opener(paint, repo, number, view.headRefOid, `re-ran the failed jobs of ${count(unclassified.runs.length, "workflow run")}`)], [`It is flaky because ${flaky}.`, `This head gets no second re-run; if it fails again, the failure is yours.`]]));
4831
5214
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Run a flaky red CI again, once per head"));
4832
5215
  //#endregion
4833
5216
  //#region src/domain/resolve.ts
@@ -4868,7 +5251,7 @@ const Conflicted = Schema.Struct({
4868
5251
  paths: Schema.Array(Schema.String)
4869
5252
  });
4870
5253
  /** The conflict as the JSON the schema defines, rather than as this file spells it. */
4871
- const asJson = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
5254
+ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
4872
5255
  /**
4873
5256
  * The prompt a resolve session opens on: what stopped the replay, and the
4874
5257
  * conflict itself as JSON.
@@ -4884,7 +5267,7 @@ const asJson = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
4884
5267
  * after reading what it did, and a session that did them would be resolving the
4885
5268
  * conflict for me rather than with me.
4886
5269
  */
4887
- const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
5270
+ const promptFor = (conflicted) => Effect.map(asJson$1(conflicted), (json) => [
4888
5271
  `A dw-mc rebase of ${conflicted.repo}#${conflicted.number} onto ${conflicted.base} stopped on a conflict. You are in a worktree standing on the pull request's commits at ${short(conflicted.head)}, with that rebase in progress and the files below unmerged.`,
4889
5272
  `The pull request is "${conflicted.title}". Resolve each file so it keeps meaning that and keeps whatever ${conflicted.base} changed underneath it; where the two cannot both hold, say so and stop.`,
4890
5273
  "Do not run git rebase --continue, do not commit and do not push. I read the resolution and do all three myself.",
@@ -4916,7 +5299,7 @@ const printFlag = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withD
4916
5299
  const resolve = Command.make("resolve", {
4917
5300
  pr: prArgument,
4918
5301
  print: printFlag
4919
- }, Effect.fn("resolve")(function* ({ pr, print }) {
5302
+ }, Effect.fn("resolve")(function* ({ pr, print: promptOnly }) {
4920
5303
  const { number, repo, launcher } = yield* forPr(pr);
4921
5304
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
4922
5305
  prView(repo, number),
@@ -4943,43 +5326,34 @@ const resolve = Command.make("resolve", {
4943
5326
  title: view.title,
4944
5327
  paths
4945
5328
  });
4946
- if (print) {
5329
+ if (promptOnly) {
4947
5330
  yield* Console.log(yield* promptFor(conflicted(conflict?.paths ?? [])));
4948
5331
  return;
4949
5332
  }
4950
- const where = `${repo}#${number}`;
5333
+ const paint = yield* Paint;
4951
5334
  const worktree = yield* standingWorktree(repo, number, view.headRefName, "rebase");
4952
- yield* Console.log(`${where} ${short(view.headRefOid)} replaying onto ${view.baseRefName} in ${worktree.directory}`);
5335
+ yield* print([opener(paint, repo, number, view.headRefOid, `replaying onto ${view.baseRefName} in ${paint.dim(worktree.directory)}`)]);
5336
+ const reviewNext = `Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`;
4953
5337
  const stopped = yield* rebaseInPlace(worktree.directory, view.baseRefName);
4954
5338
  if (stopped._tag === "replayed") {
4955
- yield* Console.log("The replay went through, so there is nothing to resolve: git replayed a resolution you made before, or the conflict is gone.");
4956
- yield* Console.log(`The worktree stands where it replayed, and the push onto ${view.headRefName} is yours:`);
4957
- yield* Effect.forEach([
4958
- ``,
4959
- ` cd ${worktree.directory}`,
4960
- ` git push`,
4961
- ``
4962
- ], (line) => Console.log(line));
4963
- yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
5339
+ yield* print(following([
5340
+ ["The replay went through, so there is nothing to resolve: git replayed a resolution you made before, or the conflict is gone.", `The worktree stands where it replayed, and the push onto ${view.headRefName} is yours:`],
5341
+ retype(paint, `cd ${worktree.directory}`, `git push`),
5342
+ [reviewNext]
5343
+ ]));
4964
5344
  return;
4965
5345
  }
4966
- yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
4967
- yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
5346
+ yield* print(following([stoppedOn(paint, stopped.paths)]));
4968
5347
  const ended = yield* steeredSession({
4969
5348
  launcher,
4970
5349
  directory: worktree.directory,
4971
5350
  prompt: yield* promptFor(conflicted(stopped.paths))
4972
5351
  });
4973
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
4974
- yield* Console.log("Nothing was committed or pushed for you; the rebase stands where it stopped.");
4975
- yield* Effect.forEach([
4976
- ``,
4977
- ` cd ${worktree.directory}`,
4978
- ` git rebase --continue`,
4979
- ` git push`,
4980
- ``
4981
- ], (line) => Console.log(line));
4982
- yield* Console.log(`Once you have pushed, dw-mc review ${number} reviews the new head as a new run.`);
5352
+ yield* print(following([
5353
+ [ended === 0 ? "The session is over." : `The session ended with ${ended}.`, "Nothing was committed or pushed for you; the rebase stands where it stopped."],
5354
+ retype(paint, `cd ${worktree.directory}`, `git rebase --continue`, `git push`),
5355
+ [reviewNext]
5356
+ ]));
4983
5357
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
4984
5358
  //#endregion
4985
5359
  //#region src/adapters/notify.ts
@@ -5172,7 +5546,7 @@ const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
5172
5546
  tools: doing.tools + 1,
5173
5547
  subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
5174
5548
  };
5175
- return says(saying(doing), ` · ${tool}`);
5549
+ return says(saying(doing), indent(`· ${tool}`));
5176
5550
  }
5177
5551
  }));
5178
5552
  const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
@@ -5266,11 +5640,12 @@ const review = Command.make("review", {
5266
5640
  promptOnly,
5267
5641
  commandOnly
5268
5642
  });
5643
+ const paint = yield* Paint;
5269
5644
  const view = yield* prView(repo, number);
5270
- yield* Console.log(`${repo}#${number} ${view.title}`);
5645
+ yield* print([`${repo}#${number} ${paint.dim(view.title)}`]);
5271
5646
  const since = force ? null : skippedSince(yield* askedOf(repo, number, view.headRefOid), settings.review.docs_only);
5272
5647
  if (since !== null) {
5273
- yield* Console.log(` only documentation changed since ${short(since)}, so this run is skipped. Pass --force to review it anyway.`);
5648
+ yield* print([indent(`only documentation changed since ${paint.dim(short(since))}, so this run is skipped. Pass --force to review it anyway.`)]);
5274
5649
  return;
5275
5650
  }
5276
5651
  const about = {
@@ -5283,7 +5658,7 @@ const review = Command.make("review", {
5283
5658
  const turn = turnFor(asked, about);
5284
5659
  yield* Effect.gen(function* () {
5285
5660
  const ran = yield* withWorktree(repo, number, (worktree) => Effect.gen(function* () {
5286
- yield* Console.log(` head ${short(worktree.head)} ${spending(turn, asked.model)}`);
5661
+ yield* print([indent(`head ${paint.dim(short(worktree.head))} ${spending(turn, asked.model)}`)]);
5287
5662
  const got = yield* Effect.result(reviewOn({
5288
5663
  launcher,
5289
5664
  directory: worktree.directory,
@@ -5313,21 +5688,14 @@ const review = Command.make("review", {
5313
5688
  yield* runs.set(runKey(repo, number, run.head), run);
5314
5689
  yield* latest.set(latestKey(repo, number), { head: run.head });
5315
5690
  yield* reports.set(reportKey(repo, number, run.head), reportDocument(run, view.title, got.prose ?? ""));
5316
- yield* Console.log("");
5317
5691
  const detail = detailOf(run);
5318
- if (detail !== null) yield* Console.log(` reported nothing: ${detail}`);
5319
- else {
5320
- const found = reportedBy(run);
5321
- if (found !== null) {
5322
- if (got.prose !== null) {
5323
- yield* Console.log(got.prose);
5324
- yield* Console.log("");
5325
- }
5326
- yield* Console.log(summary(found, settings.stamp.blocks_on));
5327
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5328
- }
5329
- }
5330
- yield* Console.log(`Recorded against ${short(ran.head)} in ${yield* stateDirectory}`);
5692
+ const found = detail === null ? reportedBy(run) : null;
5693
+ yield* print(following([
5694
+ detail === null ? [] : [indent(`reported nothing: ${detail}`)],
5695
+ found === null || got.prose === null ? [] : [got.prose],
5696
+ found === null ? [] : block(summary(found, settings.stamp.blocks_on), lines$1(found, paint)),
5697
+ [`Recorded against ${paint.dim(short(ran.head))} in ${paint.dim(yield* stateDirectory)}`]
5698
+ ]));
5331
5699
  yield* unreported(run, number);
5332
5700
  }).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
5333
5701
  }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
@@ -5357,17 +5725,94 @@ const stampCommand = Command.make("stamp", {
5357
5725
  }, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
5358
5726
  const { number, repo } = yield* forPr(pr);
5359
5727
  const facts = yield* swept(repo, number);
5360
- const where = `${repo}#${number} ${short(facts.head)}`;
5728
+ const paint = yield* Paint;
5361
5729
  if (byHand) {
5362
5730
  yield* withdraw(repo, number, facts.head);
5363
- yield* Console.log(`${where} stamp withdrawn, until the head changes`);
5731
+ yield* print([opener(paint, repo, number, facts.head, "stamp withdrawn, until the head changes")]);
5364
5732
  return;
5365
5733
  }
5366
5734
  const stamp = yield* stampOf(facts);
5367
- yield* Console.log(`${where} ${stamp.stamped ? "stamped" : `not stamped: ${stamp.reason}`}`);
5735
+ yield* print([opener(paint, repo, number, facts.head, stamp.stamped ? "stamped" : `not stamped: ${stamp.reason}`)]);
5368
5736
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print my stamp on one pull request, or withdraw it by hand"));
5369
5737
  //#endregion
5370
5738
  //#region src/cli/status.ts
5739
+ /** What moved a row, as the JSON says it: `from` is null where the row kept its bucket. */
5740
+ const SinceJson = Schema.Union([
5741
+ Schema.TaggedStruct("new", {}),
5742
+ Schema.TaggedStruct("still", {}),
5743
+ Schema.TaggedStruct("moved", {
5744
+ from: Schema.NullOr(Bucket),
5745
+ what: Schema.Array(Schema.String)
5746
+ })
5747
+ ]);
5748
+ /**
5749
+ * One pass of `dw-mc status`, as the JSON a machine reads it in.
5750
+ *
5751
+ * A row carries every fact flat beside its placement, so `jq` reaches
5752
+ * `.prs[].checks` without unwrapping anything. What the table says in lines
5753
+ * after it - what the pass could not read, and how many registered
5754
+ * repositories it left out - is here as well, because a document without them
5755
+ * reads as the whole picture when it is not.
5756
+ */
5757
+ const StatusJson = Schema.Struct({
5758
+ /** When the pass ended: status sweeps on every run. */
5759
+ sweptAt: Schema.DateTimeUtcFromString,
5760
+ repos: Schema.Array(Schema.String),
5761
+ leftOut: Schema.Int,
5762
+ prs: Schema.Array(Schema.Struct({
5763
+ bucket: Bucket,
5764
+ reason: Schema.String,
5765
+ stamped: Schema.Boolean,
5766
+ since: SinceJson,
5767
+ ...Facts.fields
5768
+ })),
5769
+ troubles: Schema.Array(Schema.Struct({
5770
+ where: Schema.String,
5771
+ detail: Schema.String
5772
+ }))
5773
+ });
5774
+ const asJson = Schema.encodeEffect(Schema.fromJsonString(StatusJson));
5775
+ const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print the pass as JSON, for jq or an agent session, and leave what I last looked at alone"));
5776
+ const sinceJson = (since) => since._tag === "moved" ? {
5777
+ _tag: "moved",
5778
+ from: since.from ?? null,
5779
+ what: since.what
5780
+ } : since;
5781
+ /**
5782
+ * The pass as one JSON document, and nothing else on stdout.
5783
+ *
5784
+ * It sweeps without a heartbeat, because what reads this is a pipe or a
5785
+ * session, and a line drawn over the document is a document broken. What moved
5786
+ * is read and never written: a machine reading the pass is not me looking, so
5787
+ * the watermark stays where the last table left it.
5788
+ */
5789
+ const printJson = Effect.fn("status.json")(function* (asked) {
5790
+ const report = yield* sweep(asked, () => Effect.void);
5791
+ const sweptAt = yield* DateTime.now;
5792
+ const placed = group(report.facts).flatMap((it) => it.placed);
5793
+ const stamped = yield* stampedAmong(report.facts);
5794
+ const sinceOf = yield* sinceShown(placed);
5795
+ yield* Console.log(yield* asJson({
5796
+ sweptAt,
5797
+ repos: report.repos,
5798
+ leftOut: report.leftOut,
5799
+ prs: placed.map((it) => ({
5800
+ bucket: it.placement.bucket,
5801
+ reason: it.placement.reason,
5802
+ stamped: stamped.has(prKey(it.facts.repo, it.facts.number)),
5803
+ since: sinceJson(sinceOf(it)),
5804
+ ...it.facts
5805
+ })),
5806
+ troubles: report.troubles
5807
+ }));
5808
+ });
5809
+ /** What moved a row, said on its own line above the group it now sits in. */
5810
+ const movement = (placed, since) => {
5811
+ if (since._tag !== "moved") return [];
5812
+ const from = since.from === void 0 ? "" : ` from ${heading[since.from]}`;
5813
+ const what = since.what.length === 0 ? "" : `: ${since.what.join(", ")}`;
5814
+ return [` ↳ ${reference(placed.facts)}${from}${what}`];
5815
+ };
5371
5816
  /**
5372
5817
  * Every tracked PR under the bucket it sits in, in the order I act on them.
5373
5818
  *
@@ -5375,9 +5820,12 @@ const stampCommand = Command.make("stamp", {
5375
5820
  * the whole table rather than restarting under each heading, and they are ruled
5376
5821
  * apart: three columns of prose run into one another without a rule, and the
5377
5822
  * middle one is a commit subject that can end in anything.
5823
+ *
5824
+ * What moved since I last looked is marked in the gutter the rows are indented
5825
+ * by, so a mark costs no column and a row with none reads as it always did.
5378
5826
  */
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);
5827
+ const lines = (grouped, stamped, sinceOf, paint) => {
5828
+ 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
5829
  let taken = 0;
5382
5830
  return grouped.flatMap((it, index) => {
5383
5831
  const mine = rows.slice(taken, taken + it.placed.length);
@@ -5385,7 +5833,8 @@ const lines = (grouped, stamped, paint) => {
5385
5833
  return [
5386
5834
  ...index === 0 ? [] : [""],
5387
5835
  heading[it.bucket],
5388
- ...mine.map((row) => ` ${row}`)
5836
+ ...it.placed.flatMap((placed) => movement(placed, sinceOf(placed))),
5837
+ ...mine
5389
5838
  ];
5390
5839
  });
5391
5840
  };
@@ -5393,11 +5842,17 @@ const lines = (grouped, stamped, paint) => {
5393
5842
  * The table of what every tracked PR waits on.
5394
5843
  *
5395
5844
  * It sweeps first, every time: a table I read is never one I forgot to refresh.
5845
+ * `--json` prints the same pass for a machine.
5396
5846
  */
5397
5847
  const status = Command.make("status", {
5398
5848
  repo: repoFlag,
5399
- all: allFlag
5849
+ all: allFlag,
5850
+ json: jsonFlag
5400
5851
  }, Effect.fn("status")(function* (flags) {
5852
+ if (flags.json) {
5853
+ yield* printJson(askedOf$1(flags));
5854
+ return;
5855
+ }
5401
5856
  const report = yield* sweeping(askedOf$1(flags));
5402
5857
  if (report.repos.length === 0) {
5403
5858
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
@@ -5405,20 +5860,17 @@ const status = Command.make("status", {
5405
5860
  }
5406
5861
  const grouped = group(report.facts);
5407
5862
  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);
5863
+ const shown = grouped.flatMap((it) => it.placed);
5864
+ for (const line of lines(grouped, yield* stampedAmong(report.facts), yield* sinceShown(shown), yield* Paint)) yield* Console.log(line);
5409
5865
  yield* printLeftOut(report);
5410
5866
  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"));
5867
+ yield* markShown(shown);
5868
+ }, 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
5869
  //#endregion
5413
5870
  //#region src/cli/uninstall.ts
5414
5871
  const configFlag = Flag.Boolean("config").pipe(Flag.withDefault(false), Flag.withDescription("Take the configuration file too, and not only the state"));
5415
5872
  const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Remove a session worktree that still holds work of mine"));
5416
- const block = (heading, rows) => rows.length === 0 ? [] : [
5417
- heading,
5418
- ...table(rows).map((line) => ` ${line}`),
5419
- ""
5420
- ];
5421
- const removes = (state, config, paint) => block("Removes", [[
5873
+ const removes = (state, config, paint) => block("Removes", table([[
5422
5874
  paint.dim(state.directory),
5423
5875
  state.size,
5424
5876
  "every record, report, clone and worktree"
@@ -5426,8 +5878,8 @@ const removes = (state, config, paint) => block("Removes", [[
5426
5878
  paint.dim(config),
5427
5879
  "",
5428
5880
  "the runner and every repository registered"
5429
- ]]]);
5430
- const held = (holds, paint) => block("Holds work of mine", holds.map((it) => [paint.dim(it.at.directory), it.detail]));
5881
+ ]]]));
5882
+ const held = (holds, paint) => holds.length === 0 ? [] : block("Holds work of mine", table(holds.map((it) => [paint.dim(it.at.directory), it.detail])));
5431
5883
  /**
5432
5884
  * Takes the tool's own footprint off this machine, which no package manager
5433
5885
  * does.
@@ -5458,20 +5910,17 @@ const uninstall = Command.make("uninstall", {
5458
5910
  const found = yield* inventory;
5459
5911
  const file = yield* configPath;
5460
5912
  const configured = yield* fs.exists(file);
5461
- const holds = yield* Effect.forEach(standing(found), (at) => Effect.map(holding(at.repo, at.number, at.session), (holding_) => holding_._tag === "held" ? [{
5913
+ const holds = yield* Effect.forEach(standing$1(found), (at) => Effect.map(holding(at.repo, at.number, at.session), (holding_) => holding_._tag === "held" ? [{
5462
5914
  at,
5463
5915
  detail: holding_.detail
5464
5916
  }] : [])).pipe(Effect.map((found_) => found_.flat()));
5465
- yield* Effect.forEach(removes({
5917
+ yield* print([...separated([removes({
5466
5918
  directory: found.directory,
5467
5919
  size: weight(everything$1(found))
5468
- }, alsoConfig && configured ? file : void 0, paint), (line) => Console.log(line));
5469
- if (holds.length > 0) {
5470
- yield* Effect.forEach(held(holds, paint), (line) => Console.log(line));
5471
- if (!force) {
5472
- yield* Console.log("Nothing was removed. Push that work or drop it, or run this again with --force.");
5473
- return;
5474
- }
5920
+ }, alsoConfig && configured ? file : void 0, paint), held(holds, paint)]), ""]);
5921
+ if (holds.length > 0 && !force) {
5922
+ yield* Console.log("Nothing was removed. Push that work or drop it, or run this again with --force.");
5923
+ return;
5475
5924
  }
5476
5925
  if (!yes && !(yield* confirm("Remove it all?"))) {
5477
5926
  yield* Console.log("Nothing was removed.");
@@ -5491,7 +5940,7 @@ const uninstall = Command.make("uninstall", {
5491
5940
  * Running from source leaves the constant undeclared rather than undefined, so
5492
5941
  * the check has to be `typeof` and the fallback is what a test reads.
5493
5942
  */
5494
- const version = "0.6.0";
5943
+ const version = "0.8.0";
5495
5944
  /** Where the project lives, printed beside the version in the header. */
5496
5945
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5497
5946
  const subcommands = [
@@ -5504,6 +5953,7 @@ const subcommands = [
5504
5953
  rerun,
5505
5954
  resolve,
5506
5955
  merge,
5956
+ forgetCommand,
5507
5957
  sweepCommand,
5508
5958
  status,
5509
5959
  stampCommand,
@@ -5548,18 +5998,41 @@ const header = (colors) => {
5548
5998
  return meta === "" ? paint.cyan(line) : `${paint.cyan(line.padEnd(width))}${gap}${paint.dim(meta)}`;
5549
5999
  }).join("\n");
5550
6000
  };
6001
+ /** A path under my home, the way I would type it. */
6002
+ const tilde = (path, home) => home !== void 0 && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
6003
+ /**
6004
+ * What the configuration file says of this machine, in the few words a help
6005
+ * screen has room for.
6006
+ *
6007
+ * A file that cannot be read keeps the help screen standing and says only that
6008
+ * it cannot: the reason is ADR 0010's, and every other command prints it.
6009
+ */
6010
+ const standing = read.pipe(Effect.map(Option.match({
6011
+ onNone: () => "not set up - run dw-mc init",
6012
+ onSome: (file) => {
6013
+ const n = Object.keys(file.repos ?? {}).length;
6014
+ return n === 1 ? "1 repository registered" : `${n} repositories registered`;
6015
+ }
6016
+ })), Effect.catchTag("ConfigMalformed", () => Effect.succeed("cannot be read - any other command says why")));
6017
+ /**
6018
+ * The two lines naming this machine's setup, with the labels `dw-mc init`
6019
+ * prints. The paths are dimmed as context, and nothing is coloured by state.
6020
+ */
6021
+ const described = (setup, registered, paint) => [`config ${paint.dim(setup.config)} ${registered}`, `state ${paint.dim(setup.state)}`];
5551
6022
  /**
5552
6023
  * The formatter for the two screens the tool introduces itself on, with the
5553
- * header above what the default formatter draws.
6024
+ * header above what the default formatter draws, and on the help screen the
6025
+ * lines naming this machine's setup under it.
5554
6026
  *
5555
6027
  * The root command is the one whose help document lists subcommands, which is
5556
6028
  * what keeps the header off `dw-mc status --help`.
5557
6029
  */
5558
- const formatter = (colors) => {
6030
+ const formatter = (colors, setupLines) => {
5559
6031
  const inner = CliOutput.defaultFormatter({ colors });
5560
6032
  const drawn = header(colors);
6033
+ const withSetup = setupLines.length === 0 ? drawn : `${drawn}\n\n${setupLines.join("\n")}`;
5561
6034
  return {
5562
- formatHelpDoc: (doc) => doc.subcommands === void 0 ? inner.formatHelpDoc(doc) : `${drawn}\n\n${inner.formatHelpDoc(doc)}`,
6035
+ formatHelpDoc: (doc) => doc.subcommands === void 0 ? inner.formatHelpDoc(doc) : `${withSetup}\n\n${inner.formatHelpDoc(doc)}`,
5563
6036
  formatVersion: (name, printed) => `${drawn}\n\n${inner.formatVersion(name, printed)}`,
5564
6037
  formatCliError: inner.formatCliError,
5565
6038
  formatError: inner.formatError,
@@ -5572,17 +6045,30 @@ const formatter = (colors) => {
5572
6045
  * It replaces the formatter under `--help` and `--version` rather than for the
5573
6046
  * run, because a failed parse prints the help screen through the same
5574
6047
  * formatter: decorating that one would bury the error under a logo.
6048
+ *
6049
+ * The paths are worked out as the layer is built, from the environment alone.
6050
+ * The configuration file is read only once `--help` is asked for, because no
6051
+ * other run of the tool prints what it says here. A store that fails outright
6052
+ * leaves the lines off rather than the help screen.
5575
6053
  */
5576
- const layer = Layer.unwrap(Effect.map(screened, (colors) => {
5577
- const introducing = Effect.provideService(CliOutput.Formatter, formatter(colors));
5578
- return CliConfig.layer({ builtIns: CliConfig.defaults.builtIns.map((builtIn) => builtIn === GlobalFlag.Help || builtIn === GlobalFlag.Version ? GlobalFlag.Action({
6054
+ const layer = Layer.unwrap(Effect.gen(function* () {
6055
+ const colors = yield* screened;
6056
+ const config = yield* ConfigStore;
6057
+ const home = Option.getOrUndefined(yield* Config.String("HOME").pipe(Config.option));
6058
+ const setup = {
6059
+ config: tilde(config.path, home),
6060
+ state: tilde(yield* stateDirectory, home)
6061
+ };
6062
+ const setupLines = Effect.provideService(standing, ConfigStore, config).pipe(Effect.map((said) => described(setup, said, paintFor(colors))), Effect.orElseSucceed(() => []));
6063
+ const introducing = (builtIn, lines) => GlobalFlag.Action({
5579
6064
  flag: builtIn.flag,
5580
- run: (value, context) => introducing(builtIn.run(value, context))
5581
- }) : builtIn) });
6065
+ run: (value, context) => Effect.flatMap(lines, (it) => Effect.provideService(builtIn.run(value, context), CliOutput.Formatter, formatter(colors, it)))
6066
+ });
6067
+ return CliConfig.layer({ builtIns: CliConfig.defaults.builtIns.map((builtIn) => builtIn === GlobalFlag.Help ? introducing(GlobalFlag.Help, setupLines) : builtIn === GlobalFlag.Version ? introducing(GlobalFlag.Version, Effect.succeed([])) : builtIn) });
5582
6068
  }));
5583
6069
  //#endregion
5584
6070
  //#region src/cli/bin.ts
5585
- dwMc.pipe(Command.run({ version }), Effect.provide(Layer.provideMerge(Layer.mergeAll(ConfigStore.layer, layer$1, layer, layer$2), NodeServices.layer)), NodeRuntime.runMain);
6071
+ dwMc.pipe(Command.run({ version }), Effect.provide(Layer.provideMerge(Layer.mergeAll(Layer.provideMerge(layer, ConfigStore.layer), layer$1, layer$2), NodeServices.layer)), NodeRuntime.runMain);
5586
6072
  //#endregion
5587
6073
  export {};
5588
6074