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/README.md +1 -1
- package/dist/bin.js +1238 -1113
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
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/
|
|
1558
|
-
/**
|
|
1559
|
-
const
|
|
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
|
-
*
|
|
1561
|
+
* Every severity word a review may answer with.
|
|
1574
1562
|
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1578
|
-
*
|
|
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
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
-
/**
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1622
|
-
*
|
|
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
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
*
|
|
1627
|
+
* The schema every runner must satisfy, as the JSON Schema a runner is handed.
|
|
1654
1628
|
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
|
|
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
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
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
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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
|
-
*
|
|
1651
|
+
* The findings that withhold the stamp: everything at `blocksOn` or above it.
|
|
1720
1652
|
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
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
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
*
|
|
1661
|
+
* What a review run came to, which is what its second turn reported.
|
|
1773
1662
|
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
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
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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
|
|
1672
|
+
* One review run against a tracked PR at a specific head commit.
|
|
1796
1673
|
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
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
|
|
1802
|
-
|
|
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
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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
|
-
*
|
|
1830
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1835
|
-
*
|
|
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
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
*
|
|
1722
|
+
* The run at one head, or none where nothing has reviewed it.
|
|
1855
1723
|
*
|
|
1856
|
-
*
|
|
1857
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
*
|
|
1894
|
-
* left on the diff.
|
|
1741
|
+
* What a run reported, or null where it reported nothing at all.
|
|
1895
1742
|
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
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
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
*
|
|
1753
|
+
* Why a run reported nothing, or null where it reported.
|
|
1917
1754
|
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
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
|
|
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
|
|
1769
|
+
* The re-run rule: the head this run is skipped against, or null where it runs.
|
|
1954
1770
|
*
|
|
1955
|
-
*
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
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
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1978
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1983
|
-
*
|
|
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
|
|
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
|
-
*
|
|
1817
|
+
* What the review runs on `head` say about it, for the stamp to rest on.
|
|
1988
1818
|
*
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
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
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
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
|
|
2000
|
-
yield*
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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/
|
|
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
|
-
*
|
|
2051
|
-
*
|
|
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
|
-
*
|
|
2054
|
-
*
|
|
2055
|
-
*
|
|
2056
|
-
*
|
|
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
|
-
*
|
|
2060
|
-
*
|
|
2061
|
-
|
|
2062
|
-
|
|
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
|
-
*
|
|
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
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
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/
|
|
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
|
|
1950
|
+
* The two blocks a cleanup writes: what it takes and what it leaves.
|
|
2111
1951
|
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
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
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
]
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
-
*
|
|
2126
|
-
* session in it.
|
|
1975
|
+
* Takes back the disk the tool spent on itself, and nothing that is mine.
|
|
2127
1976
|
*
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
2131
|
-
*
|
|
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
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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/
|
|
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
|
-
*
|
|
2094
|
+
* The open pull requests I authored in `repo`.
|
|
2156
2095
|
*
|
|
2157
|
-
*
|
|
2158
|
-
*
|
|
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
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
])
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
*
|
|
2117
|
+
* One entry of a PR's status check rollup.
|
|
2186
2118
|
*
|
|
2187
|
-
*
|
|
2188
|
-
*
|
|
2189
|
-
*
|
|
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
|
|
2192
|
-
|
|
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
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
-
*
|
|
2234
|
-
*
|
|
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
|
|
2154
|
+
const viewFields = "number,title,url,isDraft,headRefOid,headRefName,baseRefName,author,isCrossRepository,mergeable,reviewDecision,statusCheckRollup";
|
|
2242
2155
|
/**
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
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
|
|
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
|
-
*
|
|
2253
|
-
* my last commit and my acknowledgement.
|
|
2176
|
+
* Every open pull request on a repository, by branch.
|
|
2254
2177
|
*
|
|
2255
|
-
*
|
|
2256
|
-
*
|
|
2257
|
-
*
|
|
2258
|
-
*
|
|
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
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2283
|
-
*
|
|
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
|
|
2286
|
-
const
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
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
|
|
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
|
-
*
|
|
2303
|
-
*
|
|
2304
|
-
*
|
|
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
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
};
|
|
2312
|
-
|
|
2313
|
-
|
|
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
|
-
*
|
|
2253
|
+
* The repository paths that changed between two commits.
|
|
2345
2254
|
*
|
|
2346
|
-
*
|
|
2347
|
-
*
|
|
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
|
|
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
|
|
2275
|
+
* The commits on a pull request.
|
|
2352
2276
|
*
|
|
2353
|
-
*
|
|
2354
|
-
*
|
|
2355
|
-
*
|
|
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
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2388
|
-
*
|
|
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
|
|
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
|
|
2430
|
-
* is
|
|
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
|
|
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
|
-
*
|
|
2438
|
-
* output is read with.
|
|
2309
|
+
* Squash-merges a pull request and deletes the branch it stood on.
|
|
2439
2310
|
*
|
|
2440
|
-
*
|
|
2441
|
-
*
|
|
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
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2454
|
-
*
|
|
2455
|
-
* `
|
|
2456
|
-
*
|
|
2457
|
-
*
|
|
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
|
|
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
|
|
2432
|
+
* The failures a command owes me a sentence for rather than a stack.
|
|
2462
2433
|
*
|
|
2463
|
-
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
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
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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
|
|
2447
|
+
* The same, for a command that cuts a standing worktree and opens an agent
|
|
2448
|
+
* session in it.
|
|
2476
2449
|
*
|
|
2477
|
-
* `
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
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
|
|
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/
|
|
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
|
|
2477
|
+
* What GitHub says about a pull request, in this tool's words.
|
|
2486
2478
|
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
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
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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
|
-
*
|
|
2507
|
+
* Everything the bucket rules are allowed to know about a tracked PR.
|
|
2497
2508
|
*
|
|
2498
|
-
* It is a schema because a
|
|
2499
|
-
*
|
|
2500
|
-
*
|
|
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
|
|
2513
|
+
const Facts = Schema.Struct({
|
|
2503
2514
|
repo: Schema.String,
|
|
2504
2515
|
number: Schema.Int,
|
|
2505
|
-
|
|
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
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
/**
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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
|
-
*
|
|
2555
|
+
* Why a PR is mine to move when somebody has said something I have not
|
|
2556
|
+
* answered.
|
|
2566
2557
|
*
|
|
2567
|
-
*
|
|
2568
|
-
*
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
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
|
|
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
|
|
2565
|
+
* Why a PR is mine to move when a review run found something that withholds
|
|
2566
|
+
* the stamp.
|
|
2578
2567
|
*
|
|
2579
|
-
*
|
|
2580
|
-
*
|
|
2581
|
-
*
|
|
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
|
|
2572
|
+
const blockedBy = (n) => `${n} blocking finding${n === 1 ? "" : "s"}`;
|
|
2584
2573
|
/**
|
|
2585
|
-
*
|
|
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
|
-
*
|
|
2588
|
-
*
|
|
2589
|
-
*
|
|
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
|
|
2582
|
+
const answeredAt = (facts) => later(later(facts.myLastCommentAt, facts.myLastCommitAt), facts.acknowledgedAt);
|
|
2592
2583
|
/**
|
|
2593
|
-
* The
|
|
2594
|
-
*
|
|
2595
|
-
*
|
|
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
|
|
2604
|
-
if (
|
|
2605
|
-
|
|
2606
|
-
|
|
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
|
-
*
|
|
2598
|
+
* What is actually true of a PR nothing is waiting on.
|
|
2612
2599
|
*
|
|
2613
|
-
*
|
|
2614
|
-
*
|
|
2615
|
-
*
|
|
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
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
"",
|
|
2624
|
-
|
|
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
|
-
*
|
|
2617
|
+
* The bucket a tracked PR sits in, and the reason for it.
|
|
2629
2618
|
*
|
|
2630
|
-
*
|
|
2631
|
-
*
|
|
2632
|
-
*
|
|
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
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
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
|
-
*
|
|
2666
|
+
* A segment of nothing but dots, which no repository is called.
|
|
2642
2667
|
*
|
|
2643
|
-
*
|
|
2644
|
-
*
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
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
|
-
*
|
|
2650
|
-
*
|
|
2651
|
-
*
|
|
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
|
|
2654
|
-
const
|
|
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
|
-
|
|
2657
|
-
|
|
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
|
|
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
|
|
2976
|
-
const people = view.people.map((thread) =>
|
|
2977
|
-
const bots = view.bots.map((thread) =>
|
|
2978
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
3046
|
-
|
|
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$
|
|
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) =>
|
|
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$
|
|
3143
|
+
yield* Console.log(yield* asJson$3(found));
|
|
3108
3144
|
return;
|
|
3109
3145
|
}
|
|
3110
|
-
yield*
|
|
3111
|
-
|
|
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$
|
|
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$
|
|
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*
|
|
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 (
|
|
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*
|
|
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*
|
|
3640
|
-
|
|
3641
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
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
|
-
|
|
3776
|
-
yield*
|
|
3777
|
-
|
|
3778
|
-
|
|
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
|
-
|
|
4180
|
-
yield*
|
|
4181
|
-
yield* forgetting(repo, number, { deleted: view.headRefName }).pipe(Effect.catch((error) =>
|
|
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*
|
|
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*
|
|
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
|
|
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*
|
|
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*
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
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*
|
|
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
|
|
5175
|
-
yield*
|
|
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 (
|
|
5329
|
+
if (promptOnly) {
|
|
5294
5330
|
yield* Console.log(yield* promptFor(conflicted(conflict?.paths ?? [])));
|
|
5295
5331
|
return;
|
|
5296
5332
|
}
|
|
5297
|
-
const
|
|
5333
|
+
const paint = yield* Paint;
|
|
5298
5334
|
const worktree = yield* standingWorktree(repo, number, view.headRefName, "rebase");
|
|
5299
|
-
yield*
|
|
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*
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
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*
|
|
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*
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
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),
|
|
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*
|
|
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*
|
|
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*
|
|
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
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
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
|
|
5728
|
+
const paint = yield* Paint;
|
|
5708
5729
|
if (byHand) {
|
|
5709
5730
|
yield* withdraw(repo, number, facts.head);
|
|
5710
|
-
yield*
|
|
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*
|
|
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
|
|
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*
|
|
5917
|
+
yield* print([...separated([removes({
|
|
5826
5918
|
directory: found.directory,
|
|
5827
5919
|
size: weight(everything$1(found))
|
|
5828
|
-
}, alsoConfig && configured ? file : void 0, paint), (
|
|
5829
|
-
if (holds.length > 0) {
|
|
5830
|
-
yield*
|
|
5831
|
-
|
|
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.
|
|
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) : `${
|
|
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.
|
|
5938
|
-
const
|
|
5939
|
-
|
|
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) =>
|
|
5942
|
-
})
|
|
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
|
|
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
|
|