coverage-check 0.1.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.
@@ -0,0 +1,100 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseLcov } from "../lcov-parser.mts";
3
+ import { mergeLcov, toLcov } from "../lcov-merge.mts";
4
+ import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mts";
5
+ import { FileSystemSuiteStore } from "../suite-store.mts";
6
+
7
+ const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
8
+ const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
9
+
10
+ export type StorePutArgs = {
11
+ suite: string;
12
+ store: string;
13
+ artifacts: string;
14
+ stripPrefixes: string[];
15
+ sha: string | null;
16
+ ref: string | null;
17
+ };
18
+
19
+ function parseArgs(argv: string[]): StorePutArgs {
20
+ const args: StorePutArgs = {
21
+ suite: "",
22
+ store: "",
23
+ artifacts: "./coverage-artifacts",
24
+ stripPrefixes: [],
25
+ sha: null,
26
+ ref: null,
27
+ };
28
+
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const flag = argv[i]!;
31
+ const next = argv[i + 1];
32
+ const val = (): string => {
33
+ if (next === undefined) throw new Error(`${flag} requires a value`);
34
+ i++;
35
+ return next;
36
+ };
37
+ switch (flag) {
38
+ case "--suite":
39
+ args.suite = val();
40
+ break;
41
+ case "--store":
42
+ args.store = val();
43
+ break;
44
+ case "--artifacts":
45
+ args.artifacts = val();
46
+ break;
47
+ case "--strip-prefix":
48
+ args.stripPrefixes.push(val());
49
+ break;
50
+ case "--sha":
51
+ args.sha = val();
52
+ break;
53
+ case "--ref":
54
+ args.ref = val();
55
+ break;
56
+ default:
57
+ throw new Error(`unknown flag: ${flag}`);
58
+ }
59
+ }
60
+
61
+ if (!args.suite) throw new Error("--suite is required");
62
+ if (!args.store) throw new Error("--store is required");
63
+ return args;
64
+ }
65
+
66
+ export async function main(argv: string[]): Promise<number> {
67
+ let args: StorePutArgs;
68
+ try {
69
+ args = parseArgs(argv);
70
+ } catch (err) {
71
+ /* c8 ignore next */
72
+ stderr(`coverage-check store-put: ${err instanceof Error ? err.message : err}`);
73
+ return 2;
74
+ }
75
+ return runStorePut(args);
76
+ }
77
+
78
+ export async function runStorePut(args: StorePutArgs): Promise<number> {
79
+ const lcovFiles = collectLcovFiles(args.artifacts);
80
+ if (lcovFiles.length === 0) {
81
+ stderr(`coverage-check store-put: no lcov.info files found under ${args.artifacts}`);
82
+ return 2;
83
+ }
84
+
85
+ const stripPrefixes = buildStripPrefixes(args.stripPrefixes);
86
+ const reports = lcovFiles.map((f) => parseLcov(readFileSync(f, "utf8"), stripPrefixes));
87
+ const merged = mergeLcov(reports);
88
+ const lcovText = toLcov(merged);
89
+
90
+ const store = new FileSystemSuiteStore(args.store);
91
+ await store.put(args.suite, Buffer.from(lcovText, "utf8"), {
92
+ sha: args.sha ?? undefined,
93
+ ref: args.ref ?? undefined,
94
+ });
95
+
96
+ stdout(
97
+ `coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s)) → ${args.store}`,
98
+ );
99
+ return 0;
100
+ }
@@ -0,0 +1,154 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { main, runStorePut } from "./store-put.mts";
6
+ import { FileSystemSuiteStore } from "../suite-store.mts";
7
+
8
+ describe("main argument parsing", () => {
9
+ it("returns 2 when --suite is missing", async () => {
10
+ expect(await main(["--store", "/tmp/store"])).toBe(2);
11
+ });
12
+
13
+ it("returns 2 when --store is missing", async () => {
14
+ expect(await main(["--suite", "backend"])).toBe(2);
15
+ });
16
+
17
+ it("returns 2 on unknown flag", async () => {
18
+ expect(await main(["--suite", "backend", "--store", "/tmp/s", "--unknown"])).toBe(2);
19
+ });
20
+
21
+ it("returns 2 when a flag is missing its value", async () => {
22
+ expect(await main(["--suite"])).toBe(2);
23
+ });
24
+ });
25
+
26
+ describe("runStorePut", () => {
27
+ let tmpDir: string;
28
+ let artifactsDir: string;
29
+ let storeDir: string;
30
+
31
+ beforeEach(() => {
32
+ tmpDir = mkdtempSync(join(tmpdir(), "store-put-test-"));
33
+ artifactsDir = join(tmpDir, "artifacts");
34
+ storeDir = join(tmpDir, "store");
35
+ mkdirSync(artifactsDir);
36
+ mkdirSync(storeDir);
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(tmpDir, { recursive: true, force: true });
41
+ });
42
+
43
+ it("returns 2 when no lcov.info files found in artifacts", async () => {
44
+ expect(
45
+ await runStorePut({
46
+ suite: "backend",
47
+ store: storeDir,
48
+ artifacts: artifactsDir,
49
+ stripPrefixes: [],
50
+ sha: null,
51
+ ref: null,
52
+ }),
53
+ ).toBe(2);
54
+ });
55
+
56
+ it("stores merged lcov from artifacts and returns 0", async () => {
57
+ writeFileSync(
58
+ join(artifactsDir, "lcov.info"),
59
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,0\nend_of_record\n",
60
+ );
61
+
62
+ expect(
63
+ await runStorePut({
64
+ suite: "backend",
65
+ store: storeDir,
66
+ artifacts: artifactsDir,
67
+ stripPrefixes: [],
68
+ sha: "abc123",
69
+ ref: "refs/heads/main",
70
+ }),
71
+ ).toBe(0);
72
+
73
+ const store = new FileSystemSuiteStore(storeDir);
74
+ const suites = await store.list();
75
+ expect(suites).toContain("backend");
76
+
77
+ const buf = await store.get("backend");
78
+ expect(buf).not.toBeNull();
79
+ const lcovText = buf!.toString();
80
+ expect(lcovText).toContain("SF:backend/foo.mts");
81
+ expect(lcovText).toContain("DA:1,1");
82
+ });
83
+
84
+ it("merges multiple lcov files from nested subdirectories", async () => {
85
+ const sub = join(artifactsDir, "shard1");
86
+ mkdirSync(sub);
87
+ writeFileSync(join(artifactsDir, "lcov.info"), "SF:backend/a.mts\nDA:1,1\nend_of_record\n");
88
+ writeFileSync(join(sub, "lcov.info"), "SF:backend/b.mts\nDA:2,1\nend_of_record\n");
89
+
90
+ expect(
91
+ await runStorePut({
92
+ suite: "backend",
93
+ store: storeDir,
94
+ artifacts: artifactsDir,
95
+ stripPrefixes: [],
96
+ sha: null,
97
+ ref: null,
98
+ }),
99
+ ).toBe(0);
100
+
101
+ const store = new FileSystemSuiteStore(storeDir);
102
+ const buf = await store.get("backend");
103
+ const lcovText = buf!.toString();
104
+ expect(lcovText).toContain("SF:backend/a.mts");
105
+ expect(lcovText).toContain("SF:backend/b.mts");
106
+ });
107
+
108
+ it("accepts --strip-prefix flag", async () => {
109
+ writeFileSync(
110
+ join(artifactsDir, "lcov.info"),
111
+ "SF:/home/runner/work/repo/backend/foo.mts\nDA:1,1\nend_of_record\n",
112
+ );
113
+
114
+ expect(
115
+ await main([
116
+ "--suite",
117
+ "backend",
118
+ "--store",
119
+ storeDir,
120
+ "--artifacts",
121
+ artifactsDir,
122
+ "--strip-prefix",
123
+ "/home/runner/work/repo",
124
+ ]),
125
+ ).toBe(0);
126
+
127
+ const store = new FileSystemSuiteStore(storeDir);
128
+ const buf = await store.get("backend");
129
+ // After stripping the prefix, the stored lcov should have the normalized path
130
+ expect(buf!.toString()).toContain("SF:backend/foo.mts");
131
+ });
132
+
133
+ it("round-trips through main() CLI interface", async () => {
134
+ writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
135
+
136
+ expect(
137
+ await main([
138
+ "--suite",
139
+ "frontend",
140
+ "--store",
141
+ storeDir,
142
+ "--artifacts",
143
+ artifactsDir,
144
+ "--sha",
145
+ "deadbeef",
146
+ "--ref",
147
+ "refs/heads/feat",
148
+ ]),
149
+ ).toBe(0);
150
+
151
+ const stored = readFileSync(join(storeDir, "frontend", "lcov.info"), "utf8");
152
+ expect(stored).toContain("SF:web/app.tsx");
153
+ });
154
+ });
@@ -0,0 +1,15 @@
1
+ export { runCheck } from "./commands/check.mts";
2
+ export { runStorePut } from "./commands/store-put.mts";
3
+ export { FileSystemSuiteStore } from "./suite-store.mts";
4
+
5
+ export type { CheckArgs } from "./commands/check.mts";
6
+ export type { StorePutArgs } from "./commands/store-put.mts";
7
+ export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
8
+ export type {
9
+ CoverageCheckResult,
10
+ BucketResult,
11
+ FileCoverageResult,
12
+ LcovData,
13
+ DiffLines,
14
+ CoverageRule,
15
+ } from "./types.mts";
@@ -0,0 +1,127 @@
1
+ import type { DiffLines } from "./types.mts";
2
+
3
+ /**
4
+ * Decodes a git C-string (inner content between surrounding double-quotes).
5
+ * Git quotes unusual paths (non-ASCII, spaces, etc.) with core.quotePath=true.
6
+ * Handles octal byte escapes (\nnn), \\, \", \n, \t.
7
+ */
8
+ export function decodeGitCString(s: string): string {
9
+ const bytes: number[] = [];
10
+ for (let i = 0; i < s.length; ) {
11
+ if (s[i] === "\\" && i + 1 < s.length) {
12
+ const next = s[i + 1]!;
13
+ if (next >= "0" && next <= "7") {
14
+ bytes.push(parseInt(s.slice(i + 1, i + 4), 8));
15
+ i += 4;
16
+ } else if (next === "\\") {
17
+ bytes.push(92);
18
+ i += 2;
19
+ } else if (next === '"') {
20
+ bytes.push(34);
21
+ i += 2;
22
+ } else if (next === "n") {
23
+ bytes.push(10);
24
+ i += 2;
25
+ } else if (next === "t") {
26
+ bytes.push(9);
27
+ i += 2;
28
+ } else {
29
+ bytes.push(s.charCodeAt(i));
30
+ i++;
31
+ }
32
+ } else {
33
+ bytes.push(s.charCodeAt(i));
34
+ i++;
35
+ }
36
+ }
37
+ return Buffer.from(new Uint8Array(bytes)).toString("utf8");
38
+ }
39
+
40
+ /**
41
+ * Parses the output of `git diff --unified=0` into a map of
42
+ * repo-root-relative file path → set of added/modified line numbers.
43
+ *
44
+ * Only added lines (lines in the new version) are tracked. Deleted-only
45
+ * hunks (where the `+` count is 0) are skipped.
46
+ */
47
+ export function parseDiff(text: string): DiffLines {
48
+ const result: DiffLines = new Map();
49
+ let currentLines: Set<number> | null = null;
50
+ let inHeader = false;
51
+
52
+ for (const raw of text.split("\n")) {
53
+ const line = raw.trimEnd();
54
+
55
+ // Only parse +++ as a file header when we are in the diff header block
56
+ // (after `diff --git` / `---`). Without this guard a source line beginning
57
+ // with `++ b/` would appear as `+++ b/…` in the diff and be misclassified.
58
+ let newFilePath: string | null = null;
59
+ if (inHeader) {
60
+ if (line.startsWith("+++ b/")) {
61
+ newFilePath = line.slice(6);
62
+ } else if (line.startsWith('+++ "b/') && line.endsWith('"')) {
63
+ newFilePath = decodeGitCString(line.slice(5, -1)).slice(2);
64
+ }
65
+ }
66
+
67
+ if (newFilePath !== null) {
68
+ inHeader = false;
69
+ const path = newFilePath;
70
+ if (path === "dev/null") {
71
+ currentLines = null;
72
+ continue;
73
+ }
74
+ currentLines = result.get(path) ?? new Set();
75
+ result.set(path, currentLines);
76
+ } else if (line.startsWith("--- ")) {
77
+ // ignore (part of diff header)
78
+ } else if (line.startsWith("@@ ") && currentLines !== null) {
79
+ // @@ -old_start[,old_count] +new_start[,new_count] @@
80
+ const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
81
+ if (!match) continue;
82
+ const newStart = parseInt(match[1]!, 10);
83
+ const newCount = match[2] !== undefined ? parseInt(match[2], 10) : 1;
84
+ if (newCount === 0) continue;
85
+ for (let i = 0; i < newCount; i++) {
86
+ currentLines.add(newStart + i);
87
+ }
88
+ } else if (line.startsWith("diff --git ")) {
89
+ currentLines = null;
90
+ inHeader = true;
91
+ }
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ /** Runs git diff and returns the parsed result. */
98
+ export async function getChangedLines(baseRef: string, headRef: string): Promise<DiffLines> {
99
+ const { spawn } = await import("node:child_process");
100
+ const spawnProcess = (cmd: string, args: string[]) =>
101
+ new Promise<string>((resolve, reject) => {
102
+ const chunks: Buffer[] = [];
103
+ const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "inherit"] });
104
+ proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
105
+ proc.on("error", reject);
106
+ proc.on("close", (code) =>
107
+ code === 0
108
+ ? resolve(Buffer.concat(chunks).toString("utf8"))
109
+ : reject(new Error(`${cmd} exited with code ${code}`)),
110
+ );
111
+ });
112
+
113
+ const mergeBase = await spawnProcess("git", ["merge-base", baseRef, headRef]);
114
+ const base = mergeBase.trim();
115
+ // --src-prefix/--dst-prefix override diff.noprefix and diff.mnemonicPrefix git config
116
+ const diff = await spawnProcess("git", [
117
+ "diff",
118
+ "--unified=0",
119
+ "--inter-hunk-context=0",
120
+ "--no-color",
121
+ "--src-prefix=a/",
122
+ "--dst-prefix=b/",
123
+ base,
124
+ headRef,
125
+ ]);
126
+ return parseDiff(diff);
127
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { decodeGitCString, parseDiff } from "./diff-parser.mts";
3
+
4
+ const SIMPLE_DIFF = `
5
+ diff --git a/web/components/Foo.tsx b/web/components/Foo.tsx
6
+ index abc..def 100644
7
+ --- a/web/components/Foo.tsx
8
+ +++ b/web/components/Foo.tsx
9
+ @@ -1,3 +1,5 @@
10
+ +import React from 'react'
11
+ +
12
+ export function Foo() {
13
+ - return null
14
+ + return <div />
15
+ }
16
+ +export const BAR = 1
17
+ `;
18
+
19
+ const DELETION_DIFF = `
20
+ diff --git a/backend/foo.mts b/backend/foo.mts
21
+ --- a/backend/foo.mts
22
+ +++ b/backend/foo.mts
23
+ @@ -5,3 +5,0 @@
24
+ -deleted line
25
+ -deleted line
26
+ -deleted line
27
+ `;
28
+
29
+ const MULTI_FILE_DIFF = `
30
+ diff --git a/web/components/A.tsx b/web/components/A.tsx
31
+ --- a/web/components/A.tsx
32
+ +++ b/web/components/A.tsx
33
+ @@ -1,1 +1,2 @@
34
+ unchanged
35
+ +added
36
+ diff --git a/backend/b.mts b/backend/b.mts
37
+ --- a/backend/b.mts
38
+ +++ b/backend/b.mts
39
+ @@ -10,0 +11,1 @@
40
+ +new line
41
+ `;
42
+
43
+ describe("parseDiff", () => {
44
+ it("parses added lines from a hunk", () => {
45
+ const result = parseDiff(SIMPLE_DIFF);
46
+ const fooLines = result.get("web/components/Foo.tsx")!;
47
+ expect(fooLines.has(1)).toBe(true);
48
+ expect(fooLines.has(5)).toBe(true);
49
+ expect(fooLines.size).toBe(5);
50
+ });
51
+
52
+ it("skips pure-deletion hunks (count=0)", () => {
53
+ const result = parseDiff(DELETION_DIFF);
54
+ const lines = result.get("backend/foo.mts");
55
+ expect(!lines || lines.size === 0).toBe(true);
56
+ });
57
+
58
+ it("handles multiple files", () => {
59
+ const result = parseDiff(MULTI_FILE_DIFF);
60
+ expect(result.has("web/components/A.tsx")).toBe(true);
61
+ expect(result.has("backend/b.mts")).toBe(true);
62
+ expect(result.get("backend/b.mts")?.has(11)).toBe(true);
63
+ });
64
+
65
+ it("skips deleted files (+++ b/dev/null)", () => {
66
+ const diff = `
67
+ diff --git a/backend/deleted.mts b/backend/deleted.mts
68
+ --- a/backend/deleted.mts
69
+ +++ b/dev/null
70
+ @@ -1,3 +0,0 @@
71
+ -deleted line 1
72
+ -deleted line 2
73
+ -deleted line 3
74
+ `;
75
+ const result = parseDiff(diff);
76
+ expect(result.has("dev/null")).toBe(false);
77
+ expect(result.has("backend/deleted.mts")).toBe(false);
78
+ });
79
+
80
+ it("handles hunk headers with trailing section context text", () => {
81
+ const diff = `
82
+ diff --git a/backend/x.mts b/backend/x.mts
83
+ --- a/backend/x.mts
84
+ +++ b/backend/x.mts
85
+ @@ -1,3 +1,5 @@ export function Foo() {
86
+ +import React from 'react'
87
+ +
88
+ export function Foo() {
89
+ - return null
90
+ + return <div />
91
+ }
92
+ +export const BAR = 1
93
+ `;
94
+ const result = parseDiff(diff);
95
+ expect(result.get("backend/x.mts")?.size).toBe(5);
96
+ expect(result.get("backend/x.mts")?.has(1)).toBe(true);
97
+ expect(result.get("backend/x.mts")?.has(5)).toBe(true);
98
+ });
99
+
100
+ it("handles comma-less hunk lines (single-line change)", () => {
101
+ const diff = `
102
+ diff --git a/backend/x.mts b/backend/x.mts
103
+ --- a/backend/x.mts
104
+ +++ b/backend/x.mts
105
+ @@ -1 +1 @@
106
+ -old
107
+ +new
108
+ `;
109
+ const result = parseDiff(diff);
110
+ expect(result.get("backend/x.mts")?.has(1)).toBe(true);
111
+ });
112
+
113
+ it("does not misclassify content lines starting with +++ b/ as file headers", () => {
114
+ const diff = `
115
+ diff --git a/backend/foo.mts b/backend/foo.mts
116
+ --- a/backend/foo.mts
117
+ +++ b/backend/foo.mts
118
+ @@ -1,1 +1,2 @@
119
+ unchanged
120
+ +++ b/this-is-content-not-a-header
121
+ `;
122
+ const result = parseDiff(diff);
123
+ expect(result.has("backend/foo.mts")).toBe(true);
124
+ expect(result.has("this-is-content-not-a-header")).toBe(false);
125
+ expect(result.get("backend/foo.mts")?.has(2)).toBe(true);
126
+ });
127
+
128
+ it("skips malformed hunk header lines (@@ with no valid pattern)", () => {
129
+ const diff = `
130
+ diff --git a/backend/x.mts b/backend/x.mts
131
+ --- a/backend/x.mts
132
+ +++ b/backend/x.mts
133
+ @@ bad hunk header @@
134
+ +new line
135
+ `;
136
+ const result = parseDiff(diff);
137
+ // malformed @@ line is skipped; no lines added
138
+ const lines = result.get("backend/x.mts");
139
+ expect(!lines || lines.size === 0).toBe(true);
140
+ });
141
+
142
+ it("handles git-quoted paths (core.quotePath=true)", () => {
143
+ const diff = `
144
+ diff --git "a/backend/caf\\303\\251.mts" "b/backend/caf\\303\\251.mts"
145
+ --- "a/backend/caf\\303\\251.mts"
146
+ +++ "b/backend/caf\\303\\251.mts"
147
+ @@ -1,1 +1,2 @@
148
+ existing
149
+ +new line
150
+ `;
151
+ const result = parseDiff(diff);
152
+ expect(result.has("backend/café.mts")).toBe(true);
153
+ expect(result.get("backend/café.mts")?.has(2)).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe("decodeGitCString", () => {
158
+ it("returns plain ASCII unchanged", () => {
159
+ expect(decodeGitCString("backend/foo.mts")).toBe("backend/foo.mts");
160
+ });
161
+
162
+ it("decodes octal UTF-8 byte sequences", () => {
163
+ expect(decodeGitCString("caf\\303\\251.mts")).toBe("café.mts");
164
+ });
165
+
166
+ it("decodes backslash and double-quote escapes", () => {
167
+ expect(decodeGitCString('path\\\\to\\"file')).toBe('path\\to"file');
168
+ });
169
+
170
+ it("decodes \\n and \\t escapes", () => {
171
+ expect(decodeGitCString("line\\nbreak")).toBe("line\nbreak");
172
+ expect(decodeGitCString("tab\\there")).toBe("tab\there");
173
+ });
174
+
175
+ it("passes through unknown escape sequences unchanged", () => {
176
+ expect(decodeGitCString("\\z")).toBe("\\z");
177
+ });
178
+ });
@@ -0,0 +1,74 @@
1
+ import { COMMENT_MARKER } from "./report.mts";
2
+
3
+ export type GhRunner = (args: string[]) => Promise<string>;
4
+
5
+ /* c8 ignore start */
6
+ async function defaultGhRunner(args: string[]): Promise<string> {
7
+ const { spawn } = await import("node:child_process");
8
+ return new Promise((resolve, reject) => {
9
+ const chunks: Buffer[] = [];
10
+ const proc = spawn("gh", args, { stdio: ["ignore", "pipe", "inherit"] });
11
+ proc.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
12
+ proc.on("error", reject);
13
+ proc.on("close", (code: number | null) =>
14
+ code === 0
15
+ ? resolve(Buffer.concat(chunks).toString("utf8"))
16
+ : reject(new Error(`gh ${args[0]} exited with code ${code}`)),
17
+ );
18
+ });
19
+ }
20
+ /* c8 ignore stop */
21
+
22
+ /** Finds the ID of the existing coverage-check sticky comment, if any. */
23
+ async function findExistingComment(repo: string, pr: number, gh: GhRunner): Promise<number | null> {
24
+ try {
25
+ const raw = await gh([
26
+ "api",
27
+ `repos/${repo}/issues/${pr}/comments`,
28
+ "--paginate",
29
+ "-q",
30
+ `first(.[] | select(.body | startswith("${COMMENT_MARKER}"))) | .id`,
31
+ ]);
32
+ // --paginate applies the jq filter per page; take the first valid ID across all lines
33
+ const id = raw
34
+ .split("\n")
35
+ .map((line) => parseInt(line.trim(), 10))
36
+ .find((n) => Number.isFinite(n) && n > 0);
37
+ return id ?? null;
38
+ } catch (err) {
39
+ process.stderr.write(`coverage-check: warning: failed to look up existing comment: ${err}\n`);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Posts or updates the sticky coverage-check comment on a pull request.
46
+ *
47
+ * - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
48
+ * - On pass: if a marker comment exists (from any prior run), replaces it with a
49
+ * pass body. If no marker comment exists, stays silent.
50
+ */
51
+ export async function upsertComment(
52
+ body: string,
53
+ repo: string,
54
+ pr: number,
55
+ passed: boolean,
56
+ gh: GhRunner = defaultGhRunner,
57
+ ): Promise<void> {
58
+ const existingId = await findExistingComment(repo, pr, gh);
59
+
60
+ if (passed && existingId === null) return; // nothing to update
61
+
62
+ if (existingId !== null) {
63
+ await gh([
64
+ "api",
65
+ `repos/${repo}/issues/comments/${existingId}`,
66
+ "-X",
67
+ "PATCH",
68
+ "-f",
69
+ `body=${body}`,
70
+ ]);
71
+ } else {
72
+ await gh(["api", `repos/${repo}/issues/${pr}/comments`, "-f", `body=${body}`]);
73
+ }
74
+ }