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