Complete Codecov automation UX and route coverage tests
swadhinbiswas committed 8 months ago
715bdcf
1 parent e73d9d0
commit 715bdcf
3 files changed
+335 -4
Showing 3 changed files+335-4
▼doc/feature_audit.md
+3-3
| @@ -12,14 +12,14 @@ | ||
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,7 +103,7 @@ | ||
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,9 +39,16 @@ if (!hasAccess) { | ||
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,16 +190,107 @@ const repo = { | ||
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,4 +387,89 @@ const repo = { | ||
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
| @@ -0,0 +1,148 @@ | ||
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 | |