Skip to content

Commit c0c7081

Browse files
authored
feat: adds custom comment markers (#647)
* feat: implement custom comment markers Fixes #646 * chore: add permission comment * chore: add more marker tests
1 parent 809a2d0 commit c0c7081

5 files changed

Lines changed: 221 additions & 12 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,34 @@ If your project includes multiple test suites and you want to consolidate their
140140
json-final-path: './coverage/coverage-final-backend.json'
141141
```
142142

143+
#### Injecting into the Pull Request Description
144+
145+
Instead of posting a separate comment, the action can inject the report into a section of the **pull request description**, handy when you use a PR template and want coverage to live in a specific place. Add a start/end marker pair to your PR template, and the action will replace everything between them on every run, leaving the rest of the description untouched:
146+
147+
```md
148+
## Coverage
149+
150+
<!-- vitest-coverage-report-marker-start-root -->
151+
<!-- vitest-coverage-report-marker-end-root -->
152+
```
153+
154+
The `root` suffix is the same postfix used for the comment marker: it defaults to `root`, but becomes the [`name`](#name) if you set one (or the `working-directory` otherwise). So a step with `name: 'Frontend'` looks for `...-marker-start-Frontend`/`...-marker-end-Frontend`, which lets you inject several reports into one description:
155+
156+
```md
157+
<!-- vitest-coverage-report-marker-start-Frontend -->
158+
<!-- vitest-coverage-report-marker-end-Frontend -->
159+
160+
<!-- vitest-coverage-report-marker-start-Backend -->
161+
<!-- vitest-coverage-report-marker-end-Backend -->
162+
```
163+
164+
Notes:
165+
166+
- Rewriting the description needs the same [`pull-requests: write`](#required-permissions) permission as commenting, so there's nothing extra to grant, but a read-only token can't update it and the run will fail.
167+
- When the markers are present, the report goes into the description **only**, no comment is posted. If you previously ran in comment mode, delete the old comment once by hand.
168+
- Only one marker of a pair (or an end before its start) is treated as a mistake: the action logs a warning and falls back to posting a comment.
169+
- Because every run rewrites the same description, parallel jobs writing **different** markers into it can clobber each other (last write wins). If you inject multiple reports into one description, run them in a single job or guard them with a [`concurrency`](https://docs.github.com/en/actions/using-jobs/using-concurrency) group.
170+
143171
#### Threshold Icons
144172

145173
If you haven't established strict coverage thresholds in your `vitest.config` (which would fail the test run), you can still use the `threshold-icons` option to control the status icons displayed in the PR comment based on coverage percentage.

src/index.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { generateCommitSHAUrl } from "./report/generateCommitSHAUrl.js";
1313
import { generateFileCoverageHtml } from "./report/generateFileCoverageHtml.js";
1414
import { generateHeadline } from "./report/generateHeadline.js";
1515
import { generateSummaryTableHtml } from "./report/generateSummaryTableHtml.js";
16+
import { getWorkflowSummaryURL } from "./report/getWorkflowSummaryURL.js";
1617
import type { JsonSummary } from "./types/JsonSummary.js";
1718
import { writeSummaryToCommit } from "./writeSummaryToComment.js";
1819
import { writeSummaryToPR } from "./writeSummaryToPR.js";
@@ -168,12 +169,6 @@ function getMarkerPostfix({
168169
return "root";
169170
}
170171

171-
function getWorkflowSummaryURL() {
172-
const { owner, repo } = github.context.repo;
173-
const { runId } = github.context;
174-
return `${github.context.serverUrl}/${owner}/${repo}/actions/runs/${runId}`;
175-
}
176-
177172
run()
178173
.then(() => {
179174
core.info("Report generated successfully.");
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as github from "@actions/github";
2+
3+
function getWorkflowSummaryURL() {
4+
const { owner, repo } = github.context.repo;
5+
const { runId } = github.context;
6+
return `${github.context.serverUrl}/${owner}/${repo}/actions/runs/${runId}`;
7+
}
8+
9+
export { getWorkflowSummaryURL };

src/writeSummaryToPR.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const mockContext = vi.hoisted(() => ({
1111
repo: "repo",
1212
},
1313
payload: {},
14+
serverUrl: "https://github.com",
15+
runId: 42,
1416
}));
1517
vi.mock("@actions/github", () => ({
1618
context: mockContext,
@@ -41,6 +43,10 @@ describe("writeSummaryToPR()", () => {
4143
updateComment: vi.fn(),
4244
createComment: vi.fn(),
4345
},
46+
pulls: {
47+
get: vi.fn().mockResolvedValue({ data: { body: "" } }),
48+
update: vi.fn(),
49+
},
4450
},
4551
} as unknown as Octokit;
4652

@@ -97,4 +103,82 @@ describe("writeSummaryToPR()", () => {
97103
});
98104
expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
99105
});
106+
107+
it("injects into the PR body between markers and posts no comment", async () => {
108+
mockOctokit.rest.pulls.get = vi.fn().mockResolvedValue({
109+
data: {
110+
body: "## Intro\ntext\n<!-- vitest-coverage-report-marker-start-root -->\nold\n<!-- vitest-coverage-report-marker-end-root -->\n## Outro",
111+
},
112+
});
113+
114+
await writeSummaryToPR({
115+
octokit: mockOctokit,
116+
summary: mockSummary,
117+
prNumber: 123,
118+
});
119+
120+
expect(mockOctokit.rest.pulls.update).toHaveBeenCalledWith({
121+
owner: "owner",
122+
repo: "repo",
123+
pull_number: 123,
124+
body: "## Intro\ntext\n<!-- vitest-coverage-report-marker-start-root -->\nsummary content\n<!-- vitest-coverage-report-marker-end-root -->\n## Outro",
125+
});
126+
expect(mockOctokit.rest.issues.createComment).not.toHaveBeenCalled();
127+
expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
128+
});
129+
130+
it("warns and falls back to a comment when only one marker is present", async () => {
131+
mockOctokit.rest.pulls.get = vi.fn().mockResolvedValue({
132+
data: {
133+
body: "text\n<!-- vitest-coverage-report-marker-start-root -->\nno end marker",
134+
},
135+
});
136+
137+
await writeSummaryToPR({
138+
octokit: mockOctokit,
139+
summary: mockSummary,
140+
prNumber: 123,
141+
});
142+
143+
expect(core.warning).toHaveBeenCalled();
144+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
145+
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalled();
146+
});
147+
148+
it("warns and falls back to a comment when the end marker comes first", async () => {
149+
mockOctokit.rest.pulls.get = vi.fn().mockResolvedValue({
150+
data: {
151+
body: "text\n<!-- vitest-coverage-report-marker-end-root -->\nreversed\n<!-- vitest-coverage-report-marker-start-root -->",
152+
},
153+
});
154+
155+
await writeSummaryToPR({
156+
octokit: mockOctokit,
157+
summary: mockSummary,
158+
prNumber: 123,
159+
});
160+
161+
expect(core.warning).toHaveBeenCalled();
162+
expect(mockOctokit.rest.pulls.update).not.toHaveBeenCalled();
163+
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalled();
164+
});
165+
166+
it("replaces an oversized report with a stub linking to the workflow summary", async () => {
167+
mockSummary.stringify = vi.fn().mockReturnValue("x".repeat(70000));
168+
mockOctokit.paginate.iterator = vi.fn().mockReturnValue([{ data: [] }]);
169+
170+
await writeSummaryToPR({
171+
octokit: mockOctokit,
172+
summary: mockSummary,
173+
prNumber: 123,
174+
});
175+
176+
const body = (
177+
mockOctokit.rest.issues.createComment as ReturnType<typeof vi.fn>
178+
).mock.calls[0][0].body;
179+
expect(body).toContain("too large to inline");
180+
expect(body).toContain("https://github.com/owner/repo/actions/runs/42");
181+
expect(body).toContain("<!-- vitest-coverage-report-marker-root -->");
182+
expect(body.length).toBeLessThanOrEqual(65536);
183+
});
100184
});

src/writeSummaryToPR.ts

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import * as core from "@actions/core";
22
import * as github from "@actions/github";
33
import type { Octokit } from "./octokit";
4+
import { getWorkflowSummaryURL } from "./report/getWorkflowSummaryURL.js";
5+
6+
// GitHub caps issue/PR bodies and comments at 65536 characters.
7+
const MAX_BODY_LENGTH = 65536;
48

59
const COMMENT_MARKER = (markerPostfix = "root") =>
610
`<!-- vitest-coverage-report-marker-${markerPostfix} -->`;
11+
const START_MARKER_PREFIX = "<!-- vitest-coverage-report-marker-start-";
12+
const START_MARKER = (markerPostfix = "root") =>
13+
`${START_MARKER_PREFIX}${markerPostfix} -->`;
14+
const END_MARKER = (markerPostfix = "root") =>
15+
`<!-- vitest-coverage-report-marker-end-${markerPostfix} -->`;
16+
17+
const oversizeStub = () =>
18+
`⚠️ Coverage report too large to inline, see the [workflow summary](${getWorkflowSummaryURL()}).`;
719

820
const writeSummaryToPR = async ({
921
octokit,
@@ -22,12 +34,18 @@ const writeSummaryToPR = async ({
2234
return;
2335
}
2436

25-
const commentBody = `${summary.stringify()}\n\n${COMMENT_MARKER(markerPostfix)}`;
26-
const existingComment = await findCommentByBody(
27-
octokit,
28-
COMMENT_MARKER(markerPostfix),
29-
prNumber,
30-
);
37+
const report = summary.stringify();
38+
39+
// If the PR body carries our marker pair, inject there and post no comment.
40+
if (await tryInjectIntoBody({ octokit, report, markerPostfix, prNumber })) {
41+
return;
42+
}
43+
44+
const marker = COMMENT_MARKER(markerPostfix);
45+
const full = `${report}\n\n${marker}`;
46+
const commentBody =
47+
full.length <= MAX_BODY_LENGTH ? full : `${oversizeStub()}\n\n${marker}`;
48+
const existingComment = await findCommentByBody(octokit, marker, prNumber);
3149

3250
if (existingComment) {
3351
await octokit.rest.issues.updateComment({
@@ -46,6 +64,81 @@ const writeSummaryToPR = async ({
4664
}
4765
};
4866

67+
// Returns true when the report was injected into the PR body between the
68+
// start/end markers. Returns false (so the caller posts a comment) when the
69+
// markers are absent, or emits a warning and returns false when they are
70+
// malformed (only one present, or end before start).
71+
async function tryInjectIntoBody({
72+
octokit,
73+
report,
74+
markerPostfix,
75+
prNumber,
76+
}: {
77+
octokit: Octokit;
78+
report: string;
79+
markerPostfix?: string;
80+
prNumber: number;
81+
}): Promise<boolean> {
82+
const { data: pullRequest } = await octokit.rest.pulls.get({
83+
owner: github.context.repo.owner,
84+
repo: github.context.repo.repo,
85+
pull_number: prNumber,
86+
});
87+
88+
const body = pullRequest.body ?? "";
89+
const start = START_MARKER(markerPostfix);
90+
const end = END_MARKER(markerPostfix);
91+
const startIdx = body.indexOf(start);
92+
const endIdx = body.indexOf(end);
93+
94+
if (startIdx === -1 && endIdx === -1) {
95+
return false;
96+
}
97+
98+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx + start.length) {
99+
core.warning(
100+
`Found incomplete coverage markers in the pull request body. Expected both "${start}" and "${end}" with the start before the end. Falling back to a comment.`,
101+
);
102+
return false;
103+
}
104+
105+
const before = body.slice(0, startIdx + start.length);
106+
const after = body.slice(endIdx);
107+
const stub = oversizeStub();
108+
109+
// Reserve one stub's worth of room per other marker region so sibling runs
110+
// can still write their own region into the shared body. Empty regions are
111+
// the ones that still need to grow. The +2 accounts for the newlines that
112+
// wrap each injected region.
113+
const otherRegions = Math.max(0, countStartMarkers(body) - 1);
114+
const margin = otherRegions * (stub.length + 2);
115+
116+
const withReport = `${before}\n${report}\n${after}`;
117+
const newBody =
118+
withReport.length <= MAX_BODY_LENGTH - margin
119+
? withReport
120+
: `${before}\n${stub}\n${after}`;
121+
122+
await octokit.rest.pulls.update({
123+
owner: github.context.repo.owner,
124+
repo: github.context.repo.repo,
125+
pull_number: prNumber,
126+
body: newBody,
127+
});
128+
129+
return true;
130+
}
131+
132+
function countStartMarkers(body: string): number {
133+
let count = 0;
134+
let idx = body.indexOf(START_MARKER_PREFIX);
135+
while (idx !== -1) {
136+
count++;
137+
idx = body.indexOf(START_MARKER_PREFIX, idx + START_MARKER_PREFIX.length);
138+
}
139+
return count;
140+
}
141+
49142
async function findCommentByBody(
50143
octokit: Octokit,
51144
commentBodyIncludes: string,

0 commit comments

Comments
 (0)