@avi2dg/checks 0.62.0 → 0.63.1
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/CHANGELOG.md +24 -0
- package/docs/bins.md +2 -0
- package/docs/gates/checks-night-run.md +34 -2
- package/package.json +4 -3
- package/src/core/git.ts +9 -0
- package/src/core/lint.ts +2 -2
- package/src/delivery/docs-only.ts +2 -4
- package/src/delivery/mutation-job.ts +14 -17
- package/src/testing/mutation-scope.ts +1 -3
- package/src/testing/night-run-findings.ts +20 -0
- package/src/testing/night-run.ts +58 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.63.1
|
|
6
|
+
|
|
7
|
+
Released 2026-11-03.
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
|
|
11
|
+
- **testing:** note a cleared mutation issue once [#29](https://github.com/avi2d/checks/pull/29)
|
|
12
|
+
|
|
13
|
+
## 0.63.0
|
|
14
|
+
|
|
15
|
+
Released 2026-11-03.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **testing:** close cleared test and gate issues and note cleared mutation issues [#28](https://github.com/avi2d/checks/pull/28)
|
|
20
|
+
|
|
21
|
+
## 0.62.1
|
|
22
|
+
|
|
23
|
+
Released 2026-11-02.
|
|
24
|
+
|
|
25
|
+
### Fixes
|
|
26
|
+
|
|
27
|
+
- **dependencies:** raise minimatch to 11.0.3 and qs override to 6.16.1 [#25](https://github.com/avi2d/checks/pull/25)
|
|
28
|
+
|
|
5
29
|
## 0.62.0
|
|
6
30
|
|
|
7
31
|
Released 2026-11-02.
|
package/docs/bins.md
CHANGED
|
@@ -51,6 +51,8 @@ These bins run on their own:
|
|
|
51
51
|
- [`checks-mutation-scope`](gates/checks-mutation-scope.md) turns a branch's diff into the files its mutation runs mutate.
|
|
52
52
|
- [`checks-subsumed-tests`](gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
|
|
53
53
|
- [`checks-night-run`](gates/checks-night-run.md) opens or comments on one GitHub issue per finding of a night run on `main`, each file whose mutation score fell, each failing test, and each failed nightly gate.
|
|
54
|
+
It closes the issue of a test or gate that a later night no longer reports.
|
|
55
|
+
It comments on a mutation issue a later night no longer reports, and leaves it open.
|
|
54
56
|
- [`checks-changelog`](gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
|
|
55
57
|
- [`checks-release-notes`](gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
|
|
56
58
|
- [`checks-release-report`](gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
|
|
@@ -5,6 +5,8 @@ audience: consumers
|
|
|
5
5
|
# checks-night-run
|
|
6
6
|
|
|
7
7
|
`checks-night-run` turns a night run on `main` into GitHub issues, one for each file whose mutation score fell, one for each failing test, and one for each failed nightly gate.
|
|
8
|
+
It closes the issue of a test or gate that a later night no longer reports.
|
|
9
|
+
It comments on a mutation issue a later night no longer reports, and leaves it open.
|
|
8
10
|
|
|
9
11
|
## What it checks
|
|
10
12
|
|
|
@@ -40,8 +42,37 @@ Any other finding opens one issue labelled `night-run`.
|
|
|
40
42
|
The issue and the comment name tonight's run, the file and the test, the score of both nights or the failed gate.
|
|
41
43
|
The body holds the file, the full test name or the failed gate, and the finding key.
|
|
42
44
|
A title longer than GitHub's 256 characters is cut to at most 256 and ends in `…`.
|
|
43
|
-
A night with no finding
|
|
45
|
+
A night with no finding creates no label and opens no issue.
|
|
46
|
+
It still reads the open issues, to close or comment on each one whose finding is gone.
|
|
44
47
|
Closing an issue ends it, and the same finding on a later night opens a new one.
|
|
48
|
+
|
|
49
|
+
## How it closes an issue
|
|
50
|
+
|
|
51
|
+
An open test or gate issue whose key tonight's findings lack gets one comment naming tonight's run and the key.
|
|
52
|
+
It then closes as completed.
|
|
53
|
+
An open mutation issue whose key tonight's findings lack gets one comment that names tonight's run and the key, and stays open.
|
|
54
|
+
A closed issue is no longer read, so a later night without the finding comments on it no more.
|
|
55
|
+
A comment that reports the finding carries a reported marker, and a note that a mutation finding is gone carries a noted marker.
|
|
56
|
+
A mutation issue whose latest marked comment already notes the finding gone gets no further note.
|
|
57
|
+
So a mutation finding gone for many nights is noted once, and noted again only after a later night reports it again.
|
|
58
|
+
A person's comment carries no marker, so it never makes the run note an issue twice.
|
|
59
|
+
It judges a key only from a report it read tonight:
|
|
60
|
+
|
|
61
|
+
| Key | Closes when |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `test:<file> > <describe> > <name>` | tonight read a JUnit report, the test is not failing, and `<file>`'s worker did not crash |
|
|
64
|
+
| `test:<file>` | tonight read a JUnit report and `<file>`'s worker did not crash |
|
|
65
|
+
| `gates:<gate>` | tonight read a lint log that passes or names its failed gates, and `<gate>` is not among them |
|
|
66
|
+
| `mutation:<file>` | never |
|
|
67
|
+
|
|
68
|
+
A crashed worker loses every result of its file, so the issue of a test in that file stays open.
|
|
69
|
+
A lint log that is undecided or never finished says nothing of any one gate, so every gate issue stays open.
|
|
70
|
+
A mutation finding is a fall from one night to the next.
|
|
71
|
+
A later night that does not fall again still holds the lower score.
|
|
72
|
+
A night that compared scores comments on the mutation issue without closing it, since a score can recover and drop again.
|
|
73
|
+
So a person closes a mutation issue once the score is back.
|
|
74
|
+
An issue with no key in its body is never read, so a person's issue under the `night-run` label is never commented on or closed.
|
|
75
|
+
A run off `main` comments on and closes no issue.
|
|
45
76
|
Agents and people fix each issue as a task of its own, so no pull request waits for a night run.
|
|
46
77
|
A run on any ref but `refs/heads/main` files no issue and records no score, and lists each finding in its log only.
|
|
47
78
|
|
|
@@ -73,7 +104,7 @@ At least one report is given.
|
|
|
73
104
|
|
|
74
105
|
| Code | When |
|
|
75
106
|
| --- | --- |
|
|
76
|
-
| 0 | it filed every finding and recorded tonight's scores, it found nothing to file, or the run is off `main` and it listed each finding |
|
|
107
|
+
| 0 | it filed every finding, closed each issue whose finding is gone and recorded tonight's scores, it found nothing to file, or the run is off `main` and it listed each finding |
|
|
77
108
|
| 2 | an argument does not parse, a report or the lint log does not decode, a GitHub Actions variable is unset or a `gh` call fails |
|
|
78
109
|
|
|
79
110
|
It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
@@ -84,6 +115,7 @@ It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
|
84
115
|
checks-night-run: the night run https://github.com/acme/widget/actions/runs/12 compared with the night run https://github.com/acme/widget/actions/runs/11; 2 finding(s)
|
|
85
116
|
opened #31 for mutation:src/cart.ts
|
|
86
117
|
commented on #27 for test:tests/unit/cart.test.ts > totals > rounds half to even
|
|
118
|
+
closed #24 for gates:checks-repetition
|
|
87
119
|
checks-night-run: recorded 42 file score(s) in reports/night-run.json
|
|
88
120
|
```
|
|
89
121
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.1",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -294,10 +294,11 @@
|
|
|
294
294
|
"@total-typescript/ts-reset": "0.6.1",
|
|
295
295
|
"commonmark": "0.31.2",
|
|
296
296
|
"knip": "6.38.0",
|
|
297
|
-
"minimatch": "
|
|
297
|
+
"minimatch": "11.0.3"
|
|
298
298
|
},
|
|
299
299
|
"overrides": {
|
|
300
|
-
"
|
|
300
|
+
"minimatch": "11.0.3",
|
|
301
|
+
"qs": "6.16.1",
|
|
301
302
|
"smol-toml": "1.9.0"
|
|
302
303
|
},
|
|
303
304
|
"patchedDependencies": {
|
package/src/core/git.ts
CHANGED
|
@@ -107,6 +107,15 @@ export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
|
107
107
|
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
export function pathsOf(change: Change): readonly string[] {
|
|
111
|
+
return change.kind === "renamed" ? [change.from, change.path] : [change.path];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const pathsSinceMergeBase = Effect.fn("pathsSinceMergeBase")(function* (base: string, head: string, cwd?: string) {
|
|
115
|
+
const mergeBase = (yield* git(["merge-base", base, head], cwd)).trim();
|
|
116
|
+
return (yield* changedPaths(mergeBase, head, [], cwd)).flatMap(pathsOf);
|
|
117
|
+
});
|
|
118
|
+
|
|
110
119
|
const ESCAPES: Readonly<Record<string, string>> = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
111
120
|
|
|
112
121
|
// Git quotes a path holding a quote, a backslash or a control character, and ends one holding a space with a tab.
|
package/src/core/lint.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { EVERY_REPOSITORY, NIGHT_GATES, PR_GATES, WHOLE_TREE, type KitGate } from "./gates.ts";
|
|
5
|
-
import { changedPaths, defaultBranch, git } from "./git.ts";
|
|
5
|
+
import { changedPaths, defaultBranch, git, pathsOf } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
7
7
|
import { FAST_TREE_GATES, selectScope } from "./scope.ts";
|
|
8
8
|
|
|
@@ -190,7 +190,7 @@ const scopedGates = Effect.fn("scopedGates")(function* (range: Range, root: stri
|
|
|
190
190
|
if (range.refs.length === 1) return { bins: undefined, detail: "the full run, since a lone tip scopes nothing" };
|
|
191
191
|
const [base, head] = range.refs;
|
|
192
192
|
const changes = yield* changedPaths(base, head, [], root);
|
|
193
|
-
const committed = changes.flatMap(
|
|
193
|
+
const committed = changes.flatMap(pathsOf);
|
|
194
194
|
const working = (yield* git(["diff", "--no-renames", "--name-only", "-z", "HEAD", "--"], root)).split("\0").filter(Boolean);
|
|
195
195
|
const untracked = (yield* git(["ls-files", "--others", "--exclude-standard", "-z"], root)).split("\0").filter(Boolean);
|
|
196
196
|
const scope = selectScope([...committed, ...working, ...untracked]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
-
import {
|
|
3
|
+
import { pathsSinceMergeBase } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
|
|
6
6
|
const NAME = "docs-only";
|
|
@@ -18,9 +18,7 @@ const check = Effect.gen(function* () {
|
|
|
18
18
|
yield* Console.log("docs-only=false");
|
|
19
19
|
return true;
|
|
20
20
|
}
|
|
21
|
-
const
|
|
22
|
-
const changes = yield* changedPaths(mergeBase, head, []);
|
|
23
|
-
const paths = changes.flatMap((change) => (change.kind === "renamed" ? [change.from, change.path] : [change.path]));
|
|
21
|
+
const paths = yield* pathsSinceMergeBase(base, head);
|
|
24
22
|
const full = paths.filter((path) => !isDocsPath(path));
|
|
25
23
|
if (paths.length === 0) {
|
|
26
24
|
yield* Console.error(`${NAME}: the range changes no file, so the full run stays`);
|
|
@@ -143,27 +143,24 @@ function mutationBin([program = "", subcommand]: Command): boolean {
|
|
|
143
143
|
return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
const wanted = new Set<string>();
|
|
148
|
-
const lookup = (file: string) => {
|
|
149
|
-
if (!read.has(file)) wanted.add(file);
|
|
150
|
-
return files.get(file);
|
|
151
|
-
};
|
|
152
|
-
for (const run of runs) runsMutation(run, scripts, lookup);
|
|
153
|
-
return [...wanted];
|
|
154
|
-
}
|
|
146
|
+
type FilesWanted = (runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>) => readonly string[];
|
|
155
147
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
148
|
+
type Walk = (run: Run, scripts: ReadonlyMap<string, string>, files: (file: string) => string | undefined) => void;
|
|
149
|
+
|
|
150
|
+
function wantedBy(walk: Walk): FilesWanted {
|
|
151
|
+
return (runs, scripts, files, read) => {
|
|
152
|
+
const wanted = new Set<string>();
|
|
153
|
+
const lookup = (file: string) => {
|
|
154
|
+
if (!read.has(file)) wanted.add(file);
|
|
155
|
+
return files.get(file);
|
|
156
|
+
};
|
|
157
|
+
for (const run of runs) walk(run, scripts, lookup);
|
|
158
|
+
return [...wanted];
|
|
161
159
|
};
|
|
162
|
-
for (const run of runs) runsBin(run, scripts, lookup, () => false, true);
|
|
163
|
-
return [...wanted];
|
|
164
160
|
}
|
|
165
161
|
|
|
166
|
-
|
|
162
|
+
const filesWanted = wantedBy(runsMutation);
|
|
163
|
+
const scheduleFilesWanted = wantedBy((run, scripts, files) => runsBin(run, scripts, files, () => false, true));
|
|
167
164
|
|
|
168
165
|
const readFiles = Effect.fn("readFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[], wanted: FilesWanted) {
|
|
169
166
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { escape, minimatch } from "minimatch";
|
|
4
|
-
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "../core/git.ts";
|
|
4
|
+
import { changedPaths, git, pathsAt, pathsOf, rangeEnds, type Change } from "../core/git.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
7
7
|
|
|
@@ -87,8 +87,6 @@ function scopedNames(change: Change, mutated: (file: string) => boolean): readon
|
|
|
87
87
|
return mutated(change.path) ? [change.path] : [];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const pathsOf = (change: Change): readonly string[] => (change.kind === "renamed" ? [change.from, change.path] : [change.path]);
|
|
91
|
-
|
|
92
90
|
export function pickFromDiff(changes: readonly Change[], mutated: (file: string) => boolean): Picked {
|
|
93
91
|
const paths = changes.flatMap(pathsOf);
|
|
94
92
|
return {
|
|
@@ -43,6 +43,9 @@ function fell(before: Score, after: Score): boolean {
|
|
|
43
43
|
return before.valid > 0 && after.valid > 0 && after.detected * before.valid < before.detected * after.valid;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// A night judges a key only from a report it read, since a report it lacks leaves every finding in it absent.
|
|
47
|
+
export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean };
|
|
48
|
+
|
|
46
49
|
export function keyOf(finding: Finding): string {
|
|
47
50
|
if (finding.kind === "mutation") return `mutation:${finding.file}`;
|
|
48
51
|
if (finding.kind === "test") return `test:${finding.file}${NAME_SEPARATOR}${finding.name}`;
|
|
@@ -86,6 +89,23 @@ export function gatesOf(log: string): readonly Finding[] {
|
|
|
86
89
|
return PASSED_GATES.test(log) ? [] : [{ kind: "gates", file: "checks-lint" }];
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
export function gatesDecided(log: string): boolean {
|
|
93
|
+
return FAILED_GATES.test(log) || PASSED_GATES.test(log);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A mutation finding is a fall between two nights, so a night without it shows no score recovered.
|
|
97
|
+
export function cleared(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
|
|
98
|
+
if (present.has(key)) return false;
|
|
99
|
+
if (key.startsWith("gates:")) return observed.gates;
|
|
100
|
+
if (!key.startsWith("test:")) return false;
|
|
101
|
+
const [file] = key.slice("test:".length).split(NAME_SEPARATOR);
|
|
102
|
+
return observed.tests && !present.has(`test:${file}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function noted(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
|
|
106
|
+
return key.startsWith("mutation:") && !present.has(key) && observed.mutation;
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
export function percent(score: Score): string {
|
|
90
110
|
return `${((score.detected / score.valid) * 100).toFixed(2)}%`;
|
|
91
111
|
}
|
package/src/testing/night-run.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { DEFAULT_BRANCH } from "../core/gates.ts";
|
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
|
|
7
7
|
import { parseReport as parseMutationReport } from "./mutation-compare.ts";
|
|
8
|
-
import { type Finding, findingsOf, gatesOf, keyOf, NightRecordSchema, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
8
|
+
import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, NightRecordSchema, noted, type Observed, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
9
9
|
import { parseReport as parseTestReport } from "./test-report.ts";
|
|
10
10
|
|
|
11
11
|
const NAME = "checks-night-run";
|
|
@@ -43,7 +43,7 @@ export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]):
|
|
|
43
43
|
|
|
44
44
|
export type OpenIssue = { readonly number: number; readonly key: string };
|
|
45
45
|
|
|
46
|
-
export type Filed = { readonly key: string; readonly issue: number; readonly action: "opened" | "commented on" };
|
|
46
|
+
export type Filed = { readonly key: string; readonly issue: number; readonly action: "opened" | "commented on" | "closed" };
|
|
47
47
|
|
|
48
48
|
export class Issues extends Context.Service<
|
|
49
49
|
Issues,
|
|
@@ -52,6 +52,8 @@ export class Issues extends Context.Service<
|
|
|
52
52
|
readonly open: Effect.Effect<readonly OpenIssue[], GitHubFailure>;
|
|
53
53
|
readonly create: (title: string, body: string) => Effect.Effect<number, GitHubFailure>;
|
|
54
54
|
readonly comment: (issue: number, body: string) => Effect.Effect<void, GitHubFailure>;
|
|
55
|
+
readonly close: (issue: number) => Effect.Effect<void, GitHubFailure>;
|
|
56
|
+
readonly comments: (issue: number) => Effect.Effect<readonly string[], GitHubFailure>;
|
|
55
57
|
}
|
|
56
58
|
>()("@avi2dg/checks/testing/Issues") {
|
|
57
59
|
static readonly gh = Layer.effect(
|
|
@@ -64,6 +66,8 @@ export class Issues extends Context.Service<
|
|
|
64
66
|
open: provided(openIssues),
|
|
65
67
|
create: (title, body) => provided(gitHubJson(decodeNumbered, "POST", `${REPOSITORY}/issues`, { title, body, labels: [LABEL] })).pipe(Effect.map(({ number }) => number)),
|
|
66
68
|
comment: (issue, body) => provided(gitHubJson(decodeAny, "POST", `${REPOSITORY}/issues/${issue}/comments`, { body })).pipe(Effect.asVoid),
|
|
69
|
+
close: (issue) => provided(gitHubJson(decodeAny, "PATCH", `${REPOSITORY}/issues/${issue}`, { state: "closed", state_reason: "completed" })).pipe(Effect.asVoid),
|
|
70
|
+
comments: (issue) => provided(issueComments(issue)),
|
|
67
71
|
});
|
|
68
72
|
}),
|
|
69
73
|
);
|
|
@@ -75,6 +79,7 @@ const decodeIssues = Schema.decodeUnknownEffect(
|
|
|
75
79
|
Schema.fromJsonString(Schema.Array(Schema.Struct({ number: Schema.Int, body: Schema.NullOr(Schema.String), pull_request: Schema.optionalKey(Schema.Unknown) }))),
|
|
76
80
|
);
|
|
77
81
|
const decodeKey = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String));
|
|
82
|
+
const decodeComments = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Struct({ body: Schema.String }))));
|
|
78
83
|
|
|
79
84
|
const MARKER = /<!-- checks-night-run ("(?:[^"\\]|\\.)*") -->/;
|
|
80
85
|
|
|
@@ -102,6 +107,15 @@ const openIssues = Effect.gen(function* () {
|
|
|
102
107
|
}
|
|
103
108
|
});
|
|
104
109
|
|
|
110
|
+
const issueComments = Effect.fn("issueComments")(function* (issue: number) {
|
|
111
|
+
const found: string[] = [];
|
|
112
|
+
for (let page = 1; ; page += 1) {
|
|
113
|
+
const batch = yield* gitHubJson(decodeComments, "GET", `${REPOSITORY}/issues/${issue}/comments?per_page=${PAGE_SIZE}&page=${page}`);
|
|
114
|
+
found.push(...batch.map(({ body }) => body));
|
|
115
|
+
if (batch.length < PAGE_SIZE) return found;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
105
119
|
function fullTitleOf(finding: Finding): string {
|
|
106
120
|
if (finding.kind === "mutation") return `Night run: mutation score fell in ${finding.file}`;
|
|
107
121
|
if (finding.kind === "test") return `Night run: ${finding.file} > ${finding.name} fails`;
|
|
@@ -137,12 +151,32 @@ export function issueBody(finding: Finding, run: string): string {
|
|
|
137
151
|
return `${findingText(finding, run)}\n\nFinding key: \`${keyOf(finding)}\`\n\n${marker(keyOf(finding))}\n`;
|
|
138
152
|
}
|
|
139
153
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
const REPORTED = "<!-- checks-night-run reported -->";
|
|
155
|
+
const NOTED = "<!-- checks-night-run noted -->";
|
|
156
|
+
|
|
157
|
+
export function reportedText(finding: Finding, run: string): string {
|
|
158
|
+
return `${findingText(finding, run)}\n\n${REPORTED}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The issue body reports the finding too, so an issue with no marked comment is not yet noted.
|
|
162
|
+
export function alreadyNoted(comments: readonly string[]): boolean {
|
|
163
|
+
return comments.findLast((body) => body.includes(NOTED) || body.includes(REPORTED))?.includes(NOTED) ?? false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function clearedText(key: string, run: string): string {
|
|
167
|
+
return `The night run ${run} no longer reports \`${key}\`, so it closes this issue.`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function notedText(key: string, run: string): string {
|
|
171
|
+
return `The night run ${run} no longer reports \`${key}\`. This issue stays open because a mutation score can recover and drop again.\n\n${NOTED}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const fileFindings = Effect.fn("fileFindings")(function* (findings: readonly Finding[], observed: Observed, run: string) {
|
|
143
175
|
const issues = yield* Issues;
|
|
144
|
-
yield* issues.ensureLabel;
|
|
145
|
-
const
|
|
176
|
+
if (findings.length > 0) yield* issues.ensureLabel;
|
|
177
|
+
const listed = yield* issues.open;
|
|
178
|
+
const present = new Set(findings.map(keyOf));
|
|
179
|
+
const open = new Map(listed.map((issue) => [issue.key, issue.number]));
|
|
146
180
|
const filed: Filed[] = [];
|
|
147
181
|
for (const finding of findings) {
|
|
148
182
|
const key = keyOf(finding);
|
|
@@ -152,10 +186,22 @@ export const fileFindings = Effect.fn("fileFindings")(function* (findings: reado
|
|
|
152
186
|
open.set(key, issue);
|
|
153
187
|
filed.push({ key, issue, action: "opened" });
|
|
154
188
|
} else {
|
|
155
|
-
yield* issues.comment(known,
|
|
189
|
+
yield* issues.comment(known, reportedText(finding, run));
|
|
156
190
|
filed.push({ key, issue: known, action: "commented on" });
|
|
157
191
|
}
|
|
158
192
|
}
|
|
193
|
+
for (const { number, key } of listed) {
|
|
194
|
+
if (noted(key, present, observed)) {
|
|
195
|
+
if (alreadyNoted(yield* issues.comments(number))) continue;
|
|
196
|
+
yield* issues.comment(number, notedText(key, run));
|
|
197
|
+
filed.push({ key, issue: number, action: "commented on" });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!cleared(key, present, observed)) continue;
|
|
201
|
+
yield* issues.comment(number, clearedText(key, run));
|
|
202
|
+
yield* issues.close(number);
|
|
203
|
+
filed.push({ key, issue: number, action: "closed" });
|
|
204
|
+
}
|
|
159
205
|
return filed;
|
|
160
206
|
});
|
|
161
207
|
|
|
@@ -245,10 +291,12 @@ const nightRun = Effect.gen(function* () {
|
|
|
245
291
|
const actions = yield* actionsRun;
|
|
246
292
|
const scores = Option.isSome(options.mutation) ? Option.some(yield* tonightScores(options.mutation.value)) : Option.none<NightRecord["scores"]>();
|
|
247
293
|
const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
|
|
248
|
-
const
|
|
294
|
+
const log = Option.isSome(options.gates) ? Option.some(yield* readText(options.gates.value)) : Option.none<string>();
|
|
295
|
+
const gates: readonly Finding[] = Option.isSome(log) ? gatesOf(log.value) : [];
|
|
249
296
|
const previous = Option.isSome(scores) ? yield* previousNight(actions) : Option.none<PreviousNight>();
|
|
297
|
+
const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) };
|
|
250
298
|
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, gates);
|
|
251
|
-
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, actions.run) } : { onMain: false, ref: actions.ref };
|
|
299
|
+
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, observed, actions.run) } : { onMain: false, ref: actions.ref };
|
|
252
300
|
if (filing.onMain && Option.isSome(scores)) yield* writeRecord({ scores: scores.value });
|
|
253
301
|
yield* Console.log(report(actions.run, Option.map(scores, (tonight) => Object.keys(tonight).length), previous, findings, filing));
|
|
254
302
|
return true;
|