Complete Codecov automation UX and route coverage tests

swadhinbiswas committed 8 months ago
715bdcf
1 parent e73d9d0
commit 715bdcf
3 files changed
+335 -4
▼doc/feature_audit.md
+3-3
12
12
| **Code Review** | 9 | 1 | 0 | 10 |
13
13
| **Issues & Planning** | 10 | 0 | 0 | 10 |
14
14
| **CI/CD & Automation** | 6 | 2 | 0 | 8 |
15
-| **Third-Party Integrations** | 4 | 18 | 0 | 22 |
15
+| **Third-Party Integrations** | 5 | 17 | 0 | 22 |
16
16
| **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 |
17
17
| **Security** | 9 | 3 | 0 | 12 |
18
18
| **Analytics & Insights** | 6 | 2 | 0 | 8 |
19
19
| **Notifications & Collaboration** | 5 | 3 | 0 | 8 |
20
20
| **Interfaces & Extensibility** | 5 | 2 | 0 | 7 |
21
21
| **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 |
22
-| **Total** | **77** | **48** | **0** | **125** |
22
+| **Total** | **78** | **47** | **0** | **125** |
23
23
24
24
---
25
25
103
103
### 6.1 Code Quality & Coverage
104
104
| Feature | Status | Notes |
105
105
| :--- | :---: | :--- |
106
-| Codecov | ⚠️ | Coverage webhook processing + API configuration implemented; deeper workflow/UI automation is still partial |
106
+| Codecov | ✅ | Coverage webhook processing + API configuration are implemented, and repository settings now provide Codecov automation UX (config save/update, gating controls, webhook endpoint/secret visibility, and recent coverage report surfacing) using `integrations/code-quality` APIs |
107
107
| Coveralls | ⚠️ | Coverage webhook processing + API configuration implemented; deeper workflow/UI automation is still partial |
108
108
| SonarQube | ⚠️ | Integration and quality-gate ingestion implemented, enterprise-depth coverage still partial |
109
109
| Snyk | ⚠️ | Trivy-based scanning exists |
110
110
▼src/pages/[owner]/[repo]/settings/external-ci.astro
+184-1
39
39
const integration = await db.query.externalCiIntegrations.findFirst({
40
40
where: eq(schema.externalCiIntegrations.repositoryId, repoData.id),
41
41
});
42
+const codecovConfig = await db.query.codeQualityConfigs.findFirst({
43
+ where: and(
44
+ eq(schema.codeQualityConfigs.repositoryId, repoData.id),
45
+ eq(schema.codeQualityConfigs.provider, "codecov")
46
+ ),
47
+});
42
48
43
49
const siteUrl = process.env.SITE_URL || "http://localhost:4321";
44
50
const endpointUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/external-ci/checks`;
51
+const codecovWebhookUrl = `${siteUrl}/api/repos/${repoData.owner.username}/${repoData.name}/integrations/code-quality/webhooks/codecov`;
45
52
46
53
const repo = {
47
54
owner: repoData.owner.username,
183
190
</div>
184
191
</CardContent>
185
192
</Card>
193
+
194
+ <Card>
195
+ <CardHeader>
196
+ <CardTitle>Codecov Coverage Automation</CardTitle>
197
+ <CardDescription>
198
+ Configure coverage gating, webhook ingestion, and PR reporting for Codecov.
199
+ </CardDescription>
200
+ </CardHeader>
201
+ <CardContent className="space-y-4">
202
+ <div class="rounded-md border border-dashed border-white/10 bg-white/5 p-3 text-sm text-muted-foreground">
203
+ <div class="text-xs uppercase tracking-wide text-muted-foreground">Codecov Webhook Endpoint</div>
204
+ <div class="mt-1 font-mono text-xs break-all">{codecovWebhookUrl}</div>
205
+ </div>
206
+
207
+ <div class="grid gap-3 md:grid-cols-2">
208
+ <div>
209
+ <label for="codecov-project-key" class="block text-xs text-muted-foreground mb-1">Project key</label>
210
+ <input
211
+ id="codecov-project-key"
212
+ class="h-8 w-full rounded-md border bg-background px-2 text-xs"
213
+ placeholder="owner/repo"
214
+ value={codecovConfig?.projectKey || ""}
215
+ />
216
+ </div>
217
+ <div>
218
+ <label for="codecov-api-token" class="block text-xs text-muted-foreground mb-1">API token</label>
219
+ <input
220
+ id="codecov-api-token"
221
+ type="password"
222
+ class="h-8 w-full rounded-md border bg-background px-2 text-xs"
223
+ placeholder={codecovConfig ? "•••••••• (leave empty to keep)" : "Codecov token"}
224
+ />
225
+ </div>
226
+ </div>
227
+
228
+ <div class="grid gap-3 md:grid-cols-4">
229
+ <div>
230
+ <label for="codecov-min-coverage" class="block text-xs text-muted-foreground mb-1">Min coverage %</label>
231
+ <input
232
+ id="codecov-min-coverage"
233
+ type="number"
234
+ min="0"
235
+ max="100"
236
+ step="0.1"
237
+ class="h-8 w-full rounded-md border bg-background px-2 text-xs"
238
+ value={codecovConfig?.minCoverage ?? 80}
239
+ />
240
+ </div>
241
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-5">
242
+ <input id="codecov-block-on-fail" type="checkbox" checked={!!codecovConfig?.blockOnFail} />
243
+ Block on fail
244
+ </label>
245
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-5">
246
+ <input id="codecov-report-on-pr" type="checkbox" checked={codecovConfig?.reportOnPR ?? true} />
247
+ Report on PR
248
+ </label>
249
+ <label class="flex items-center gap-2 text-xs text-muted-foreground mt-5">
250
+ <input id="codecov-enabled" type="checkbox" checked={codecovConfig?.isEnabled ?? true} />
251
+ Enabled
252
+ </label>
253
+ </div>
254
+
255
+ <div class="text-xs text-muted-foreground">
256
+ Webhook secret:
257
+ <span id="codecov-webhook-secret" class="font-mono">{codecovConfig?.webhookSecret || "Not configured yet"}</span>
258
+ </div>
259
+
260
+ <div class="flex items-center gap-2">
261
+ <Button id="save-codecov" variant="outline">Save Codecov Config</Button>
262
+ <span id="codecov-save-msg" class="text-xs text-muted-foreground"></span>
263
+ </div>
264
+
265
+ <div class="pt-2 border-t border-white/10">
266
+ <div class="text-xs font-medium text-muted-foreground mb-2">Recent Codecov Reports</div>
267
+ <div id="codecov-reports" class="space-y-1 text-xs text-muted-foreground">
268
+ Loading coverage reports...
269
+ </div>
270
+ </div>
271
+ </CardContent>
272
+ </Card>
186
273
</div>
187
274
</div>
188
275
</div>
189
276
</BaseLayout>
190
277
191
-<script define:vars={{ repoOwner: repo.owner, repoName: repo.name }}>
278
+<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialCodecovConfigId: codecovConfig?.id || null }}>
192
279
const btn = document.getElementById("generate-token");
193
280
const output = document.getElementById("token-output");
194
281
const providerTemplate = document.getElementById("provider-template");
195
282
const payloadTemplate = document.getElementById("payload-template");
283
+ const saveCodecovBtn = document.getElementById("save-codecov");
284
+ const codecovSaveMsg = document.getElementById("codecov-save-msg");
285
+ const codecovProjectKey = document.getElementById("codecov-project-key");
286
+ const codecovApiToken = document.getElementById("codecov-api-token");
287
+ const codecovMinCoverage = document.getElementById("codecov-min-coverage");
288
+ const codecovBlockOnFail = document.getElementById("codecov-block-on-fail");
289
+ const codecovReportOnPr = document.getElementById("codecov-report-on-pr");
290
+ const codecovEnabled = document.getElementById("codecov-enabled");
291
+ const codecovWebhookSecret = document.getElementById("codecov-webhook-secret");
292
+ const codecovReports = document.getElementById("codecov-reports");
293
+ let codecovConfigId = initialCodecovConfigId;
196
294
197
295
const payloadTemplates = {
198
296
normalized: `{
289
387
btn.removeAttribute("disabled");
290
388
}
291
389
});
390
+
391
+ async function loadCodecovSummary() {
392
+ if (!codecovReports) return;
393
+ try {
394
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/code-quality?summary=1`);
395
+ const payload = await res.json();
396
+ if (!res.ok) {
397
+ throw new Error(payload?.error?.message || payload?.message || "Failed to load coverage summary");
398
+ }
399
+ const data = payload?.data || payload;
400
+ const codecov = (data?.configs || []).find((cfg) => cfg.provider === "codecov");
401
+ if (codecov) {
402
+ codecovConfigId = codecov.id;
403
+ if (codecovWebhookSecret) codecovWebhookSecret.textContent = codecov.webhookSecret || "Not configured";
404
+ }
405
+
406
+ const reports = (data?.coverage || []).filter((report) => report.provider === "codecov").slice(0, 5);
407
+ if (reports.length === 0) {
408
+ codecovReports.textContent = "No Codecov reports received yet.";
409
+ return;
410
+ }
411
+
412
+ codecovReports.innerHTML = reports.map((report) => `
413
+ <div class="rounded border border-white/10 bg-white/5 px-2 py-1.5 flex items-center justify-between gap-2">
414
+ <span>Coverage ${Number(report.coverage || 0).toFixed(1)}%</span>
415
+ <span class="text-[11px] text-muted-foreground font-mono">${(report.commitSha || "").slice(0, 7)}</span>
416
+ </div>
417
+ `).join("");
418
+ } catch (error) {
419
+ codecovReports.textContent = error.message || "Failed to load coverage summary";
420
+ }
421
+ }
422
+
423
+ saveCodecovBtn?.addEventListener("click", async () => {
424
+ if (!codecovSaveMsg) return;
425
+ saveCodecovBtn.setAttribute("disabled", "true");
426
+ codecovSaveMsg.textContent = "Saving Codecov config...";
427
+
428
+ const projectKey = codecovProjectKey?.value?.trim();
429
+ const apiToken = codecovApiToken?.value?.trim();
430
+ const minCoverageNumber = Number.parseFloat(codecovMinCoverage?.value || "0");
431
+ const payload = {
432
+ provider: "codecov",
433
+ ...(projectKey ? { projectKey } : {}),
434
+ ...(apiToken ? { apiToken } : {}),
435
+ minCoverage: Number.isFinite(minCoverageNumber) ? minCoverageNumber : 0,
436
+ blockOnFail: !!codecovBlockOnFail?.checked,
437
+ reportOnPR: !!codecovReportOnPr?.checked,
438
+ isEnabled: !!codecovEnabled?.checked,
439
+ };
440
+
441
+ try {
442
+ const res = await fetch(
443
+ codecovConfigId
444
+ ? `/api/repos/${repoOwner}/${repoName}/integrations/code-quality/${codecovConfigId}`
445
+ : `/api/repos/${repoOwner}/${repoName}/integrations/code-quality`,
446
+ {
447
+ method: codecovConfigId ? "PATCH" : "POST",
448
+ headers: { "Content-Type": "application/json" },
449
+ body: JSON.stringify(payload),
450
+ },
451
+ );
452
+ const body = await res.json();
453
+ if (!res.ok) {
454
+ throw new Error(body?.error?.message || body?.message || "Failed to save Codecov config");
455
+ }
456
+
457
+ const data = body?.data || body;
458
+ if (data?.id) {
459
+ codecovConfigId = data.id;
460
+ }
461
+ if (codecovWebhookSecret && data?.webhookSecret) {
462
+ codecovWebhookSecret.textContent = data.webhookSecret;
463
+ }
464
+ codecovSaveMsg.textContent = "Codecov configuration saved.";
465
+ if (codecovApiToken) codecovApiToken.value = "";
466
+ loadCodecovSummary();
467
+ } catch (error) {
468
+ codecovSaveMsg.textContent = error.message || "Failed to save Codecov config";
469
+ } finally {
470
+ saveCodecovBtn.removeAttribute("disabled");
471
+ }
472
+ });
473
+
474
+ loadCodecovSummary();
292
475
</script>
293
476
▼tests/integration/code-quality-route.test.ts
+148-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ getUserFromRequestMock,
5
+ canReadRepoMock,
6
+ canWriteRepoMock,
7
+ getQualityConfigsMock,
8
+ getCoverageHistoryMock,
9
+ getQualityIssuesMock,
10
+ configureQualityProviderMock,
11
+ fakeSchema,
12
+} = vi.hoisted(() => ({
13
+ getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
14
+ canReadRepoMock: vi.fn(async () => true),
15
+ canWriteRepoMock: vi.fn(async () => true),
16
+ getQualityConfigsMock: vi.fn(async () => []),
17
+ getCoverageHistoryMock: vi.fn(async () => []),
18
+ getQualityIssuesMock: vi.fn(async () => []),
19
+ configureQualityProviderMock: vi.fn(async () => ({
20
+ id: "quality-1",
21
+ repositoryId: "repo-1",
22
+ provider: "codecov",
23
+ projectKey: "owner/demo",
24
+ webhookSecret: "secret-1",
25
+ minCoverage: 85,
26
+ blockOnFail: true,
27
+ isEnabled: true,
28
+ reportOnPR: true,
29
+ })),
30
+ fakeSchema: {
31
+ users: { username: {} },
32
+ repositories: { ownerId: {}, name: {}, id: {} },
33
+ } as any,
34
+}));
35
+
36
+let mockDb: any;
37
+
38
+vi.mock("@/db", () => ({
39
+ getDatabase: () => mockDb,
40
+ schema: fakeSchema,
41
+}));
42
+
43
+vi.mock("@/lib/auth", () => ({
44
+ getUserFromRequest: getUserFromRequestMock,
45
+}));
46
+
47
+vi.mock("@/lib/permissions", () => ({
48
+ canReadRepo: canReadRepoMock,
49
+ canWriteRepo: canWriteRepoMock,
50
+}));
51
+
52
+vi.mock("@/lib/code-quality", () => ({
53
+ QUALITY_PROVIDERS: {
54
+ codecov: { name: "Codecov" },
55
+ coveralls: { name: "Coveralls" },
56
+ sonarqube: { name: "SonarQube" },
57
+ snyk: { name: "Snyk" },
58
+ },
59
+ getQualityConfigs: getQualityConfigsMock,
60
+ getCoverageHistory: getCoverageHistoryMock,
61
+ getQualityIssues: getQualityIssuesMock,
62
+ configureQualityProvider: configureQualityProviderMock,
63
+}));
64
+
65
+import { GET as codeQualityGet, POST as codeQualityPost } from "@/pages/api/repos/[owner]/[repo]/integrations/code-quality";
66
+
67
+function makeDb() {
68
+ return {
69
+ query: {
70
+ users: {
71
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
72
+ },
73
+ repositories: {
74
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })),
75
+ },
76
+ },
77
+ };
78
+}
79
+
80
+async function readJson(response: Response): Promise<any> {
81
+ return response.json();
82
+}
83
+
84
+describe("code quality integrations route", () => {
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ mockDb = makeDb();
88
+ });
89
+
90
+ it("returns summary including coverage and issues", async () => {
91
+ getQualityConfigsMock.mockResolvedValue([
92
+ {
93
+ id: "quality-1",
94
+ repositoryId: "repo-1",
95
+ provider: "codecov",
96
+ webhookSecret: "secret-1",
97
+ apiToken: "token",
98
+ },
99
+ ]);
100
+ getCoverageHistoryMock.mockResolvedValue([{ id: "cov-1", provider: "codecov", coverage: 89.2 }]);
101
+ getQualityIssuesMock.mockResolvedValue([{ id: "issue-1", provider: "sonarqube", severity: "major" }]);
102
+
103
+ const response = await codeQualityGet({
104
+ params: { owner: "owner-1", repo: "demo" },
105
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/code-quality?summary=1"),
106
+ url: new URL("http://localhost/api/repos/owner-1/demo/integrations/code-quality?summary=1"),
107
+ } as any);
108
+
109
+ const body = await readJson(response);
110
+ expect(response.status).toBe(200);
111
+ expect(body?.data?.providers?.length).toBe(4);
112
+ expect(body?.data?.configs?.[0]?.provider).toBe("codecov");
113
+ expect(body?.data?.configs?.[0]?.apiToken).toBeUndefined();
114
+ expect(body?.data?.coverage?.[0]?.provider).toBe("codecov");
115
+ expect(body?.data?.issues?.[0]?.provider).toBe("sonarqube");
116
+ });
117
+
118
+ it("creates codecov quality config for authorized writers", async () => {
119
+ const response = await codeQualityPost({
120
+ params: { owner: "owner-1", repo: "demo" },
121
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/code-quality", {
122
+ method: "POST",
123
+ headers: { "content-type": "application/json" },
124
+ body: JSON.stringify({
125
+ provider: "codecov",
126
+ projectKey: "owner/demo",
127
+ apiToken: "token-1",
128
+ minCoverage: 85,
129
+ blockOnFail: true,
130
+ }),
131
+ }),
132
+ } as any);
133
+
134
+ const body = await readJson(response);
135
+ expect(response.status).toBe(200);
136
+ expect(configureQualityProviderMock).toHaveBeenCalledWith({
137
+ repositoryId: "repo-1",
138
+ provider: "codecov",
139
+ projectKey: "owner/demo",
140
+ apiToken: "token-1",
141
+ serverUrl: undefined,
142
+ minCoverage: 85,
143
+ blockOnFail: true,
144
+ });
145
+ expect(body?.data?.provider).toBe("codecov");
146
+ expect(body?.data?.apiToken).toBeUndefined();
147
+ });
148
+});
0
149