dw-mc 0.7.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
@@ -1554,1109 +1554,1151 @@ const truncate = (text, width) => {
1554
1554
  /** `n` of something, pluralised the one way English usually is. */
1555
1555
  const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
1556
1556
  //#endregion
1557
- //#region src/domain/cleanup.ts
1558
- /** The checkouts that stand for a session, named by the session they stand for. */
1559
- const standing = (inventory) => inventory.cuttings.flatMap((cutting) => {
1560
- const session = sessionOf(cutting.cut);
1561
- return session === void 0 ? [] : [{
1562
- ...cutting,
1563
- session
1564
- }];
1565
- });
1566
- /** The checkouts a review run cut, which no run that ended still needs. */
1567
- const orphaned = (inventory) => inventory.cuttings.filter((cutting) => cutting.cut === "worktrees");
1568
- const sum = (sizes) => ByteSize.bytes(sizes.reduce((total, size) => total + ByteSize.toBigInt(size), BigInt(0)));
1569
- /** What the glossary calls the session a standing checkout was cut for. */
1570
- const sessionName = (session) => session === "fix" ? "fix" : "resolve";
1571
- const reason = (sessions) => sessions.map((it) => `a ${sessionName(it.session)} session stands on ${it.repo}#${it.number}`).join(", ");
1557
+ //#region src/domain/findings.ts
1558
+ /** Whether a review run found anything at all. */
1559
+ const Verdict = Schema.Literals(["clean", "findings"]);
1572
1560
  /**
1573
- * What a cleanup would take, weighed.
1561
+ * Every severity word a review may answer with.
1574
1562
  *
1575
- * A clone whose repository has a session standing on it stays, and that is not
1576
- * politeness: a standing worktree keeps its history inside the clone, so a
1577
- * clone removed from under one leaves a directory of files with nothing behind
1578
- * them. The worktrees of that session stay with it; the review run's own go
1579
- * 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.
1580
1567
  */
1581
- const plan = (inventory) => {
1582
- const sessions = standing(inventory);
1583
- const worktrees = orphaned(inventory);
1584
- const held = /* @__PURE__ */ new Map();
1585
- for (const session of sessions) held.set(session.repo, [...held.get(session.repo) ?? [], session]);
1586
- const clones = inventory.clones.filter((clone) => !held.has(clone.repo));
1587
- return {
1588
- clones,
1589
- worktrees,
1590
- kept: inventory.clones.flatMap((clone) => {
1591
- const sessionsHere = held.get(clone.repo);
1592
- return sessionsHere === void 0 ? [] : [{
1593
- clone,
1594
- because: reason(sessionsHere)
1595
- }];
1596
- }),
1597
- size: sum([...clones, ...worktrees].map((it) => it.size))
1598
- };
1599
- };
1600
- /** What the whole state directory weighs: the clones, the checkouts and the records. */
1601
- const everything$1 = (inventory) => sum([
1602
- ...inventory.clones.map((it) => it.size),
1603
- ...inventory.cuttings.map((it) => it.size),
1604
- inventory.records.size
1568
+ const Spelling = Schema.Literals([
1569
+ "error",
1570
+ "warning",
1571
+ "info",
1572
+ "Critical",
1573
+ "Required",
1574
+ "Optional",
1575
+ "Nit",
1576
+ "FYI"
1605
1577
  ]);
1606
- /** Whether a plan has anything to do at all. */
1607
- const empty = (it) => it.clones.length === 0 && it.worktrees.length === 0;
1608
- /** A size as a line says it: three digits at most, and the unit the terminal reads. */
1609
- const weight = (size) => ByteSize.format(size, {
1610
- system: "decimal",
1611
- 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
1612
1603
  });
1613
- //#endregion
1614
- //#region src/cli/cleanup.ts
1615
- 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"));
1616
- /** A path said as the state directory's own, which is the heading it sits under. */
1617
- const inside = (path, state, directory) => path.relative(state, directory);
1618
1604
  /**
1619
- * 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.
1620
1615
  *
1621
- * The weight is on every row because the whole question is whether this is
1622
- * worth doing, and the reason is on every row because a clone and a worktree
1623
- * are taken back for different reasons and both read as "a directory of mine"
1624
- * 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.
1625
1618
  */
1626
- const lines$3 = (it, state, path, paint) => {
1627
- const taking = table([...it.worktrees.map((worktree) => [
1628
- paint.dim(inside(path, state, worktree.directory)),
1629
- weight(worktree.size),
1630
- "a review worktree a run left behind"
1631
- ]), ...it.clones.map((clone) => [
1632
- paint.dim(inside(path, state, clone.directory)),
1633
- weight(clone.size),
1634
- "a bare clone, cloned again on the next run"
1635
- ])]);
1636
- const staying = table(it.kept.map((kept) => [
1637
- paint.dim(inside(path, state, kept.clone.directory)),
1638
- weight(kept.clone.size),
1639
- kept.because
1640
- ]));
1641
- return [
1642
- "Takes back",
1643
- ...taking.map((line) => ` ${line}`),
1644
- "",
1645
- ...staying.length === 0 ? [] : [
1646
- "Stays",
1647
- ...staying.map((line) => ` ${line}`),
1648
- ""
1649
- ]
1650
- ];
1651
- };
1619
+ const Reported = Schema.Struct({
1620
+ verdict: Verdict,
1621
+ findings: Schema.Array(Schema.Struct({
1622
+ ...shared,
1623
+ severity: Weighed
1624
+ }))
1625
+ });
1652
1626
  /**
1653
- * 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.
1654
1628
  *
1655
- * What it removes is what the tool builds again by itself: the bare clones and
1656
- * the worktrees a review run cut. What it never removes is what I decided - the
1657
- * configuration file - and what I worked in - the worktree of a fix or resolve
1658
- * session, which stands on a branch of the tool's own and holds what I
1659
- * committed there. Forgetting a pull request's records is a different question
1660
- * with a different answer (`dw-mc forget`), 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.
1661
1638
  *
1662
- * A clone with a session standing on it stays with the session: a standing
1663
- * worktree keeps its history inside the clone, so a clone taken from under one
1664
- * would leave a directory of files with nothing behind them. The clones that
1665
- * stay are pruned instead, because a worktree directory removed under `git`
1666
- * leaves the clone's record of it behind and the next session cut at that path
1667
- * 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.
1668
1642
  */
1669
- const cleanup = Command.make("cleanup", { yes: yesFlag }, Effect.fn("cleanup")(function* ({ yes }) {
1670
- const path = yield* Path.Path;
1671
- const paint = yield* Paint;
1672
- const found = yield* beating((since) => `measuring the state directory · ${since}`, () => inventory);
1673
- const it = plan(found);
1674
- if (empty(it)) {
1675
- yield* Console.log(`Nothing to take back in ${found.directory}.`);
1676
- return;
1677
- }
1678
- yield* Effect.forEach(lines$3(it, found.directory, path, paint), (line) => Console.log(line));
1679
- if (!yes && !(yield* confirm(`Take back ${weight(it.size)}?`))) {
1680
- yield* Console.log("Nothing was removed.");
1681
- return;
1682
- }
1683
- yield* Effect.forEach([...it.worktrees, ...it.clones], (taken) => Effect.andThen(discard(taken.directory), tidy(taken.directory, found.directory)));
1684
- yield* Effect.forEach(it.kept, (kept) => prune(kept.clone.directory));
1685
- yield* Console.log(`Took back ${weight(it.size)}.`);
1686
- })).pipe(Command.withDescription("Take back the disk the tool spent on clones and review worktrees"));
1687
- //#endregion
1688
- //#region src/adapters/gh.ts
1689
- /** `gh` is on the machine but would not run. */
1690
- var GhUnavailable = class extends Schema.TaggedError()("GhUnavailable", { detail: Schema.String }) {
1691
- get message() {
1692
- return `gh could not be run: ${this.detail}\nInstall it from https://cli.github.com, then run 'gh auth login'.`;
1693
- }
1694
- };
1695
- /** `gh` runs but is not logged in, so every read of GitHub would fail. */
1696
- var GhUnauthenticated = class extends Schema.TaggedError()("GhUnauthenticated", { detail: Schema.String }) {
1697
- get message() {
1698
- return `gh is not authenticated. Run 'gh auth login'.\n${this.detail}`;
1699
- }
1700
- };
1701
- /** The working directory is not inside a repository `gh` can name. */
1702
- var NoRepository = class extends Schema.TaggedError()("NoRepository", { detail: Schema.String }) {
1703
- get message() {
1704
- return `This directory is not a GitHub repository dw-mc can register.\n${this.detail}`;
1705
- }
1706
- };
1707
- /** `gh` answered, in a shape this version of dw-mc does not know. */
1708
- var GhUnreadable = class extends Schema.TaggedError()("GhUnreadable", {
1709
- command: Schema.String,
1710
- reason: Schema.String
1711
- }) {
1712
- get message() {
1713
- return `gh ${this.command} answered with something dw-mc cannot read: ${this.reason}`;
1714
- }
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
1715
1649
  };
1716
- /** What a `gh` that would not even start comes to. */
1717
- const unavailable = (error) => new GhUnavailable({ detail: error.reason._tag === "NotFound" ? "it is not installed" : error.message });
1718
1650
  /**
1719
- * Stops unless `gh` is installed and logged in.
1651
+ * The findings that withhold the stamp: everything at `blocksOn` or above it.
1720
1652
  *
1721
- * Every read of GitHub goes through `gh` as me, so a missing or logged-out `gh`
1722
- * 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.
1723
1656
  */
1724
- const requireAuth = capture("gh", ["auth", "status"]).pipe(Effect.asVoid, Effect.catchTags({
1725
- PlatformError: (error) => Effect.fail(unavailable(error)),
1726
- CommandFailed: (error) => Effect.fail(new GhUnauthenticated({ detail: error.stderr }))
1727
- }), Effect.withSpan("gh.requireAuth"));
1728
- const RepoView = Schema.fromJsonString(Schema.Struct({ nameWithOwner: Schema.String }));
1729
- /** The `owner/repo` of the repository the working directory is in. */
1730
- const currentRepo = Effect.gen(function* () {
1731
- const json = yield* capture("gh", [
1732
- "repo",
1733
- "view",
1734
- "--json",
1735
- "nameWithOwner"
1736
- ]).pipe(Effect.catchTags({
1737
- PlatformError: (error) => Effect.fail(unavailable(error)),
1738
- CommandFailed: (error) => Effect.fail(new NoRepository({ detail: error.stderr }))
1739
- }));
1740
- return (yield* Schema.decodeEffect(RepoView)(json).pipe(Effect.mapError((error) => new GhUnreadable({
1741
- command: "repo view",
1742
- reason: error.message
1743
- })))).nameWithOwner;
1744
- }).pipe(Effect.withSpan("gh.currentRepo"));
1745
- /** A call to GitHub that `gh` itself refused, whether it was reading or writing. */
1746
- var GhReadFailed = class extends Schema.TaggedError()("GhReadFailed", {
1747
- command: Schema.String,
1748
- detail: Schema.String
1749
- }) {
1750
- get message() {
1751
- return `gh ${this.command} failed: ${this.detail}`;
1752
- }
1753
- };
1754
- /** One `gh` read, decoded, with every way it can go wrong in our words. */
1755
- const readJson = (label, command, args, schema) => capture(command, args).pipe(Effect.catchTags({
1756
- PlatformError: (error) => Effect.fail(unavailable(error)),
1757
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
1758
- command: label,
1759
- detail: error.stderr
1760
- }))
1761
- }), Effect.flatMap((json) => Schema.decodeEffect(schema)(json).pipe(Effect.mapError((error) => new GhUnreadable({
1762
- command: label,
1763
- reason: error.message
1764
- })))), Effect.withSpan(`gh.${label}`));
1765
- /** The login `gh` is authenticated as: the "me" every read is scoped to. */
1766
- const viewer = readJson("api user", "gh", ["api", "user"], Schema.fromJsonString(Schema.Struct({ login: Schema.String }))).pipe(Effect.map((user) => user.login));
1767
- const SearchResults = Schema.fromJsonString(Schema.Array(Schema.Struct({
1768
- number: Schema.Int,
1769
- repository: Schema.Struct({ nameWithOwner: Schema.String })
1770
- })));
1657
+ const blocking = (findings, blocksOn) => findings.filter((finding) => rank[finding.severity] >= rank[blocksOn]);
1658
+ //#endregion
1659
+ //#region src/domain/review.ts
1771
1660
  /**
1772
- * The open pull requests I authored in `repo`.
1661
+ * What a review run came to, which is what its second turn reported.
1773
1662
  *
1774
- * One search per repository rather than one for all of them: a repository `gh`
1775
- * 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.
1776
1666
  */
1777
- const searchPrs = Effect.fnUntraced(function* (repo) {
1778
- return (yield* readJson("search prs", "gh", [
1779
- "search",
1780
- "prs",
1781
- "--author=@me",
1782
- "--state=open",
1783
- "--repo",
1784
- repo,
1785
- "--limit",
1786
- "100",
1787
- "--json",
1788
- "number,repository"
1789
- ], SearchResults)).map((it) => ({
1790
- repo: it.repository.nameWithOwner,
1791
- number: it.number
1792
- }));
1793
- });
1667
+ const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
1668
+ verdict: Verdict,
1669
+ findings: Schema.Array(Finding)
1670
+ }), Schema.TaggedStruct("failed", { detail: Schema.String })]);
1794
1671
  /**
1795
- * One entry of a PR's status check rollup.
1672
+ * One review run against a tracked PR at a specific head commit.
1796
1673
  *
1797
- * A rollup mixes two shapes: a `CheckRun` reports a `status` and a `conclusion`,
1798
- * a `StatusContext` an overall `state`. Every field is optional because which
1799
- * 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.
1800
1677
  */
1801
- const CheckEntry = Schema.Struct({
1802
- name: Schema.optionalKey(Schema.String),
1803
- context: Schema.optionalKey(Schema.String),
1804
- status: Schema.optionalKey(Schema.String),
1805
- conclusion: Schema.optionalKey(Schema.String),
1806
- state: Schema.optionalKey(Schema.String),
1807
- /** The workflow the check runs in. A commit status belongs to no workflow. */
1808
- workflowName: Schema.optionalKey(Schema.String),
1809
- /** Where the check reports, which is the only place its job id appears. */
1810
- detailsUrl: Schema.optionalKey(Schema.String)
1811
- });
1812
- const PrView = Schema.fromJsonString(Schema.Struct({
1678
+ const ReviewRun = Schema.Struct({
1679
+ repo: Schema.String,
1813
1680
  number: Schema.Int,
1814
- title: Schema.String,
1815
- url: Schema.String,
1816
- isDraft: Schema.Boolean,
1817
- headRefOid: Schema.String,
1818
- headRefName: Schema.String,
1819
- baseRefName: Schema.String,
1820
- /** Who opened it, which is what says whether its branch is mine to push to. */
1821
- author: Schema.NullOr(Schema.Struct({ login: Schema.String })),
1822
- /** Whether the head branch lives in a fork rather than in this repository. */
1823
- isCrossRepository: Schema.Boolean,
1824
- mergeable: Schema.String,
1825
- reviewDecision: Schema.String,
1826
- statusCheckRollup: Schema.NullOr(Schema.Array(CheckEntry))
1827
- }));
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);
1828
1702
  /**
1829
- * The fields one `gh pr view` asks for, named so a test can spell the vector it
1830
- * expects without copying the list and watching it drift.
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.
1831
1705
  */
1832
- 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`;
1833
1709
  /**
1834
- * Everything about one pull request that arrives without paging through it:
1835
- * 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.
1836
1717
  */
1837
- const prView = Effect.fnUntraced(function* (repo, number) {
1838
- return yield* readJson("pr view", "gh", [
1839
- "pr",
1840
- "view",
1841
- String(number),
1842
- "--repo",
1843
- repo,
1844
- "--json",
1845
- viewFields
1846
- ], PrView);
1847
- });
1848
- const OpenPrs = Schema.fromJsonString(Schema.Array(Schema.Struct({
1849
- number: Schema.Int,
1850
- headRefName: Schema.String,
1851
- baseRefName: Schema.String
1852
- })));
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`;
1853
1721
  /**
1854
- * Every open pull request on a repository, by branch.
1722
+ * The run at one head, or none where nothing has reviewed it.
1855
1723
  *
1856
- * Everyone's and not only mine: a stack is recognised from branches built on
1857
- * 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.
1858
1727
  *
1859
- * The page is deep because a pull request this misses is one that looks like it
1860
- * is in no stack, and a stack the tool cannot see is one it could drive.
1728
+ * Forgetting a run costs one review.
1861
1729
  */
1862
- const openPrs = Effect.fnUntraced(function* (repo) {
1863
- return (yield* readJson("pr list", "gh", [
1864
- "pr",
1865
- "list",
1866
- "--repo",
1867
- repo,
1868
- "--state",
1869
- "open",
1870
- "--limit",
1871
- "500",
1872
- "--json",
1873
- "number,headRefName,baseRefName"
1874
- ], OpenPrs)).map((it) => ({
1875
- number: it.number,
1876
- head: it.headRefName,
1877
- base: it.baseRefName
1878
- }));
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);
1879
1739
  });
1880
- const Comments = Schema.fromJsonString(Schema.Array(Schema.Struct({
1881
- created_at: Schema.DateTimeUtcFromString,
1882
- user: Schema.NullOr(Schema.Struct({
1883
- login: Schema.String,
1884
- type: Schema.String
1885
- }))
1886
- })));
1887
- const comments$1 = (label, path) => readJson(label, "gh", ["api", path], Comments).pipe(Effect.map((all) => all.flatMap((comment) => comment.user === null ? [] : [{
1888
- login: comment.user.login,
1889
- bot: comment.user.type === "Bot",
1890
- at: comment.created_at
1891
- }])));
1892
1740
  /**
1893
- * Every comment on a pull request: the ones on the conversation and the ones
1894
- * left on the diff.
1741
+ * What a run reported, or null where it reported nothing at all.
1895
1742
  *
1896
- * REST is what says whether an author is a person or an app - `gh pr view`
1897
- * reports a bot's login with no sign that it is one - and the bucket rules turn
1898
- * on exactly that. Verified by running both: the endpoints ignore `direction`,
1899
- * so a page is asked for at its maximum and the newest comment is picked out of
1900
- * 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.
1901
1747
  */
1902
- const prComments = Effect.fnUntraced(function* (repo, number) {
1903
- const page = "per_page=100";
1904
- 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 });
1905
- return [...conversation, ...onDiff];
1906
- });
1907
- const Reviews = Schema.fromJsonString(Schema.Array(Schema.Struct({
1908
- submitted_at: Schema.DateTimeUtcFromString,
1909
- body: Schema.String,
1910
- user: Schema.NullOr(Schema.Struct({
1911
- login: Schema.String,
1912
- type: Schema.String
1913
- }))
1914
- })));
1748
+ const reportedBy = (run) => run.outcome._tag === "reported" ? {
1749
+ verdict: run.outcome.verdict,
1750
+ findings: run.outcome.findings
1751
+ } : null;
1915
1752
  /**
1916
- * The reviews on a pull request that said something, as comments.
1753
+ * Why a run reported nothing, or null where it reported.
1917
1754
  *
1918
- * A review carries a body of its own, which is where a reviewer writes the
1919
- * sentence that is not attached to any line. An empty body is a verdict and
1920
- * 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.
1921
1758
  */
1922
- const prReviews = Effect.fnUntraced(function* (repo, number) {
1923
- return (yield* readJson("api reviews", "gh", ["api", `repos/${repo}/pulls/${number}/reviews?per_page=100`], Reviews)).flatMap((review) => review.user === null || review.body.trim() === "" ? [] : [{
1924
- login: review.user.login,
1925
- bot: review.user.type === "Bot",
1926
- at: review.submitted_at
1927
- }]);
1928
- });
1929
- 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;
1930
1760
  /**
1931
- * The repository paths that changed between two commits.
1932
- *
1933
- * GitHub compares them rather than git, because the commit a run was recorded
1934
- * against is not one the tool's own clone is promised to still have: a force
1935
- * push moves the pull request's ref and the old commit goes with it, where
1936
- * GitHub keeps both sides of the comparison. A comparison of a commit with
1937
- * itself reports no files at all, and so does one of two commits with nothing
1938
- * between them, which is why the key is optional.
1761
+ * Whether the files changed since the last run are worth paying for another.
1939
1762
  *
1940
- * `base...head` measures from where the two commits last agreed, so two heads
1941
- * on one branch report what was pushed between them, and a branch rebased since
1942
- * reports its whole diff. The second is the right answer for a caller deciding
1943
- * 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.
1944
1766
  */
1945
- const comparedFiles = Effect.fnUntraced(function* (repo, base, head) {
1946
- return ((yield* readJson("api compare", "gh", ["api", `repos/${repo}/compare/${base}...${head}`], Compare)).files ?? []).map((file) => file.filename);
1947
- });
1948
- const Commits = Schema.fromJsonString(Schema.Struct({ commits: Schema.Array(Schema.Struct({
1949
- committedDate: Schema.DateTimeUtcFromString,
1950
- authors: Schema.Array(Schema.Struct({ login: Schema.NullOr(Schema.String) }))
1951
- })) }));
1767
+ const worthRerunning = (changed, docsOnly) => changed.some((file) => !docsOnly.some((glob) => matchesGlob(file, glob)));
1952
1768
  /**
1953
- * The commits on a pull request.
1769
+ * The re-run rule: the head this run is skipped against, or null where it runs.
1954
1770
  *
1955
- * This is the expensive read of the three: `gh` returns every commit with its
1956
- * whole message, so a sweep only asks for it when something about the PR has
1957
- * 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.
1958
1778
  */
1959
- const prCommits = Effect.fnUntraced(function* (repo, number) {
1960
- return (yield* readJson("pr view commits", "gh", [
1961
- "pr",
1962
- "view",
1963
- String(number),
1964
- "--repo",
1965
- repo,
1966
- "--json",
1967
- "commits"
1968
- ], Commits)).commits.map((commit) => ({
1969
- logins: commit.authors.flatMap((author) => author.login === null ? [] : [author.login]),
1970
- at: commit.committedDate
1971
- }));
1972
- });
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(" ");
1973
1786
  /**
1974
- * What `gh` says about merging, in our words. Anything else is `unknown`:
1975
- * 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.
1976
1788
  *
1977
- * `Match.withReturnType` comes first in the pipeline or the return type is not
1978
- * 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.
1979
1792
  */
1980
- 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");
1981
1803
  /**
1982
- * What `gh` says the reviewers decided, in our words. A repository that requires
1983
- * 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.
1984
1809
  */
1985
- 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
+ };
1986
1816
  /**
1987
- * 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.
1988
1818
  *
1989
- * The one write the tool makes that no reflog of mine undoes, and the whole of
1990
- * it: a squash, because that is how the repository lands a pull request and the
1991
- * squash subject is its title, and the branch, because squashing kills it
1992
- * anyway. No `--auto`, which would hand GitHub a merge to make at a head
1993
- * 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.
1994
1824
  *
1995
- * Whether this pull request is one to merge is decided before we get here, and
1996
- * `gh` still has the last word: a branch protection this machine cannot see
1997
- * 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.
1998
1828
  */
1999
- const mergePr = Effect.fnUntraced(function* (repo, number) {
2000
- yield* capture("gh", [
2001
- "pr",
2002
- "merge",
2003
- String(number),
2004
- "--repo",
2005
- repo,
2006
- "--squash",
2007
- "--delete-branch"
2008
- ]).pipe(Effect.catchTags({
2009
- PlatformError: (error) => Effect.fail(unavailable(error)),
2010
- CommandFailed: (error) => Effect.fail(new GhReadFailed({
2011
- command: "pr merge",
2012
- detail: error.stderr
2013
- }))
2014
- }));
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
+ };
2015
1835
  });
2016
1836
  //#endregion
2017
- //#region src/adapters/conversation.ts
2018
- const Actor = Schema.NullOr(Schema.Struct({
2019
- login: Schema.String,
2020
- __typename: Schema.String
2021
- }));
2022
- const Said$1 = Schema.Struct({
2023
- author: Actor,
2024
- body: Schema.String,
2025
- createdAt: Schema.DateTimeUtcFromString
2026
- });
2027
- const Conversation = Schema.fromJsonString(Schema.Struct({ data: Schema.Struct({ repository: Schema.Struct({ pullRequest: Schema.Struct({
2028
- comments: Schema.Struct({ nodes: Schema.Array(Said$1) }),
2029
- reviews: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
2030
- author: Actor,
2031
- body: Schema.String,
2032
- submittedAt: Schema.NullOr(Schema.DateTimeUtcFromString)
2033
- })) }),
2034
- reviewThreads: Schema.Struct({ nodes: Schema.Array(Schema.Struct({
2035
- isResolved: Schema.Boolean,
2036
- isOutdated: Schema.Boolean,
2037
- path: Schema.NullOr(Schema.String),
2038
- line: Schema.NullOr(Schema.Int),
2039
- comments: Schema.Struct({ nodes: Schema.Array(Said$1) })
2040
- })) })
2041
- }) }) }) }));
2042
- const remark = (said, at) => said.author === null || at === null || said.body.trim() === "" ? [] : [{
2043
- login: said.author.login,
2044
- bot: said.author.__typename === "Bot",
2045
- at,
2046
- body: said.body.trim()
2047
- }];
2048
- const byTime = (self, other) => DateTime.Order(self.at, other.at);
1837
+ //#region src/cli/block.ts
2049
1838
  /**
2050
- * A pull request's whole conversation: the comments on it, the bodies of the
2051
- * reviews, and every thread on the diff with whether it is settled.
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).
2052
1842
  *
2053
- * GraphQL rather than the two REST endpoints a sweep reads, because resolution
2054
- * is not in REST at all: a review comment's payload carries `body`, `path`,
2055
- * `line`, `diff_hunk` and `side`, and nothing saying whether somebody closed
2056
- * the thread it belongs to. A thread that was settled a week ago is not
2057
- * something to answer, so the state that says so has to arrive with it.
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.
2058
1847
  *
2059
- * The pull request's own comments and the reviews' bodies come back as one
2060
- * strand under no path, in the order they were written: they are one
2061
- * conversation as it happened, and which endpoint each line came from is an
2062
- * accident of GitHub's model rather than anything to read.
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.
1850
+ */
1851
+ /**
1852
+ * A line of a block's body, under its heading. A blank line stays blank.
2063
1853
  *
2064
- * `__typename` is what says a bot is a bot, the way `user.type` does in REST.
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.
2065
1856
  */
2066
- const prConversation = Effect.fnUntraced(function* (repo, number) {
2067
- const [owner = repo, name = repo] = repo.split("/");
2068
- const pr = (yield* readJson("api graphql", "gh", [
2069
- "api",
2070
- "graphql",
2071
- "-f",
2072
- `query=query($owner:String!,$name:String!,$number:Int!){
2073
- repository(owner:$owner,name:$name){
2074
- pullRequest(number:$number){
2075
- comments(last:100){nodes{author{login __typename} body createdAt}}
2076
- reviews(last:100){nodes{author{login __typename} body submittedAt}}
2077
- reviewThreads(last:100){nodes{
2078
- isResolved isOutdated path line
2079
- comments(first:100){nodes{author{login __typename} body createdAt}}
2080
- }}
2081
- }
2082
- }
2083
- }`,
2084
- "-F",
2085
- `owner=${owner}`,
2086
- "-F",
2087
- `name=${name}`,
2088
- "-F",
2089
- `number=${number}`
2090
- ], Conversation)).data.repository.pullRequest;
2091
- const conversation = [...pr.comments.nodes.flatMap((it) => remark(it, it.createdAt)), ...pr.reviews.nodes.flatMap((it) => remark(it, it.submittedAt))].toSorted(byTime);
2092
- const threads = pr.reviewThreads.nodes.map((it) => ({
2093
- path: it.path,
2094
- line: it.line,
2095
- resolved: it.isResolved,
2096
- outdated: it.isOutdated,
2097
- comments: it.comments.nodes.flatMap((comment) => remark(comment, comment.createdAt)).toSorted(byTime)
2098
- }));
2099
- return [...conversation.length === 0 ? [] : [{
2100
- path: null,
2101
- line: null,
2102
- resolved: false,
2103
- outdated: false,
2104
- comments: conversation
2105
- }], ...threads];
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;
1861
+ /**
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.
1864
+ */
1865
+ const separated = (blocks) => blocks.filter(nonEmpty).flatMap((lines, index) => index === 0 ? lines : ["", ...lines]);
1866
+ /**
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.
1870
+ */
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)));
1887
+ //#endregion
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(", ");
1903
+ /**
1904
+ * What a cleanup would take, weighed.
1905
+ *
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.
1911
+ */
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
1936
+ ]);
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
2106
1943
  });
2107
1944
  //#endregion
2108
- //#region src/cli/exit.ts
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);
2109
1949
  /**
2110
- * The failures a command owes me a sentence for rather than a stack.
1950
+ * The two blocks a cleanup writes: what it takes and what it leaves.
2111
1951
  *
2112
- * Every one of them is a machine or a file that needs fixing, and the message
2113
- * says what to fix. Anything not named here is a fault of the tool's own, and a
2114
- * stack is what I want to see for those.
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.
2115
1956
  */
2116
- const userFacing = [
2117
- "ConfigMalformed",
2118
- "GhUnavailable",
2119
- "GhReadFailed",
2120
- "GhUnreadable"
2121
- ];
2122
- /** The same, for a command that also runs `git` against the tool's own clone. */
2123
- const userFacingAndGit = [...userFacing, "GitFailed"];
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
+ };
2124
1974
  /**
2125
- * The same, for a command that cuts a standing worktree and opens an agent
2126
- * session in it.
1975
+ * Takes back the disk the tool spent on itself, and nothing that is mine.
2127
1976
  *
2128
- * `dw-mc fix` and `dw-mc resolve` are the two, and they fail the same ways
2129
- * because they do the same thing to different findings: a worktree that holds
2130
- * work of mine and an agent that would not run are the session's failures, not
2131
- * either command's.
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.
2132
1990
  */
2133
- const userFacingAndSession = [
2134
- ...userFacing,
2135
- "GitFailed",
2136
- "WorktreeHeld",
2137
- "AgentFailed"
2138
- ];
2139
- /** Turns one of those into the sentence the CLI prints, and the exit code it leaves. */
2140
- const asUserError = (cause) => Effect.fail(new CliError.UserError({ cause }));
2141
- //#endregion
2142
- //#region src/domain/moment.ts
2143
- const isLater = Order.isGreaterThan(DateTime.Order);
2144
- /** Whether `self` happened after `other`, counting never as before anything. */
2145
- const isAfter = (self, other) => Predicate.isNotNull(self) && (other === null || isLater(self, other));
2146
- /** The later of the two. */
2147
- const later = (self, other) => isAfter(self, other) ? self : other;
2148
- /** Whether the two are the same moment, counting never as the same as never. */
2149
- const isSame = (self, other) => self === null || other === null ? self === other : DateTime.Equivalence(self, other);
2150
- /** The latest of many, or never when there are none. */
2151
- const newest = (moments) => moments.reduce(later, null);
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"));
2152
2009
  //#endregion
2153
- //#region src/terms/pr.ts
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 });
2040
+ /**
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.
2045
+ */
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
+ }
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
+ })));
2154
2093
  /**
2155
- * What GitHub says about a pull request, in this tool's words.
2094
+ * The open pull requests I authored in `repo`.
2156
2095
  *
2157
- * The three of them are here because both sides need the same one: `gh` and the
2158
- * checks adapter answer in these words, and the bucket rules decide on them. A
2159
- * union restated on each side is a case that goes unreachable the day the other
2160
- * side gains a member.
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.
2161
2098
  */
2162
- /** How far GitHub has got towards letting a tracked PR merge. */
2163
- const Mergeability = Schema.Literals([
2164
- "mergeable",
2165
- "conflicting",
2166
- "unknown"
2167
- ]);
2168
- /** What the reviewers have decided, or that nobody is required to. */
2169
- const ReviewDecision = Schema.Literals([
2170
- "approved",
2171
- "changes-requested",
2172
- "review-required",
2173
- "none"
2174
- ]);
2175
- /** What CI says about the current head. */
2176
- const ChecksState = Schema.Literals([
2177
- "green",
2178
- "red",
2179
- "pending",
2180
- "none"
2181
- ]);
2182
- //#endregion
2183
- //#region src/domain/bucket.ts
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
+ });
2184
2116
  /**
2185
- * Everything the bucket rules are allowed to know about a tracked PR.
2117
+ * One entry of a PR's status check rollup.
2186
2118
  *
2187
- * It is a schema because a sweep writes it to the state directory and reads it
2188
- * back on the next one: the same facts that decide a bucket are what a quiet PR
2189
- * is recognised by.
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.
2190
2122
  */
2191
- const Facts = Schema.Struct({
2192
- repo: Schema.String,
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({
2193
2135
  number: Schema.Int,
2194
2136
  title: Schema.String,
2195
2137
  url: Schema.String,
2196
- /** Shown, never acted on unless I ask. */
2197
- draft: Schema.Boolean,
2198
- /** The head commit every other fact here is about. */
2199
- head: Schema.String,
2200
- mergeable: Mergeability,
2201
- reviewDecision: ReviewDecision,
2202
- checks: ChecksState,
2203
- /** Why the flaky classifier excuses this red CI, or null where it does not. */
2204
- ciFlaky: Schema.NullOr(Schema.String),
2205
- /** The head a rebase onto the base conflicted at, or null where none has. */
2206
- rebaseConflictAt: Schema.NullOr(Schema.String),
2207
- /** The newest comment from a person who is not me, bots excluded. */
2208
- newestHumanCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2209
- myLastCommentAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2210
- myLastCommitAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2211
- /** The newest comment my acknowledgement covers, or null where I have made none. */
2212
- acknowledgedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2213
- /** The head a review run has already covered, or null where none has. */
2214
- reviewRunHead: Schema.NullOr(Schema.String),
2215
- /** Findings on this head that withhold the stamp, at the bar `stamp.blocks_on` sets. */
2216
- blockingFindings: Schema.Int
2217
- });
2218
- /** The one place a tracked PR sits at a time, named for what it waits on. */
2219
- const Bucket = Schema.Literals([
2220
- "needs-me",
2221
- "needs-review-run",
2222
- "waiting-on-others",
2223
- "ready"
2224
- ]);
2225
- /** The buckets in the order I act on them: the top of the table is my next move. */
2226
- const order = [
2227
- "needs-me",
2228
- "needs-review-run",
2229
- "waiting-on-others",
2230
- "ready"
2231
- ];
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
+ }));
2232
2150
  /**
2233
- * Why a PR is mine to move when somebody has said something I have not
2234
- * answered.
2235
- *
2236
- * It is named because it is read twice: here, where it puts the PR in Needs me,
2237
- * and by `dw-mc comments`, which says what settles that one branch of the
2238
- * bucket. A sentence matched from the other side of the tool is a rule that
2239
- * breaks on a reword.
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.
2240
2153
  */
2241
- const unanswered = "a comment I have not answered";
2154
+ const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
2242
2155
  /**
2243
- * Why a PR is mine to move when a review run found something that withholds
2244
- * the stamp.
2245
- *
2246
- * It is named for the reason `unanswered` is: `dw-mc stamp` says this same
2247
- * sentence about this same number, and two spellings of it would be two
2248
- * answers to what a blocking finding is worth.
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.
2249
2158
  */
2250
- const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
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
+ })));
2251
2175
  /**
2252
- * How far my answer to the conversation reaches: the latest of my last comment,
2253
- * my last commit and my acknowledgement.
2176
+ * Every open pull request on a repository, by branch.
2254
2177
  *
2255
- * A comment is answered by a reply, by a push, or by my word that nothing in it
2256
- * was mine to answer. It is named because it is read twice: here, where a newer
2257
- * comment puts the PR in Needs me, and by `dw-mc comments`, which shows exactly
2258
- * the comments newer than it.
2259
- */
2260
- const answeredAt = (facts) => later(later(facts.myLastCommentAt, facts.myLastCommitAt), facts.acknowledgedAt);
2261
- /**
2262
- * The first of the rules that makes a PR mine to move, or null when none
2263
- * does. The order is the order I would fix them in: a conflict makes every
2264
- * other signal on the PR stale, and a red build is worth more than a comment.
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.
2180
+ *
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.
2265
2183
  */
2266
- const needsMe = (facts) => {
2267
- if (facts.mergeable === "conflicting") return "merge conflict";
2268
- if (facts.rebaseConflictAt === facts.head) return "a rebase onto the base conflicted";
2269
- if (facts.checks === "red" && facts.ciFlaky === null) return "CI is red";
2270
- if (facts.reviewDecision === "changes-requested") return "changes requested";
2271
- if (facts.blockingFindings > 0) return blockedBy(facts.blockingFindings);
2272
- if (isAfter(facts.newestHumanCommentAt, answeredAt(facts))) return unanswered;
2273
- return null;
2274
- };
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
+ }])));
2275
2214
  /**
2276
- * What is actually true of a PR nothing is waiting on.
2277
- *
2278
- * Ready is reached by having no reason not to be, so the reason says only what
2279
- * holds: a repository that requires no reviewer produces no approval, and a
2280
- * pull request with no CI at all is not green.
2215
+ * Every comment on a pull request: the ones on the conversation and the ones
2216
+ * left on the diff.
2281
2217
  *
2282
- * A red CI the classifier excused is said out loud, because GitHub does not
2283
- * excuse it: the check is still red, and Ready is what `dw-mc merge` reads.
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.
2284
2223
  */
2285
- const readyReason = (facts) => {
2286
- const held = [
2287
- facts.reviewDecision === "approved" ? "approved" : null,
2288
- facts.checks === "green" ? "green" : null,
2289
- facts.mergeable === "mergeable" ? "mergeable" : null
2290
- ].filter((it) => it !== null);
2291
- const standing = held.length === 0 ? "nothing left to wait on" : held.join(", ");
2292
- return facts.checks === "red" && facts.ciFlaky !== null ? `${standing} (red CI called flaky: ${facts.ciFlaky})` : standing;
2293
- };
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
+ })));
2294
2237
  /**
2295
- * The bucket a tracked PR sits in, and the reason for it.
2296
- *
2297
- * This is the single place the bucket rules exist. Every tracked PR lands in
2298
- * exactly one bucket, so the rules are tried in priority order and the first
2299
- * that claims the PR wins: a PR that both needs a review run and has changes
2300
- * requested is mine to move, not the review's.
2238
+ * The reviews on a pull request that said something, as comments.
2301
2239
  *
2302
- * Ready does not insist on an approval, because a repository that requires no
2303
- * reviewer never produces one. What it insists on is that nobody else has been
2304
- * asked and is yet to answer.
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`.
2305
2243
  */
2306
- const place = (facts) => {
2307
- const mine = needsMe(facts);
2308
- if (mine !== null) return {
2309
- bucket: "needs-me",
2310
- reason: mine
2311
- };
2312
- if (facts.reviewRunHead !== facts.head) return {
2313
- bucket: "needs-review-run",
2314
- reason: "no review run on this head"
2315
- };
2316
- if (facts.reviewDecision === "review-required") return {
2317
- bucket: "waiting-on-others",
2318
- reason: "a review from someone else"
2319
- };
2320
- if (facts.checks === "pending") return {
2321
- bucket: "waiting-on-others",
2322
- reason: "CI is still running"
2323
- };
2324
- return {
2325
- bucket: "ready",
2326
- reason: readyReason(facts)
2327
- };
2328
- };
2329
- const group = (facts) => {
2330
- const placed = facts.map((it) => ({
2331
- facts: it,
2332
- placement: place(it)
2333
- })).toSorted((a, b) => a.facts.repo.localeCompare(b.facts.repo) || a.facts.number - b.facts.number);
2334
- return order.map((bucket) => ({
2335
- bucket,
2336
- placed: placed.filter((it) => it.placement.bucket === bucket)
2337
- })).filter((bucket) => bucket.placed.length > 0);
2338
- };
2339
- //#endregion
2340
- //#region src/domain/reference.ts
2341
- /** `owner/name#12`, or `12` on its own. */
2342
- const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
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 }))) }));
2343
2252
  /**
2344
- * A segment of nothing but dots, which no repository is called.
2253
+ * The repository paths that changed between two commits.
2345
2254
  *
2346
- * The repository names a directory under the state directory before it names
2347
- * 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.
2348
2266
  */
2349
- 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
+ })) }));
2350
2274
  /**
2351
- * The pull request a reference names.
2275
+ * The commits on a pull request.
2352
2276
  *
2353
- * A reference that spells its repository out is taken as it is, registered or
2354
- * not: reviewing someone else's pull request is a thing to ask for, and the
2355
- * 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.
2356
2280
  */
2357
- const resolve$1 = (text, registered) => {
2358
- const found = spelled.exec(text);
2359
- const number = found?.[2];
2360
- if (number === void 0) return {
2361
- _tag: "unreadable",
2362
- text
2363
- };
2364
- const spelledRepo = found?.[1];
2365
- if (spelledRepo !== void 0 && spelledRepo.split("/").some((segment) => onlyDots.test(segment))) return {
2366
- _tag: "unreadable",
2367
- text
2368
- };
2369
- const repo = spelledRepo ?? (registered.length === 1 ? registered[0] : void 0);
2370
- if (repo === void 0) return {
2371
- _tag: "ambiguous",
2372
- repos: registered
2373
- };
2374
- return {
2375
- _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",
2376
2287
  repo,
2377
- number: Number(number)
2378
- };
2379
- };
2380
- //#endregion
2381
- //#region src/domain/findings.ts
2382
- /** Whether a review run found anything at all. */
2383
- 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
+ });
2384
2295
  /**
2385
- * 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.
2386
2298
  *
2387
- * The first three are ours, and the only ones a run is asked for. The rest are
2388
- * the persona a run with no slash command carries, which grades in its own
2389
- * words: a turn that comes back in them is worth reading rather than throwing
2390
- * 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.
2391
2301
  */
2392
- const Spelling = Schema.Literals([
2393
- "error",
2394
- "warning",
2395
- "info",
2396
- "Critical",
2397
- "Required",
2398
- "Optional",
2399
- "Nit",
2400
- "FYI"
2401
- ]);
2402
- /** What each of those words weighs. The record is exhaustive, so neither list can drift. */
2403
- const severityOf = {
2404
- error: "error",
2405
- warning: "warning",
2406
- info: "info",
2407
- Critical: "error",
2408
- Required: "error",
2409
- Optional: "warning",
2410
- Nit: "info",
2411
- FYI: "info"
2412
- };
2413
- const Weighed = Spelling.pipe(Schema.decodeTo(Severity, SchemaTransformation.transform({
2414
- decode: (word) => severityOf[word],
2415
- encode: (severity) => severity
2416
- })));
2417
- /** The fields both spellings of a finding share. Only the severity differs. */
2418
- const shared = {
2419
- file: Schema.String,
2420
- line: Schema.Int,
2421
- summary: Schema.String
2422
- };
2423
- /** One problem a review run reports, at a file and line. */
2424
- const Finding = Schema.Struct({
2425
- ...shared,
2426
- severity: Severity
2427
- });
2302
+ const mergeabilityOf = (raw) => Match.value(raw).pipe(Match.withReturnType(), Match.when("MERGEABLE", () => "mergeable"), Match.when("CONFLICTING", () => "conflicting"), Match.orElse(() => "unknown"));
2428
2303
  /**
2429
- * What a review run found: the shape the tool keeps, and the one a fix session
2430
- * 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.
2431
2306
  */
2432
- const Findings = Schema.Struct({
2433
- verdict: Verdict,
2434
- findings: Schema.Array(Finding)
2435
- });
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"));
2436
2308
  /**
2437
- * The same findings as a runner may spell them, which is what the second turn's
2438
- * output is read with.
2309
+ * Squash-merges a pull request and deletes the branch it stood on.
2439
2310
  *
2440
- * A word nothing maps fails here, and a failed read is a failure of the run:
2441
- * 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.
2442
2320
  */
2443
- const Reported = Schema.Struct({
2444
- verdict: Verdict,
2445
- findings: Schema.Array(Schema.Struct({
2446
- ...shared,
2447
- severity: Weighed
2448
- }))
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
2449
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);
2450
2371
  /**
2451
- * 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.
2452
2374
  *
2453
- * It is derived from the schema the findings are kept under rather than written
2454
- * out beside it, so a runner is asked for exactly the shape that is persisted.
2455
- * `Reported` is wider on purpose and only on the severity: what a runner is
2456
- * asked for is our three words, and a persona's five are read where they arrive
2457
- * 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.
2458
2387
  */
2459
- 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
2460
2431
  /**
2461
- * The findings as the Markdown a report is written in.
2432
+ * The failures a command owes me a sentence for rather than a stack.
2462
2433
  *
2463
- * It is what a schema-held run's report says: with a schema in force a run
2464
- * answers in findings and not in prose, so the report kept beside it is written
2465
- * 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.
2466
2437
  */
2467
- 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");
2468
- /** Where each severity sits against the others, so the bar can be compared with it. */
2469
- const rank = {
2470
- info: 0,
2471
- warning: 1,
2472
- error: 2
2473
- };
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"];
2474
2446
  /**
2475
- * 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.
2476
2449
  *
2477
- * `stamp.blocks_on` is my bar rather than a constant, so a repository whose
2478
- * warnings I do not want to merge past is configured rather than coded. An
2479
- * 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.
2480
2454
  */
2481
- 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 }));
2482
2463
  //#endregion
2483
- //#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
2484
2476
  /**
2485
- * 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.
2486
2478
  *
2487
- * A failure is recorded as one and is never a clean verdict: a turn that exited
2488
- * badly, ran out of patience or answered in a shape that does not validate has
2489
- * 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.
2490
2483
  */
2491
- const Outcome = Schema.Union([Schema.TaggedStruct("reported", {
2492
- verdict: Verdict,
2493
- findings: Schema.Array(Finding)
2494
- }), 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
2495
2506
  /**
2496
- * 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.
2497
2508
  *
2498
- * It is a schema because a review run outlives the command that started it: the
2499
- * state directory is where the next sweep learns that this head has been
2500
- * 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.
2501
2512
  */
2502
- const ReviewRun = Schema.Struct({
2513
+ const Facts = Schema.Struct({
2503
2514
  repo: Schema.String,
2504
2515
  number: Schema.Int,
2505
- /** 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. */
2506
2521
  head: Schema.String,
2507
- /**
2508
- * The slash command line the run opened on, or null where it opened on the
2509
- * tool's own prompt. A report found months later says what it was asked, and a
2510
- * record an earlier version wrote carries no such field and is forgotten.
2511
- */
2512
- command: Schema.NullOr(Schema.String),
2513
- effort: Schema.NullOr(Effort),
2514
- /**
2515
- * The agent session the run happened in, or null where it never reached one.
2516
- *
2517
- * A run that would not start or exited before it said anything has no session,
2518
- * and the run is still recorded: a failure is recorded as what it is.
2519
- */
2520
- sessionId: Schema.NullOr(Schema.String),
2521
- ranAt: Schema.DateTimeUtcFromString,
2522
- outcome: Outcome
2523
- });
2524
- /** A head as it is read out loud: the seven characters git itself abbreviates to. */
2525
- const short = (head) => head.slice(0, 7);
2526
- /**
2527
- * Where a run is kept: one key per head, so a run and the code it read cannot
2528
- * drift apart, and a re-review replaces the run before it.
2529
- */
2530
- const runKey = (repo, number, head) => `${prKey(repo, number)}@${head}`;
2531
- /** Where the run's report is kept: beside the run, as the Markdown it is. */
2532
- const reportKey = (repo, number, head) => `${runKey(repo, number, head)}.md`;
2533
- /**
2534
- * Which head a pull request was last reviewed at: an index beside `runKey` and
2535
- * `reportKey` rather than a thing the glossary names.
2536
- *
2537
- * A run is kept under the head it read, which answers the question a sweep asks
2538
- * of one head. The re-run rule and `dw-mc findings` ask the other one - which
2539
- * head the last run was at - and this is where they read it, so neither has to
2540
- * ask GitHub what is current before it can look anything up.
2541
- */
2542
- const LastReviewed = Schema.Struct({ head: Schema.String });
2543
- /** Where that head is kept. No head is spelled `latest`, so nothing collides. */
2544
- const latestKey = (repo, number) => `${prKey(repo, number)}@latest`;
2545
- /**
2546
- * The run at one head, or none where nothing has reviewed it.
2547
- *
2548
- * A head is where the question is asked - the stamp, the bucket and `dw-mc
2549
- * findings` all ask about one commit - and one read off the disk answers it
2550
- * without an index to keep in step.
2551
- *
2552
- * Forgetting a run costs one review.
2553
- */
2554
- const runAt = Effect.fn("review.runAt")(function* (repo, number, head) {
2555
- const runs = yield* storeFor("runs", ReviewRun);
2556
- return yield* remembered(runs.get(runKey(repo, number, head)));
2557
- });
2558
- /** The last review run on a pull request, or none where it has had none. */
2559
- const lastRun = Effect.fn("review.lastRun")(function* (repo, number) {
2560
- const heads = yield* storeFor("runs", LastReviewed);
2561
- const at = yield* remembered(heads.get(latestKey(repo, number)));
2562
- 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
2563
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
+ ];
2564
2554
  /**
2565
- * 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.
2566
2557
  *
2567
- * A failure is not a clean verdict: a run that could not report has found
2568
- * nothing, which is not the same as having found nothing wrong. Everything that
2569
- * reads a run's findings reads them through here, so the distinction is drawn
2570
- * 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.
2571
2562
  */
2572
- const reportedBy = (run) => run.outcome._tag === "reported" ? {
2573
- verdict: run.outcome.verdict,
2574
- findings: run.outcome.findings
2575
- } : null;
2563
+ const unanswered = "a comment I have not answered";
2576
2564
  /**
2577
- * 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.
2578
2567
  *
2579
- * The sibling of `reportedBy`, and here for the same reason: the two halves of
2580
- * an outcome are read through one place each rather than re-narrowed at every
2581
- * 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.
2582
2571
  */
2583
- const detailOf = (run) => run.outcome._tag === "failed" ? run.outcome.detail : null;
2572
+ const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
2584
2573
  /**
2585
- * 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.
2586
2576
  *
2587
- * The question is deliberately about what changed rather than how much: one
2588
- * line outside the `docs_only` globs is code nobody has reviewed, and a
2589
- * 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.
2590
2581
  */
2591
- 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);
2592
2583
  /**
2593
- * The re-run rule: the head this run is skipped against, or null where it runs.
2594
- *
2595
- * A review costs real money and minutes of my attention, and a typo fix is not
2596
- * worth either. Four things are never skipped, because the rule is here to save
2597
- * me a review and not to stand between me and one I asked for: a pull request
2598
- * with no run behind it, a run that reported nothing, a comparison GitHub would
2599
- * not answer, and anything that changed outside the globs. A head that has
2600
- * already had a run changed nothing at all, which is the one case that needs no
2601
- * 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.
2602
2587
  */
2603
- const skippedSince = (asked, docsOnly) => {
2604
- if (asked.last === null || reportedBy(asked.last) === null) return null;
2605
- const changed = asked.last.head === asked.head ? [] : asked.changed;
2606
- 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;
2607
2596
  };
2608
- /** What a run was opened on, as the report says it. */
2609
- const askedOf$2 = (run) => run.command === null ? "the tool's own prompt" : [run.command, run.effort].filter((part) => part !== null).join(" ");
2610
2597
  /**
2611
- * 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.
2612
2599
  *
2613
- * The heading is the whole point of writing it rather than storing the prose
2614
- * alone - a file found months later says which pull request, which commit and
2615
- * what the run was asked, without anything else having to be open.
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.
2603
+ *
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.
2616
2606
  */
2617
- const reportDocument = (run, title, prose) => [
2618
- `# ${run.repo}#${run.number} ${title}`,
2619
- "",
2620
- `- head: ${run.head}`,
2621
- `- run: ${askedOf$2(run)}`,
2622
- `- ran: ${DateTime.formatIso(run.ranAt)}`,
2623
- "",
2624
- prose.trim(),
2625
- ""
2626
- ].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
+ };
2627
2616
  /**
2628
- * Whether `head` has the review it needs.
2617
+ * The bucket a tracked PR sits in, and the reason for it.
2629
2618
  *
2630
- * A run that reported nothing does not count, which is the same rule
2631
- * `reportedBy` draws everywhere else: a failure has found nothing, not found
2632
- * 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.
2633
2627
  */
2634
- const reviewedBy = (run) => run !== null && reportedBy(run) !== null;
2635
- /** The findings at one head that withhold the stamp. */
2636
- const blockingIn = (run, blocksOn) => {
2637
- const found = run === null ? null : reportedBy(run);
2638
- 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);
2639
2660
  };
2661
+ //#endregion
2662
+ //#region src/domain/reference.ts
2663
+ /** `owner/name#12`, or `12` on its own. */
2664
+ const spelled = /^(?:([^\s/]+\/[^\s/]+)#)?(\d+)$/;
2640
2665
  /**
2641
- * 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.
2642
2667
  *
2643
- * Whether a head has been reviewed is the runs' to say and no sweep's: a run is
2644
- * recorded against one head, and a head with no run of its own has not been
2645
- * reviewed however many sweeps have seen the pull request. A run that could not
2646
- * report findings does not count either: its verdict is what takes a pull
2647
- * 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.
2648
2674
  *
2649
- * It is one function because the two callers are a sweep and `dw-mc merge`, and
2650
- * the second exists to land what the first only describes: two spellings of
2651
- * 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.
2652
2678
  */
2653
- const reviewedAt = Effect.fn("review.reviewedAt")(function* (repo, number, head, blocksOn) {
2654
- 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
+ };
2655
2696
  return {
2656
- reviewRunHead: reviewedBy(run) ? head : null,
2657
- blockingFindings: blockingIn(run, blocksOn).length
2697
+ _tag: "resolved",
2698
+ repo,
2699
+ number: Number(number)
2658
2700
  };
2659
- });
2701
+ };
2660
2702
  //#endregion
2661
2703
  //#region src/cli/pr.ts
2662
2704
  /** The pull request a command acts on, named the way I actually type it. */
@@ -2958,8 +3000,7 @@ const settled = (thread) => [thread.resolved ? "resolved" : null, thread.outdate
2958
3000
  * exists to replace. No diff hunk with it: the code is on this machine, under
2959
3001
  * the path the heading already prints.
2960
3002
  */
2961
- 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}`)])];
2962
- 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)]));
2963
3004
  /**
2964
3005
  * The conversation on screen: people first, then a rule, then the bots.
2965
3006
  *
@@ -2972,10 +3013,10 @@ const separated = (blocks) => blocks.flatMap((lines, index) => index === 0 ? lin
2972
3013
  * verdict older than my last push is one I have already had the chance to read,
2973
3014
  * and `--all` is where it still is.
2974
3015
  */
2975
- const lines$2 = (view, paint) => {
2976
- const people = view.people.map((thread) => block$1(thread, paint));
2977
- const bots = view.bots.map((thread) => block$1(thread, paint));
2978
- 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]];
2979
3020
  };
2980
3021
  /** What to say where there is nothing to print, which depends on why there is not. */
2981
3022
  const nothing$1 = (facts, all) => {
@@ -3000,17 +3041,13 @@ const nothing$1 = (facts, all) => {
3000
3041
  const acknowledged = Effect.fn("comments.acknowledged")(function* (facts, threads) {
3001
3042
  const pr = `${facts.repo}#${facts.number}`;
3002
3043
  const at = acknowledging(threads);
3003
- if (at === null) {
3004
- yield* Console.log(`Nothing to acknowledge: nobody has said anything on ${pr}.`);
3005
- return;
3006
- }
3044
+ if (at === null) return [`Nothing to acknowledge: nobody has said anything on ${pr}.`];
3007
3045
  yield* acknowledge(facts.repo, facts.number, at);
3008
3046
  const placement = place({
3009
3047
  ...facts,
3010
3048
  acknowledgedAt: at
3011
3049
  });
3012
- yield* Console.log(`Acknowledged everything said on ${pr} up to ${DateTime.formatIso(at)}.`);
3013
- yield* Console.log(`${pr} sits in ${heading[placement.bucket]}: ${placement.reason}.`);
3050
+ return [`Acknowledged everything said on ${pr} up to ${DateTime.formatIso(at)}.`, `${pr} sits in ${heading[placement.bucket]}: ${placement.reason}.`];
3014
3051
  });
3015
3052
  /**
3016
3053
  * The conversation on one tracked pull request, and nothing else.
@@ -3042,22 +3079,14 @@ const comments = Command.make("comments", {
3042
3079
  since: answeredAt(facts),
3043
3080
  all
3044
3081
  });
3045
- if (view.people.length === 0 && view.bots.length === 0) yield* Effect.forEach(nothing$1(facts, all), (line) => Console.log(line));
3046
- else {
3047
- yield* Console.log(paint.bold(`${repo}#${number}`) + ` ${paint.dim(facts.title)}`);
3048
- yield* Console.log("");
3049
- yield* Effect.forEach(lines$2(view, paint), (line) => Console.log(line));
3050
- }
3051
- if (ack) {
3052
- yield* Console.log("");
3053
- yield* acknowledged(facts, threads);
3054
- }
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)]));
3055
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"));
3056
3085
  //#endregion
3057
3086
  //#region src/cli/findings.ts
3058
3087
  /** The findings as the JSON the schema defines, rather than as this file spells it. */
3059
- const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Findings));
3060
- 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"));
3061
3090
  /** What a run's findings come to in one line, against the bar that blocks. */
3062
3091
  const summary = (found, blocksOn) => {
3063
3092
  if (found.findings.length === 0) return "clean, nothing to fix";
@@ -3065,14 +3094,21 @@ const summary = (found, blocksOn) => {
3065
3094
  return `${count(found.findings.length, "finding")}, ${blocked} blocking`;
3066
3095
  };
3067
3096
  /** Which run these findings are, and what they come to: the line above the list. */
3068
- 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
+ };
3069
3104
  /**
3070
3105
  * The findings one to a line, in the order the run reported them, ruled so the
3071
- * 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.
3072
3108
  */
3073
- const lines$1 = (found) => table(found.findings.map((finding) => [
3074
- `${finding.file}:${finding.line}`,
3075
- 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),
3076
3112
  finding.summary
3077
3113
  ]), rule);
3078
3114
  /**
@@ -3098,17 +3134,17 @@ const whatItFound = (run) => {
3098
3134
  */
3099
3135
  const findings = Command.make("findings", {
3100
3136
  pr: prArgument,
3101
- json: jsonFlag
3137
+ json: jsonFlag$1
3102
3138
  }, Effect.fn("findings")(function* ({ json, pr }) {
3103
3139
  const { number, repo, settings } = yield* forPr(pr);
3104
3140
  const run = yield* currentRun(repo, number);
3105
3141
  const found = yield* whatItFound(run);
3106
3142
  if (json) {
3107
- yield* Console.log(yield* asJson$2(found));
3143
+ yield* Console.log(yield* asJson$3(found));
3108
3144
  return;
3109
3145
  }
3110
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3111
- 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)));
3112
3148
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print what the current review run found on one pull request"));
3113
3149
  //#endregion
3114
3150
  //#region src/adapters/agent.ts
@@ -3508,7 +3544,7 @@ const Selection = Schema.Struct({
3508
3544
  findings: Schema.Array(Chosen)
3509
3545
  });
3510
3546
  /** The selection as the JSON the schema defines, rather than as this file spells it. */
3511
- const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3547
+ const asJson$2 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3512
3548
  /**
3513
3549
  * The prompt a fix session opens on: what these findings are, and the findings
3514
3550
  * themselves as JSON.
@@ -3523,7 +3559,7 @@ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Selection));
3523
3559
  * inside the worktree is my call, made once in `fix.commits` or for one session
3524
3560
  * with the flag.
3525
3561
  */
3526
- const promptFor$1 = (selection, commits) => Effect.map(asJson$1(selection), (json) => [
3562
+ const promptFor$1 = (selection, commits) => Effect.map(asJson$2(selection), (json) => [
3527
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.`,
3528
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.",
3529
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.`,
@@ -3549,10 +3585,11 @@ const commitFlag = Flag.Boolean("commit").pipe(Flag.withDescription("Let this se
3549
3585
  * The rows come from there rather than being built again here, so the list I
3550
3586
  * pick from and the list I read are the same list. A row that does not fit the
3551
3587
  * screen is cut: a prompt draws its own frame around the row, and a row that
3552
- * 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).
3553
3590
  */
3554
3591
  const choicesOf$1 = (found, screen) => {
3555
- const rows = lines$1(found);
3592
+ const rows = lines$1(found, plain);
3556
3593
  const room = screen === 0 ? Number.POSITIVE_INFINITY : screen - 6;
3557
3594
  return found.findings.map((finding, index) => ({
3558
3595
  title: truncate(rows[index] ?? finding.summary, room),
@@ -3599,11 +3636,12 @@ const fix = Command.make("fix", {
3599
3636
  pr: prArgument,
3600
3637
  commit: commitFlag,
3601
3638
  print: printFlag$1
3602
- }, Effect.fn("fix")(function* ({ commit, pr, print }) {
3639
+ }, Effect.fn("fix")(function* ({ commit, pr, print: promptOnly }) {
3603
3640
  const { number, repo, settings, launcher } = yield* forPr(pr);
3641
+ const paint = yield* Paint;
3604
3642
  const run = yield* currentRun(repo, number);
3605
3643
  const found = yield* whatItFound(run);
3606
- yield* Console.log(header$1(run, found, settings.stamp.blocks_on));
3644
+ yield* print([header$1(run, found, settings.stamp.blocks_on, paint)]);
3607
3645
  if (found.findings.length === 0) return;
3608
3646
  const view = yield* reading(`${repo}#${number}`, prView(repo, number));
3609
3647
  yield* refuse(staleAt(number, run.head, view.headRefOid));
@@ -3614,7 +3652,7 @@ const fix = Command.make("fix", {
3614
3652
  return;
3615
3653
  }
3616
3654
  const commits = Option.getOrElse(commit, () => settings.fix.commits);
3617
- if (print) {
3655
+ if (promptOnly) {
3618
3656
  yield* Console.log(yield* promptFor$1({
3619
3657
  repo,
3620
3658
  number,
@@ -3624,8 +3662,7 @@ const fix = Command.make("fix", {
3624
3662
  return;
3625
3663
  }
3626
3664
  const worktree = yield* standingWorktree(repo, number, view.headRefName, "fix");
3627
- yield* Console.log(` ${chosen.length} of ${found.findings.length} findings, ${commits ? "committing" : "not committing"}`);
3628
- 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}`)]);
3629
3666
  const ended = yield* steeredSession({
3630
3667
  launcher,
3631
3668
  directory: worktree.directory,
@@ -3636,9 +3673,11 @@ const fix = Command.make("fix", {
3636
3673
  findings: chosen
3637
3674
  }, commits)
3638
3675
  });
3639
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
3640
- yield* Console.log(`${commits ? "Nothing was pushed" : "Nothing was committed or pushed"} for you; the worktree stands at ${worktree.directory}.`);
3641
- 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
+ ]]));
3642
3681
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Pick findings from the current review run and open a fix session on them"));
3643
3682
  //#endregion
3644
3683
  //#region src/domain/forget.ts
@@ -3676,12 +3715,12 @@ const forget = Effect.fn("forget.forget")(function* (repo, number) {
3676
3715
  * Forgetting takes none of them: each stands on a branch of the tool's own and
3677
3716
  * holds what I committed there, so what the tool does is say they are there.
3678
3717
  */
3679
- const standingOn = (inventory, repo, number) => standing(inventory).filter((it) => it.repo === repo && it.number === number);
3718
+ const standingOn = (inventory, repo, number) => standing$1(inventory).filter((it) => it.repo === repo && it.number === number);
3680
3719
  //#endregion
3681
3720
  //#region src/cli/forget.ts
3682
3721
  /**
3683
- * Forgets one pull request and says what stays: the records go, the session
3684
- * worktrees standing on it do not.
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.
3685
3724
  *
3686
3725
  * `deleted` is the branch the pull request stood on, where the caller has just
3687
3726
  * deleted it. A session there tracks a branch that is gone, which is worth
@@ -3693,16 +3732,16 @@ const forgetting = Effect.fn("forgetting")(function* (repo, number, options = {}
3693
3732
  const paint = yield* Paint;
3694
3733
  const where = `${repo}#${number}`;
3695
3734
  const forgot = yield* forget(repo, number);
3696
- yield* Console.log(forgot === 0 ? `Nothing is kept about ${where}.` : `Forgot ${where}: ${count(forgot, "record")}.`);
3735
+ const forgotten = [forgot === 0 ? `Nothing is kept about ${where}.` : `Forgot ${where}: ${count(forgot, "record")}.`];
3697
3736
  const found = yield* inventory;
3698
3737
  const sessions = standingOn(found, repo, number);
3699
- if (sessions.length === 0) return;
3738
+ if (sessions.length === 0) return [forgotten];
3700
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`)]));
3701
- yield* Console.log("");
3702
- yield* Console.log("Stays");
3703
- yield* Effect.forEach(rows, (row) => Console.log(` ${row}`));
3704
- yield* Console.log("");
3705
- yield* Console.log("What you committed there is yours, so nothing here takes it down.");
3740
+ return [
3741
+ forgotten,
3742
+ block("Stays", rows),
3743
+ ["What you committed there is yours, so nothing here takes it down."]
3744
+ ];
3706
3745
  });
3707
3746
  /**
3708
3747
  * Forgets a pull request that closed some other way than `dw-mc merge`.
@@ -3715,7 +3754,7 @@ const forgetting = Effect.fn("forgetting")(function* (repo, number, options = {}
3715
3754
  */
3716
3755
  const forgetCommand = Command.make("forget", { pr: prArgument }, Effect.fn("forget")(function* ({ pr }) {
3717
3756
  const { number, repo } = yield* forPr(pr);
3718
- yield* forgetting(repo, number);
3757
+ yield* print(separated(yield* forgetting(repo, number)));
3719
3758
  })).pipe(Command.withDescription("Forget everything kept about a pull request that is done"));
3720
3759
  //#endregion
3721
3760
  //#region src/cli/init.ts
@@ -3772,10 +3811,13 @@ const init = Command.make("init", {
3772
3811
  const overrides = asked(base, effort);
3773
3812
  const written = Option.isSome(repo) ? withRepo(withDefaults(file, defaults), repo.value, overrides) : withDefaults(file, merge$1(defaults, overrides));
3774
3813
  if (encode(written) !== encode(file) || Option.isNone(before)) yield* write(written);
3775
- yield* Console.log(row("review", opening(written.defaults ?? {})));
3776
- yield* Console.log(row("config", config.path));
3777
- yield* Console.log(row("state", state));
3778
- 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
+ ]);
3779
3821
  }, Effect.catchTag([
3780
3822
  "ConfigMalformed",
3781
3823
  "GhUnauthenticated",
@@ -4176,9 +4218,10 @@ const merge = Command.make("merge", { pr: prArgument }, Effect.fn("merge")(funct
4176
4218
  withdrawnAt: yield* withdrawnAt(repo, number)
4177
4219
  }));
4178
4220
  yield* mergePr(repo, number);
4179
- yield* Console.log(`${repo}#${number} ${short(head)} squash-merged into ${view.baseRefName}, and ${view.headRefName} deleted`);
4180
- yield* Console.log(`The squash subject is the pull request title: ${view.title}`);
4181
- yield* forgetting(repo, number, { deleted: view.headRefName }).pipe(Effect.catch((error) => Console.log(`\nCould not forget ${repo}#${number}: ${error.message}. Run dw-mc forget ${number} to try again.`)));
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));
4182
4225
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Squash-merge a Ready, stamped pull request of mine and delete its branch"));
4183
4226
  //#endregion
4184
4227
  //#region src/domain/coverage.ts
@@ -4675,15 +4718,12 @@ const askedOf$1 = (flags) => ({
4675
4718
  */
4676
4719
  const printLeftOut = Effect.fn("sweep.printLeftOut")(function* (report) {
4677
4720
  if (report.leftOut === 0) return;
4678
- yield* Console.log("");
4679
- yield* Console.log(`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`);
4721
+ yield* print(following([[`Only ${report.repos.join(", ")}. --all covers all ${report.repos.length + report.leftOut} registered repositories.`]]));
4680
4722
  });
4681
4723
  /** What a sweep could not read, under a heading, so the table above it stands alone. */
4682
4724
  const printTroubles = Effect.fn("sweep.printTroubles")(function* (troubles) {
4683
4725
  if (troubles.length === 0) return;
4684
- yield* Console.log("");
4685
- yield* Console.log("Could not load");
4686
- 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}`))]));
4687
4727
  });
4688
4728
  /**
4689
4729
  * Refreshes what mission control knows about the tracked PRs it covers.
@@ -5108,28 +5148,26 @@ const rebase = Command.make("rebase", { pr: prArgument }, Effect.fn("rebase")(fu
5108
5148
  checks: rollupState(view.statusCheckRollup, settings.ci.ignore),
5109
5149
  stack: stackOf(number, open)
5110
5150
  }));
5111
- const where = `${repo}#${number}`;
5151
+ const paint = yield* Paint;
5112
5152
  const done = yield* rebaseOnto(repo, number, view.baseRefName, view.headRefName);
5113
5153
  if (done._tag === "up-to-date") {
5114
- yield* Console.log(`${where} ${short(view.headRefOid)} already on ${view.baseRefName}`);
5154
+ yield* print([opener(paint, repo, number, view.headRefOid, `already on ${view.baseRefName}`)]);
5115
5155
  return;
5116
5156
  }
5117
5157
  if (done._tag === "conflicted") {
5118
5158
  yield* recordConflict(repo, number, view.headRefOid, done.paths);
5119
- yield* Console.log(`${where} ${short(view.headRefOid)} the rebase onto ${view.baseRefName} conflicted, so it was aborted and nothing was pushed.`);
5120
- if (done.paths.length > 0) {
5121
- yield* Console.log(`It stopped on ${count(done.paths.length, "file")}:`);
5122
- yield* Effect.forEach(done.paths, (path) => Console.log(` ${path}`));
5123
- }
5124
- yield* Effect.forEach([
5125
- ``,
5126
- ` dw-mc resolve ${number}`,
5127
- ``
5128
- ], (line) => Console.log(line));
5129
- 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
+ ]));
5130
5165
  return;
5131
5166
  }
5132
- 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`)]);
5133
5171
  }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Rebase one branch onto its base and push it with a lease"));
5134
5172
  //#endregion
5135
5173
  //#region src/cli/rerun.ts
@@ -5171,10 +5209,8 @@ const rerun = Command.make("rerun", { pr: prArgument }, Effect.fn("rerun")(funct
5171
5209
  }));
5172
5210
  yield* recordRerun(repo, number, view.headRefOid);
5173
5211
  yield* Effect.forEach(unclassified.runs, (run) => rerunFailed(repo, run));
5174
- const where = `${repo}#${number}`;
5175
- yield* Console.log(`${where} ${short(view.headRefOid)} re-ran the failed jobs of ${count(unclassified.runs.length, "workflow run")}`);
5176
- yield* Console.log(`It is flaky because ${flaky}.`);
5177
- 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.`]]));
5178
5214
  }, Effect.catchTag(userFacing, asUserError))).pipe(Command.withDescription("Run a flaky red CI again, once per head"));
5179
5215
  //#endregion
5180
5216
  //#region src/domain/resolve.ts
@@ -5215,7 +5251,7 @@ const Conflicted = Schema.Struct({
5215
5251
  paths: Schema.Array(Schema.String)
5216
5252
  });
5217
5253
  /** The conflict as the JSON the schema defines, rather than as this file spells it. */
5218
- const asJson = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
5254
+ const asJson$1 = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
5219
5255
  /**
5220
5256
  * The prompt a resolve session opens on: what stopped the replay, and the
5221
5257
  * conflict itself as JSON.
@@ -5231,7 +5267,7 @@ const asJson = Schema.encodeEffect(Schema.fromJsonString(Conflicted));
5231
5267
  * after reading what it did, and a session that did them would be resolving the
5232
5268
  * conflict for me rather than with me.
5233
5269
  */
5234
- const promptFor = (conflicted) => Effect.map(asJson(conflicted), (json) => [
5270
+ const promptFor = (conflicted) => Effect.map(asJson$1(conflicted), (json) => [
5235
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.`,
5236
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.`,
5237
5273
  "Do not run git rebase --continue, do not commit and do not push. I read the resolution and do all three myself.",
@@ -5263,7 +5299,7 @@ const printFlag = Flag.Boolean("print").pipe(Flag.withDefault(false), Flag.withD
5263
5299
  const resolve = Command.make("resolve", {
5264
5300
  pr: prArgument,
5265
5301
  print: printFlag
5266
- }, Effect.fn("resolve")(function* ({ pr, print }) {
5302
+ }, Effect.fn("resolve")(function* ({ pr, print: promptOnly }) {
5267
5303
  const { number, repo, launcher } = yield* forPr(pr);
5268
5304
  const [view, open, me] = yield* reading(`${repo}#${number}`, Effect.all([
5269
5305
  prView(repo, number),
@@ -5290,43 +5326,34 @@ const resolve = Command.make("resolve", {
5290
5326
  title: view.title,
5291
5327
  paths
5292
5328
  });
5293
- if (print) {
5329
+ if (promptOnly) {
5294
5330
  yield* Console.log(yield* promptFor(conflicted(conflict?.paths ?? [])));
5295
5331
  return;
5296
5332
  }
5297
- const where = `${repo}#${number}`;
5333
+ const paint = yield* Paint;
5298
5334
  const worktree = yield* standingWorktree(repo, number, view.headRefName, "rebase");
5299
- 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.`;
5300
5337
  const stopped = yield* rebaseInPlace(worktree.directory, view.baseRefName);
5301
5338
  if (stopped._tag === "replayed") {
5302
- 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.");
5303
- yield* Console.log(`The worktree stands where it replayed, and the push onto ${view.headRefName} is yours:`);
5304
- yield* Effect.forEach([
5305
- ``,
5306
- ` cd ${worktree.directory}`,
5307
- ` git push`,
5308
- ``
5309
- ], (line) => Console.log(line));
5310
- 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
+ ]));
5311
5344
  return;
5312
5345
  }
5313
- yield* Console.log(`It stopped on ${count(stopped.paths.length, "file")}:`);
5314
- yield* Effect.forEach(stopped.paths, (path) => Console.log(` ${path}`));
5346
+ yield* print(following([stoppedOn(paint, stopped.paths)]));
5315
5347
  const ended = yield* steeredSession({
5316
5348
  launcher,
5317
5349
  directory: worktree.directory,
5318
5350
  prompt: yield* promptFor(conflicted(stopped.paths))
5319
5351
  });
5320
- yield* Console.log(ended === 0 ? "The session is over." : `The session ended with ${ended}.`);
5321
- yield* Console.log("Nothing was committed or pushed for you; the rebase stands where it stopped.");
5322
- yield* Effect.forEach([
5323
- ``,
5324
- ` cd ${worktree.directory}`,
5325
- ` git rebase --continue`,
5326
- ` git push`,
5327
- ``
5328
- ], (line) => Console.log(line));
5329
- 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
+ ]));
5330
5357
  }, Effect.catchTag(userFacingAndSession, asUserError))).pipe(Command.withDescription("Open a session on the conflict that stopped a rebase, in a worktree of my own"));
5331
5358
  //#endregion
5332
5359
  //#region src/adapters/notify.ts
@@ -5519,7 +5546,7 @@ const reviewOn = Effect.fn("review.reviewOn")(function* (options) {
5519
5546
  tools: doing.tools + 1,
5520
5547
  subagents: doing.subagents + (tool === "Agent" ? 1 : 0)
5521
5548
  };
5522
- return says(saying(doing), ` · ${tool}`);
5549
+ return says(saying(doing), indent(`· ${tool}`));
5523
5550
  }
5524
5551
  }));
5525
5552
  const answered = Result.isFailure(run.findings) ? Effect.fail(run.findings.failure) : Effect.succeed(run.findings.success);
@@ -5613,11 +5640,12 @@ const review = Command.make("review", {
5613
5640
  promptOnly,
5614
5641
  commandOnly
5615
5642
  });
5643
+ const paint = yield* Paint;
5616
5644
  const view = yield* prView(repo, number);
5617
- yield* Console.log(`${repo}#${number} ${view.title}`);
5645
+ yield* print([`${repo}#${number} ${paint.dim(view.title)}`]);
5618
5646
  const since = force ? null : skippedSince(yield* askedOf(repo, number, view.headRefOid), settings.review.docs_only);
5619
5647
  if (since !== null) {
5620
- 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.`)]);
5621
5649
  return;
5622
5650
  }
5623
5651
  const about = {
@@ -5630,7 +5658,7 @@ const review = Command.make("review", {
5630
5658
  const turn = turnFor(asked, about);
5631
5659
  yield* Effect.gen(function* () {
5632
5660
  const ran = yield* withWorktree(repo, number, (worktree) => Effect.gen(function* () {
5633
- 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)}`)]);
5634
5662
  const got = yield* Effect.result(reviewOn({
5635
5663
  launcher,
5636
5664
  directory: worktree.directory,
@@ -5660,21 +5688,14 @@ const review = Command.make("review", {
5660
5688
  yield* runs.set(runKey(repo, number, run.head), run);
5661
5689
  yield* latest.set(latestKey(repo, number), { head: run.head });
5662
5690
  yield* reports.set(reportKey(repo, number, run.head), reportDocument(run, view.title, got.prose ?? ""));
5663
- yield* Console.log("");
5664
5691
  const detail = detailOf(run);
5665
- if (detail !== null) yield* Console.log(` reported nothing: ${detail}`);
5666
- else {
5667
- const found = reportedBy(run);
5668
- if (found !== null) {
5669
- if (got.prose !== null) {
5670
- yield* Console.log(got.prose);
5671
- yield* Console.log("");
5672
- }
5673
- yield* Console.log(summary(found, settings.stamp.blocks_on));
5674
- for (const line of lines$1(found)) yield* Console.log(` ${line}`);
5675
- }
5676
- }
5677
- 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
+ ]));
5678
5699
  yield* unreported(run, number);
5679
5700
  }).pipe(Effect.onExit((exit) => announce("dw-mc review", `${repo}#${number} ${Exit.isSuccess(exit) ? "reviewed" : "could not be reviewed"}`)));
5680
5701
  }, Effect.catchTag(userFacingAndGit, asUserError))).pipe(Command.withDescription("Review one pull request on Claude Code, in a throwaway worktree"));
@@ -5704,17 +5725,87 @@ const stampCommand = Command.make("stamp", {
5704
5725
  }, Effect.fn("stamp")(function* ({ pr, withdraw: byHand }) {
5705
5726
  const { number, repo } = yield* forPr(pr);
5706
5727
  const facts = yield* swept(repo, number);
5707
- const where = `${repo}#${number} ${short(facts.head)}`;
5728
+ const paint = yield* Paint;
5708
5729
  if (byHand) {
5709
5730
  yield* withdraw(repo, number, facts.head);
5710
- 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")]);
5711
5732
  return;
5712
5733
  }
5713
5734
  const stamp = yield* stampOf(facts);
5714
- 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}`)]);
5715
5736
  }, Effect.catchTag(["ConfigMalformed"], asUserError))).pipe(Command.withDescription("Print my stamp on one pull request, or withdraw it by hand"));
5716
5737
  //#endregion
5717
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
+ });
5718
5809
  /** What moved a row, said on its own line above the group it now sits in. */
5719
5810
  const movement = (placed, since) => {
5720
5811
  if (since._tag !== "moved") return [];
@@ -5751,11 +5842,17 @@ const lines = (grouped, stamped, sinceOf, paint) => {
5751
5842
  * The table of what every tracked PR waits on.
5752
5843
  *
5753
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.
5754
5846
  */
5755
5847
  const status = Command.make("status", {
5756
5848
  repo: repoFlag,
5757
- all: allFlag
5849
+ all: allFlag,
5850
+ json: jsonFlag
5758
5851
  }, Effect.fn("status")(function* (flags) {
5852
+ if (flags.json) {
5853
+ yield* printJson(askedOf$1(flags));
5854
+ return;
5855
+ }
5759
5856
  const report = yield* sweeping(askedOf$1(flags));
5760
5857
  if (report.repos.length === 0) {
5761
5858
  yield* Console.log("No repositories registered. Run dw-mc init inside a repository to register it.");
@@ -5773,12 +5870,7 @@ const status = Command.make("status", {
5773
5870
  //#region src/cli/uninstall.ts
5774
5871
  const configFlag = Flag.Boolean("config").pipe(Flag.withDefault(false), Flag.withDescription("Take the configuration file too, and not only the state"));
5775
5872
  const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Remove a session worktree that still holds work of mine"));
5776
- const block = (heading, rows) => rows.length === 0 ? [] : [
5777
- heading,
5778
- ...table(rows).map((line) => ` ${line}`),
5779
- ""
5780
- ];
5781
- const removes = (state, config, paint) => block("Removes", [[
5873
+ const removes = (state, config, paint) => block("Removes", table([[
5782
5874
  paint.dim(state.directory),
5783
5875
  state.size,
5784
5876
  "every record, report, clone and worktree"
@@ -5786,8 +5878,8 @@ const removes = (state, config, paint) => block("Removes", [[
5786
5878
  paint.dim(config),
5787
5879
  "",
5788
5880
  "the runner and every repository registered"
5789
- ]]]);
5790
- 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])));
5791
5883
  /**
5792
5884
  * Takes the tool's own footprint off this machine, which no package manager
5793
5885
  * does.
@@ -5818,20 +5910,17 @@ const uninstall = Command.make("uninstall", {
5818
5910
  const found = yield* inventory;
5819
5911
  const file = yield* configPath;
5820
5912
  const configured = yield* fs.exists(file);
5821
- 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" ? [{
5822
5914
  at,
5823
5915
  detail: holding_.detail
5824
5916
  }] : [])).pipe(Effect.map((found_) => found_.flat()));
5825
- yield* Effect.forEach(removes({
5917
+ yield* print([...separated([removes({
5826
5918
  directory: found.directory,
5827
5919
  size: weight(everything$1(found))
5828
- }, alsoConfig && configured ? file : void 0, paint), (line) => Console.log(line));
5829
- if (holds.length > 0) {
5830
- yield* Effect.forEach(held(holds, paint), (line) => Console.log(line));
5831
- if (!force) {
5832
- yield* Console.log("Nothing was removed. Push that work or drop it, or run this again with --force.");
5833
- return;
5834
- }
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;
5835
5924
  }
5836
5925
  if (!yes && !(yield* confirm("Remove it all?"))) {
5837
5926
  yield* Console.log("Nothing was removed.");
@@ -5851,7 +5940,7 @@ const uninstall = Command.make("uninstall", {
5851
5940
  * Running from source leaves the constant undeclared rather than undefined, so
5852
5941
  * the check has to be `typeof` and the fallback is what a test reads.
5853
5942
  */
5854
- const version = "0.7.0";
5943
+ const version = "0.8.0";
5855
5944
  /** Where the project lives, printed beside the version in the header. */
5856
5945
  const projectUrl = "github.com/dominikwozniak/dw-mc";
5857
5946
  const subcommands = [
@@ -5909,18 +5998,41 @@ const header = (colors) => {
5909
5998
  return meta === "" ? paint.cyan(line) : `${paint.cyan(line.padEnd(width))}${gap}${paint.dim(meta)}`;
5910
5999
  }).join("\n");
5911
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)}`];
5912
6022
  /**
5913
6023
  * The formatter for the two screens the tool introduces itself on, with the
5914
- * 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.
5915
6026
  *
5916
6027
  * The root command is the one whose help document lists subcommands, which is
5917
6028
  * what keeps the header off `dw-mc status --help`.
5918
6029
  */
5919
- const formatter = (colors) => {
6030
+ const formatter = (colors, setupLines) => {
5920
6031
  const inner = CliOutput.defaultFormatter({ colors });
5921
6032
  const drawn = header(colors);
6033
+ const withSetup = setupLines.length === 0 ? drawn : `${drawn}\n\n${setupLines.join("\n")}`;
5922
6034
  return {
5923
- 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)}`,
5924
6036
  formatVersion: (name, printed) => `${drawn}\n\n${inner.formatVersion(name, printed)}`,
5925
6037
  formatCliError: inner.formatCliError,
5926
6038
  formatError: inner.formatError,
@@ -5933,17 +6045,30 @@ const formatter = (colors) => {
5933
6045
  * It replaces the formatter under `--help` and `--version` rather than for the
5934
6046
  * run, because a failed parse prints the help screen through the same
5935
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.
5936
6053
  */
5937
- const layer = Layer.unwrap(Effect.map(screened, (colors) => {
5938
- const introducing = Effect.provideService(CliOutput.Formatter, formatter(colors));
5939
- 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({
5940
6064
  flag: builtIn.flag,
5941
- run: (value, context) => introducing(builtIn.run(value, context))
5942
- }) : 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) });
5943
6068
  }));
5944
6069
  //#endregion
5945
6070
  //#region src/cli/bin.ts
5946
- 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);
5947
6072
  //#endregion
5948
6073
  export {};
5949
6074