Add GitLab CircleCI Buildkite Jenkins integration management
swadhinbiswas committed 8 months ago
fea3bac
1 parent 4e7efaa
commit fea3bac
5 files changed
+875 -10
Showing 5 changed files+875-10
▼doc/feature_audit.md
+8-8
| @@ -11,15 +11,15 @@ | ||
11 | 11 | | **Pull Requests** | 9 | 6 | 0 | 15 | |
12 | 12 | | **Code Review** | 9 | 1 | 0 | 10 | |
13 | 13 | | **Issues & Planning** | 10 | 0 | 0 | 10 | |
14 | -| **CI/CD & Automation** | 6 | 2 | 0 | 8 | | |
15 | -| **Third-Party Integrations** | 8 | 14 | 0 | 22 | | |
14 | +| **CI/CD & Automation** | 7 | 1 | 0 | 8 | | |
15 | +| **Third-Party Integrations** | 12 | 10 | 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** | **81** | **44** | **0** | **125** | | |
22 | +| **Total** | **86** | **39** | **0** | **125** | | |
23 | 23 | |
24 | 24 | --- |
25 | 25 | |
| @@ -91,7 +91,7 @@ | ||
91 | 91 | | Feature | Status | Implementation Notes | |
92 | 92 | | :--- | :---: | :--- | |
93 | 93 | | Native CI pipeline support | ✅ | `pipeline.ts` - 1066 lines, GitHub Actions compatible | |
94 | -| External CI integration | ⚠️ | Provider-aware integration APIs and normalized status ingestion are implemented, with repo-level integration health/status + token rotate/disable APIs (`external-ci`, `external-ci/checks`); provider-specific setup UX still maturing | | |
94 | +| External CI integration | ✅ | Provider-aware integration APIs and normalized status ingestion are implemented, with repo-level integration health/status + token rotate/disable APIs (`external-ci`, `external-ci/checks`) plus provider-specific configuration/summary APIs and settings UX for GitLab/CircleCI/Buildkite/Jenkins (`integrations/external-ci`) including setup fields, webhook secret visibility, and recent build surfacing | | |
95 | 95 | | Self-hosted runners | ✅ | `runner/` with Docker executor | |
96 | 96 | | Secrets management | ✅ | Schema + UI for repo/org secrets | |
97 | 97 | | Merge checks & gates | ✅ | Required status checks + external CI gate readiness are enforced, with explicit PR gate introspection via `pulls/{number}/merge-readiness` now including policy reports (`failedByType`, recommendations, pass/fail counts), active label/review/custom gate evaluation in `ci-gates.ts` with gate-type metadata, repository-level gate policy management/reporting APIs (`/repos/{owner}/{repo}/merge-gates`, `/repos/{owner}/{repo}/merge-gates/{id}`), and PR detail merge-gate policy UX for live blockers/breakdowns/remediation guidance | |
| @@ -112,10 +112,10 @@ | ||
112 | 112 | | Feature | Status | Notes | |
113 | 113 | | :--- | :---: | :--- | |
114 | 114 | | GitHub Actions | ✅ | Compatible workflow format | |
115 | -| GitLab CI | ⚠️ | External CI provider mapping + status normalization + repo integration APIs implemented | | |
116 | -| CircleCI | ⚠️ | External CI provider mapping + status normalization + repo integration APIs implemented | | |
117 | -| Buildkite | ⚠️ | External CI provider mapping + status normalization + repo integration APIs implemented | | |
118 | -| Jenkins | ⚠️ | External CI provider mapping + status normalization + repo integration APIs implemented | | |
115 | +| GitLab CI | ✅ | Provider mapping + status normalization are implemented, and repo settings now support GitLab config save/update (`baseUrl`, `projectId`, token, sync toggles), webhook secret visibility, and recent build surfacing via `integrations/external-ci` | | |
116 | +| CircleCI | ✅ | Provider mapping + status normalization are implemented, and repo settings now support CircleCI config save/update (`baseUrl`, `projectId`, token, sync toggles), webhook secret visibility, and recent build surfacing via `integrations/external-ci` | | |
117 | +| Buildkite | ✅ | Provider mapping + status normalization are implemented, and repo settings now support Buildkite config save/update (`baseUrl`, `projectId`, token, sync toggles), webhook secret visibility, and recent build surfacing via `integrations/external-ci` | | |
118 | +| Jenkins | ✅ | Provider mapping + status normalization are implemented, and repo settings now support Jenkins config save/update (`baseUrl`, `projectId`, token, sync toggles), webhook secret visibility, and recent build surfacing via `integrations/external-ci` | | |
119 | 119 | |
120 | 120 | ### 6.3 Issue Tracking |
121 | 121 | | Feature | Status | Notes | |
122 | 122 | |
▼src/pages/[owner]/[repo]/settings/external-ci.astro
+398-2
| @@ -3,7 +3,7 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; | ||
3 | 3 | import { Button } from "@/components/ui/button"; |
4 | 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; |
5 | 5 | import { getDatabase, schema } from "@/db"; |
6 | -import { eq, and } from "drizzle-orm"; | |
6 | +import { eq, and, inArray } from "drizzle-orm"; | |
7 | 7 | import { canAdminRepo } from "@/lib/permissions"; |
8 | 8 | import RepoHeader from "@/components/repo/RepoHeader.astro"; |
9 | 9 | import { Plug, ShieldCheck } from "lucide-react"; |
| @@ -39,6 +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 externalConfigs = await db.query.externalCIConfigs.findMany({ | |
43 | + where: and( | |
44 | + eq(schema.externalCIConfigs.repositoryId, repoData.id), | |
45 | + inArray(schema.externalCIConfigs.provider, ["gitlab", "circleci", "buildkite", "jenkins"]) | |
46 | + ), | |
47 | +}); | |
48 | +const gitlabConfig = externalConfigs.find((config) => config.provider === "gitlab"); | |
49 | +const circleciConfig = externalConfigs.find((config) => config.provider === "circleci"); | |
50 | +const buildkiteConfig = externalConfigs.find((config) => config.provider === "buildkite"); | |
51 | +const jenkinsConfig = externalConfigs.find((config) => config.provider === "jenkins"); | |
42 | 52 | const qualityConfigs = await db.query.codeQualityConfigs.findMany({ |
43 | 53 | where: eq(schema.codeQualityConfigs.repositoryId, repoData.id), |
44 | 54 | }); |
| @@ -195,6 +205,158 @@ const repo = { | ||
195 | 205 | </CardContent> |
196 | 206 | </Card> |
197 | 207 | |
208 | + <Card> | |
209 | + <CardHeader> | |
210 | + <CardTitle>GitLab CI Integration</CardTitle> | |
211 | + <CardDescription> | |
212 | + Configure GitLab pipeline sync, webhook secret, and provider-specific setup fields. | |
213 | + </CardDescription> | |
214 | + </CardHeader> | |
215 | + <CardContent className="space-y-4"> | |
216 | + <div class="grid gap-3 md:grid-cols-3"> | |
217 | + <div> | |
218 | + <label for="gitlab-base-url" class="block text-xs text-muted-foreground mb-1">GitLab Base URL</label> | |
219 | + <input id="gitlab-base-url" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={gitlabConfig?.baseUrl || "https://gitlab.com"} /> | |
220 | + </div> | |
221 | + <div> | |
222 | + <label for="gitlab-project-id" class="block text-xs text-muted-foreground mb-1">Project ID</label> | |
223 | + <input id="gitlab-project-id" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={gitlabConfig?.projectId || ""} placeholder="group%2Frepo or numeric id" /> | |
224 | + </div> | |
225 | + <div> | |
226 | + <label for="gitlab-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label> | |
227 | + <input id="gitlab-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={gitlabConfig ? "•••••••• (leave empty to keep)" : "Token"} /> | |
228 | + </div> | |
229 | + </div> | |
230 | + <div class="grid gap-3 md:grid-cols-3"> | |
231 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="gitlab-sync-status" type="checkbox" checked={gitlabConfig?.syncStatus ?? true} />Sync status to PRs</label> | |
232 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="gitlab-enabled" type="checkbox" checked={gitlabConfig?.isEnabled ?? true} />Enabled</label> | |
233 | + </div> | |
234 | + <div class="text-xs text-muted-foreground">Webhook secret: <span id="gitlab-webhook-secret" class="font-mono">{gitlabConfig?.webhookSecret || "Not configured yet"}</span></div> | |
235 | + <div class="flex items-center gap-2"> | |
236 | + <Button id="save-gitlab" variant="outline">Save GitLab CI Config</Button> | |
237 | + <span id="gitlab-save-msg" class="text-xs text-muted-foreground"></span> | |
238 | + </div> | |
239 | + <div class="pt-2 border-t border-white/10"> | |
240 | + <div class="text-xs font-medium text-muted-foreground mb-2">Recent GitLab Builds</div> | |
241 | + <div id="gitlab-builds" class="space-y-1 text-xs text-muted-foreground">Loading builds...</div> | |
242 | + </div> | |
243 | + </CardContent> | |
244 | + </Card> | |
245 | + | |
246 | + <Card> | |
247 | + <CardHeader> | |
248 | + <CardTitle>CircleCI Integration</CardTitle> | |
249 | + <CardDescription> | |
250 | + Configure CircleCI pipeline sync and status ingestion for pull request checks. | |
251 | + </CardDescription> | |
252 | + </CardHeader> | |
253 | + <CardContent className="space-y-4"> | |
254 | + <div class="grid gap-3 md:grid-cols-3"> | |
255 | + <div> | |
256 | + <label for="circleci-base-url" class="block text-xs text-muted-foreground mb-1">CircleCI Base URL</label> | |
257 | + <input id="circleci-base-url" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={circleciConfig?.baseUrl || "https://circleci.com"} /> | |
258 | + </div> | |
259 | + <div> | |
260 | + <label for="circleci-project-id" class="block text-xs text-muted-foreground mb-1">Project ID</label> | |
261 | + <input id="circleci-project-id" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={circleciConfig?.projectId || ""} placeholder="gh/org/repo" /> | |
262 | + </div> | |
263 | + <div> | |
264 | + <label for="circleci-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label> | |
265 | + <input id="circleci-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={circleciConfig ? "•••••••• (leave empty to keep)" : "Token"} /> | |
266 | + </div> | |
267 | + </div> | |
268 | + <div class="grid gap-3 md:grid-cols-3"> | |
269 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="circleci-sync-status" type="checkbox" checked={circleciConfig?.syncStatus ?? true} />Sync status to PRs</label> | |
270 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="circleci-enabled" type="checkbox" checked={circleciConfig?.isEnabled ?? true} />Enabled</label> | |
271 | + </div> | |
272 | + <div class="text-xs text-muted-foreground">Webhook secret: <span id="circleci-webhook-secret" class="font-mono">{circleciConfig?.webhookSecret || "Not configured yet"}</span></div> | |
273 | + <div class="flex items-center gap-2"> | |
274 | + <Button id="save-circleci" variant="outline">Save CircleCI Config</Button> | |
275 | + <span id="circleci-save-msg" class="text-xs text-muted-foreground"></span> | |
276 | + </div> | |
277 | + <div class="pt-2 border-t border-white/10"> | |
278 | + <div class="text-xs font-medium text-muted-foreground mb-2">Recent CircleCI Builds</div> | |
279 | + <div id="circleci-builds" class="space-y-1 text-xs text-muted-foreground">Loading builds...</div> | |
280 | + </div> | |
281 | + </CardContent> | |
282 | + </Card> | |
283 | + | |
284 | + <Card> | |
285 | + <CardHeader> | |
286 | + <CardTitle>Buildkite Integration</CardTitle> | |
287 | + <CardDescription> | |
288 | + Configure Buildkite trigger/status endpoints and synchronized PR checks. | |
289 | + </CardDescription> | |
290 | + </CardHeader> | |
291 | + <CardContent className="space-y-4"> | |
292 | + <div class="grid gap-3 md:grid-cols-3"> | |
293 | + <div> | |
294 | + <label for="buildkite-base-url" class="block text-xs text-muted-foreground mb-1">Buildkite Base URL</label> | |
295 | + <input id="buildkite-base-url" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={buildkiteConfig?.baseUrl || "https://api.buildkite.com/v2/organizations"} /> | |
296 | + </div> | |
297 | + <div> | |
298 | + <label for="buildkite-project-id" class="block text-xs text-muted-foreground mb-1">Pipeline Path</label> | |
299 | + <input id="buildkite-project-id" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={buildkiteConfig?.projectId || ""} placeholder="acme/pipelines/app" /> | |
300 | + </div> | |
301 | + <div> | |
302 | + <label for="buildkite-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label> | |
303 | + <input id="buildkite-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={buildkiteConfig ? "•••••••• (leave empty to keep)" : "Token"} /> | |
304 | + </div> | |
305 | + </div> | |
306 | + <div class="grid gap-3 md:grid-cols-3"> | |
307 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="buildkite-sync-status" type="checkbox" checked={buildkiteConfig?.syncStatus ?? true} />Sync status to PRs</label> | |
308 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="buildkite-enabled" type="checkbox" checked={buildkiteConfig?.isEnabled ?? true} />Enabled</label> | |
309 | + </div> | |
310 | + <div class="text-xs text-muted-foreground">Webhook secret: <span id="buildkite-webhook-secret" class="font-mono">{buildkiteConfig?.webhookSecret || "Not configured yet"}</span></div> | |
311 | + <div class="flex items-center gap-2"> | |
312 | + <Button id="save-buildkite" variant="outline">Save Buildkite Config</Button> | |
313 | + <span id="buildkite-save-msg" class="text-xs text-muted-foreground"></span> | |
314 | + </div> | |
315 | + <div class="pt-2 border-t border-white/10"> | |
316 | + <div class="text-xs font-medium text-muted-foreground mb-2">Recent Buildkite Builds</div> | |
317 | + <div id="buildkite-builds" class="space-y-1 text-xs text-muted-foreground">Loading builds...</div> | |
318 | + </div> | |
319 | + </CardContent> | |
320 | + </Card> | |
321 | + | |
322 | + <Card> | |
323 | + <CardHeader> | |
324 | + <CardTitle>Jenkins Integration</CardTitle> | |
325 | + <CardDescription> | |
326 | + Configure Jenkins job sync, credentials, and pull request status ingestion. | |
327 | + </CardDescription> | |
328 | + </CardHeader> | |
329 | + <CardContent className="space-y-4"> | |
330 | + <div class="grid gap-3 md:grid-cols-3"> | |
331 | + <div> | |
332 | + <label for="jenkins-base-url" class="block text-xs text-muted-foreground mb-1">Jenkins Base URL</label> | |
333 | + <input id="jenkins-base-url" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={jenkinsConfig?.baseUrl || "https://jenkins.example.com"} /> | |
334 | + </div> | |
335 | + <div> | |
336 | + <label for="jenkins-project-id" class="block text-xs text-muted-foreground mb-1">Job Name</label> | |
337 | + <input id="jenkins-project-id" class="h-8 w-full rounded-md border bg-background px-2 text-xs" value={jenkinsConfig?.projectId || ""} placeholder="team/job-name" /> | |
338 | + </div> | |
339 | + <div> | |
340 | + <label for="jenkins-api-token" class="block text-xs text-muted-foreground mb-1">API Token</label> | |
341 | + <input id="jenkins-api-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder={jenkinsConfig ? "•••••••• (leave empty to keep)" : "Token"} /> | |
342 | + </div> | |
343 | + </div> | |
344 | + <div class="grid gap-3 md:grid-cols-3"> | |
345 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="jenkins-sync-status" type="checkbox" checked={jenkinsConfig?.syncStatus ?? true} />Sync status to PRs</label> | |
346 | + <label class="flex items-center gap-2 text-xs text-muted-foreground mt-2"><input id="jenkins-enabled" type="checkbox" checked={jenkinsConfig?.isEnabled ?? true} />Enabled</label> | |
347 | + </div> | |
348 | + <div class="text-xs text-muted-foreground">Webhook secret: <span id="jenkins-webhook-secret" class="font-mono">{jenkinsConfig?.webhookSecret || "Not configured yet"}</span></div> | |
349 | + <div class="flex items-center gap-2"> | |
350 | + <Button id="save-jenkins" variant="outline">Save Jenkins Config</Button> | |
351 | + <span id="jenkins-save-msg" class="text-xs text-muted-foreground"></span> | |
352 | + </div> | |
353 | + <div class="pt-2 border-t border-white/10"> | |
354 | + <div class="text-xs font-medium text-muted-foreground mb-2">Recent Jenkins Builds</div> | |
355 | + <div id="jenkins-builds" class="space-y-1 text-xs text-muted-foreground">Loading builds...</div> | |
356 | + </div> | |
357 | + </CardContent> | |
358 | + </Card> | |
359 | + | |
198 | 360 | <Card> |
199 | 361 | <CardHeader> |
200 | 362 | <CardTitle>Codecov Coverage Automation</CardTitle> |
| @@ -404,11 +566,51 @@ const repo = { | ||
404 | 566 | </div> |
405 | 567 | </BaseLayout> |
406 | 568 | |
407 | -<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialCodecovConfigId: codecovConfig?.id || null, initialCoverallsConfigId: coverallsConfig?.id || null, initialSonarConfigId: sonarqubeConfig?.id || null, initialSnykConfigId: snykConfig?.id || null }}> | |
569 | +<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialGitlabConfigId: gitlabConfig?.id || null, initialCircleciConfigId: circleciConfig?.id || null, initialBuildkiteConfigId: buildkiteConfig?.id || null, initialJenkinsConfigId: jenkinsConfig?.id || null, initialCodecovConfigId: codecovConfig?.id || null, initialCoverallsConfigId: coverallsConfig?.id || null, initialSonarConfigId: sonarqubeConfig?.id || null, initialSnykConfigId: snykConfig?.id || null }}> | |
408 | 570 | const btn = document.getElementById("generate-token"); |
409 | 571 | const output = document.getElementById("token-output"); |
410 | 572 | const providerTemplate = document.getElementById("provider-template"); |
411 | 573 | const payloadTemplate = document.getElementById("payload-template"); |
574 | + const saveGitlabBtn = document.getElementById("save-gitlab"); | |
575 | + const gitlabSaveMsg = document.getElementById("gitlab-save-msg"); | |
576 | + const gitlabBaseUrl = document.getElementById("gitlab-base-url"); | |
577 | + const gitlabProjectId = document.getElementById("gitlab-project-id"); | |
578 | + const gitlabApiToken = document.getElementById("gitlab-api-token"); | |
579 | + const gitlabSyncStatus = document.getElementById("gitlab-sync-status"); | |
580 | + const gitlabEnabled = document.getElementById("gitlab-enabled"); | |
581 | + const gitlabWebhookSecret = document.getElementById("gitlab-webhook-secret"); | |
582 | + const gitlabBuilds = document.getElementById("gitlab-builds"); | |
583 | + let gitlabConfigId = initialGitlabConfigId; | |
584 | + const saveCircleciBtn = document.getElementById("save-circleci"); | |
585 | + const circleciSaveMsg = document.getElementById("circleci-save-msg"); | |
586 | + const circleciBaseUrl = document.getElementById("circleci-base-url"); | |
587 | + const circleciProjectId = document.getElementById("circleci-project-id"); | |
588 | + const circleciApiToken = document.getElementById("circleci-api-token"); | |
589 | + const circleciSyncStatus = document.getElementById("circleci-sync-status"); | |
590 | + const circleciEnabled = document.getElementById("circleci-enabled"); | |
591 | + const circleciWebhookSecret = document.getElementById("circleci-webhook-secret"); | |
592 | + const circleciBuilds = document.getElementById("circleci-builds"); | |
593 | + let circleciConfigId = initialCircleciConfigId; | |
594 | + const saveBuildkiteBtn = document.getElementById("save-buildkite"); | |
595 | + const buildkiteSaveMsg = document.getElementById("buildkite-save-msg"); | |
596 | + const buildkiteBaseUrl = document.getElementById("buildkite-base-url"); | |
597 | + const buildkiteProjectId = document.getElementById("buildkite-project-id"); | |
598 | + const buildkiteApiToken = document.getElementById("buildkite-api-token"); | |
599 | + const buildkiteSyncStatus = document.getElementById("buildkite-sync-status"); | |
600 | + const buildkiteEnabled = document.getElementById("buildkite-enabled"); | |
601 | + const buildkiteWebhookSecret = document.getElementById("buildkite-webhook-secret"); | |
602 | + const buildkiteBuilds = document.getElementById("buildkite-builds"); | |
603 | + let buildkiteConfigId = initialBuildkiteConfigId; | |
604 | + const saveJenkinsBtn = document.getElementById("save-jenkins"); | |
605 | + const jenkinsSaveMsg = document.getElementById("jenkins-save-msg"); | |
606 | + const jenkinsBaseUrl = document.getElementById("jenkins-base-url"); | |
607 | + const jenkinsProjectId = document.getElementById("jenkins-project-id"); | |
608 | + const jenkinsApiToken = document.getElementById("jenkins-api-token"); | |
609 | + const jenkinsSyncStatus = document.getElementById("jenkins-sync-status"); | |
610 | + const jenkinsEnabled = document.getElementById("jenkins-enabled"); | |
611 | + const jenkinsWebhookSecret = document.getElementById("jenkins-webhook-secret"); | |
612 | + const jenkinsBuilds = document.getElementById("jenkins-builds"); | |
613 | + let jenkinsConfigId = initialJenkinsConfigId; | |
412 | 614 | const saveCodecovBtn = document.getElementById("save-codecov"); |
413 | 615 | const codecovSaveMsg = document.getElementById("codecov-save-msg"); |
414 | 616 | const codecovProjectKey = document.getElementById("codecov-project-key"); |
| @@ -551,6 +753,99 @@ const repo = { | ||
551 | 753 | } |
552 | 754 | }); |
553 | 755 | |
756 | + function renderBuildList(target, builds, emptyMessage) { | |
757 | + if (!target) return; | |
758 | + target.innerHTML = builds.length === 0 | |
759 | + ? emptyMessage | |
760 | + : builds.map((build) => ` | |
761 | + <div class="rounded border border-white/10 bg-white/5 px-2 py-1.5 flex items-center justify-between gap-2"> | |
762 | + <span>${build.status || "unknown"}</span> | |
763 | + <span class="text-[11px] text-muted-foreground">${build.buildNumber ? `#${build.buildNumber}` : (build.externalBuildId || "").slice(0, 12)}</span> | |
764 | + </div> | |
765 | + `).join(""); | |
766 | + } | |
767 | + | |
768 | + async function loadExternalCiSummary() { | |
769 | + try { | |
770 | + const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/external-ci?summary=1`); | |
771 | + const payload = await res.json(); | |
772 | + if (!res.ok) { | |
773 | + throw new Error(payload?.error?.message || payload?.message || "Failed to load external CI summary"); | |
774 | + } | |
775 | + | |
776 | + const data = payload?.data || payload; | |
777 | + const configs = data?.configs || []; | |
778 | + const builds = data?.builds || []; | |
779 | + const configByProvider = { | |
780 | + gitlab: configs.find((config) => config.provider === "gitlab"), | |
781 | + circleci: configs.find((config) => config.provider === "circleci"), | |
782 | + buildkite: configs.find((config) => config.provider === "buildkite"), | |
783 | + jenkins: configs.find((config) => config.provider === "jenkins"), | |
784 | + }; | |
785 | + | |
786 | + if (configByProvider.gitlab) { | |
787 | + gitlabConfigId = configByProvider.gitlab.id; | |
788 | + if (gitlabWebhookSecret) gitlabWebhookSecret.textContent = configByProvider.gitlab.webhookSecret || "Not configured"; | |
789 | + } | |
790 | + if (configByProvider.circleci) { | |
791 | + circleciConfigId = configByProvider.circleci.id; | |
792 | + if (circleciWebhookSecret) circleciWebhookSecret.textContent = configByProvider.circleci.webhookSecret || "Not configured"; | |
793 | + } | |
794 | + if (configByProvider.buildkite) { | |
795 | + buildkiteConfigId = configByProvider.buildkite.id; | |
796 | + if (buildkiteWebhookSecret) buildkiteWebhookSecret.textContent = configByProvider.buildkite.webhookSecret || "Not configured"; | |
797 | + } | |
798 | + if (configByProvider.jenkins) { | |
799 | + jenkinsConfigId = configByProvider.jenkins.id; | |
800 | + if (jenkinsWebhookSecret) jenkinsWebhookSecret.textContent = configByProvider.jenkins.webhookSecret || "Not configured"; | |
801 | + } | |
802 | + | |
803 | + renderBuildList( | |
804 | + gitlabBuilds, | |
805 | + builds.filter((build) => build.configId === configByProvider.gitlab?.id).slice(0, 5), | |
806 | + "No GitLab builds synced yet." | |
807 | + ); | |
808 | + renderBuildList( | |
809 | + circleciBuilds, | |
810 | + builds.filter((build) => build.configId === configByProvider.circleci?.id).slice(0, 5), | |
811 | + "No CircleCI builds synced yet." | |
812 | + ); | |
813 | + renderBuildList( | |
814 | + buildkiteBuilds, | |
815 | + builds.filter((build) => build.configId === configByProvider.buildkite?.id).slice(0, 5), | |
816 | + "No Buildkite builds synced yet." | |
817 | + ); | |
818 | + renderBuildList( | |
819 | + jenkinsBuilds, | |
820 | + builds.filter((build) => build.configId === configByProvider.jenkins?.id).slice(0, 5), | |
821 | + "No Jenkins builds synced yet." | |
822 | + ); | |
823 | + } catch (error) { | |
824 | + if (gitlabBuilds) gitlabBuilds.textContent = error.message || "Failed to load builds"; | |
825 | + if (circleciBuilds) circleciBuilds.textContent = error.message || "Failed to load builds"; | |
826 | + if (buildkiteBuilds) buildkiteBuilds.textContent = error.message || "Failed to load builds"; | |
827 | + if (jenkinsBuilds) jenkinsBuilds.textContent = error.message || "Failed to load builds"; | |
828 | + } | |
829 | + } | |
830 | + | |
831 | + async function saveExternalCiProvider(options) { | |
832 | + const res = await fetch( | |
833 | + options.configId | |
834 | + ? `/api/repos/${repoOwner}/${repoName}/integrations/external-ci/${options.configId}` | |
835 | + : `/api/repos/${repoOwner}/${repoName}/integrations/external-ci`, | |
836 | + { | |
837 | + method: options.configId ? "PATCH" : "POST", | |
838 | + headers: { "Content-Type": "application/json" }, | |
839 | + body: JSON.stringify(options.payload), | |
840 | + }, | |
841 | + ); | |
842 | + const body = await res.json(); | |
843 | + if (!res.ok) { | |
844 | + throw new Error(body?.error?.message || body?.message || "Failed to save external CI config"); | |
845 | + } | |
846 | + return body?.data || body; | |
847 | + } | |
848 | + | |
554 | 849 | async function loadQualitySummary() { |
555 | 850 | try { |
556 | 851 | const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/code-quality?summary=1`); |
| @@ -622,6 +917,106 @@ const repo = { | ||
622 | 917 | } |
623 | 918 | } |
624 | 919 | |
920 | + saveGitlabBtn?.addEventListener("click", async () => { | |
921 | + if (!gitlabSaveMsg) return; | |
922 | + gitlabSaveMsg.textContent = "Saving GitLab config..."; | |
923 | + try { | |
924 | + const saved = await saveExternalCiProvider({ | |
925 | + configId: gitlabConfigId, | |
926 | + payload: { | |
927 | + provider: "gitlab", | |
928 | + name: "GitLab CI", | |
929 | + baseUrl: gitlabBaseUrl?.value?.trim(), | |
930 | + projectId: gitlabProjectId?.value?.trim() || undefined, | |
931 | + apiToken: gitlabApiToken?.value?.trim() || undefined, | |
932 | + syncStatus: !!gitlabSyncStatus?.checked, | |
933 | + isEnabled: !!gitlabEnabled?.checked, | |
934 | + }, | |
935 | + }); | |
936 | + if (saved?.id) gitlabConfigId = saved.id; | |
937 | + if (gitlabApiToken) gitlabApiToken.value = ""; | |
938 | + gitlabSaveMsg.textContent = "GitLab CI configuration saved."; | |
939 | + loadExternalCiSummary(); | |
940 | + } catch (error) { | |
941 | + gitlabSaveMsg.textContent = error.message || "Failed to save GitLab config"; | |
942 | + } | |
943 | + }); | |
944 | + | |
945 | + saveCircleciBtn?.addEventListener("click", async () => { | |
946 | + if (!circleciSaveMsg) return; | |
947 | + circleciSaveMsg.textContent = "Saving CircleCI config..."; | |
948 | + try { | |
949 | + const saved = await saveExternalCiProvider({ | |
950 | + configId: circleciConfigId, | |
951 | + payload: { | |
952 | + provider: "circleci", | |
953 | + name: "CircleCI", | |
954 | + baseUrl: circleciBaseUrl?.value?.trim(), | |
955 | + projectId: circleciProjectId?.value?.trim() || undefined, | |
956 | + apiToken: circleciApiToken?.value?.trim() || undefined, | |
957 | + syncStatus: !!circleciSyncStatus?.checked, | |
958 | + isEnabled: !!circleciEnabled?.checked, | |
959 | + }, | |
960 | + }); | |
961 | + if (saved?.id) circleciConfigId = saved.id; | |
962 | + if (circleciApiToken) circleciApiToken.value = ""; | |
963 | + circleciSaveMsg.textContent = "CircleCI configuration saved."; | |
964 | + loadExternalCiSummary(); | |
965 | + } catch (error) { | |
966 | + circleciSaveMsg.textContent = error.message || "Failed to save CircleCI config"; | |
967 | + } | |
968 | + }); | |
969 | + | |
970 | + saveBuildkiteBtn?.addEventListener("click", async () => { | |
971 | + if (!buildkiteSaveMsg) return; | |
972 | + buildkiteSaveMsg.textContent = "Saving Buildkite config..."; | |
973 | + try { | |
974 | + const saved = await saveExternalCiProvider({ | |
975 | + configId: buildkiteConfigId, | |
976 | + payload: { | |
977 | + provider: "buildkite", | |
978 | + name: "Buildkite", | |
979 | + baseUrl: buildkiteBaseUrl?.value?.trim(), | |
980 | + projectId: buildkiteProjectId?.value?.trim() || undefined, | |
981 | + apiToken: buildkiteApiToken?.value?.trim() || undefined, | |
982 | + syncStatus: !!buildkiteSyncStatus?.checked, | |
983 | + isEnabled: !!buildkiteEnabled?.checked, | |
984 | + }, | |
985 | + }); | |
986 | + if (saved?.id) buildkiteConfigId = saved.id; | |
987 | + if (buildkiteApiToken) buildkiteApiToken.value = ""; | |
988 | + buildkiteSaveMsg.textContent = "Buildkite configuration saved."; | |
989 | + loadExternalCiSummary(); | |
990 | + } catch (error) { | |
991 | + buildkiteSaveMsg.textContent = error.message || "Failed to save Buildkite config"; | |
992 | + } | |
993 | + }); | |
994 | + | |
995 | + saveJenkinsBtn?.addEventListener("click", async () => { | |
996 | + if (!jenkinsSaveMsg) return; | |
997 | + jenkinsSaveMsg.textContent = "Saving Jenkins config..."; | |
998 | + try { | |
999 | + const saved = await saveExternalCiProvider({ | |
1000 | + configId: jenkinsConfigId, | |
1001 | + payload: { | |
1002 | + provider: "jenkins", | |
1003 | + name: "Jenkins", | |
1004 | + baseUrl: jenkinsBaseUrl?.value?.trim(), | |
1005 | + projectId: jenkinsProjectId?.value?.trim() || undefined, | |
1006 | + apiToken: jenkinsApiToken?.value?.trim() || undefined, | |
1007 | + syncStatus: !!jenkinsSyncStatus?.checked, | |
1008 | + isEnabled: !!jenkinsEnabled?.checked, | |
1009 | + }, | |
1010 | + }); | |
1011 | + if (saved?.id) jenkinsConfigId = saved.id; | |
1012 | + if (jenkinsApiToken) jenkinsApiToken.value = ""; | |
1013 | + jenkinsSaveMsg.textContent = "Jenkins configuration saved."; | |
1014 | + loadExternalCiSummary(); | |
1015 | + } catch (error) { | |
1016 | + jenkinsSaveMsg.textContent = error.message || "Failed to save Jenkins config"; | |
1017 | + } | |
1018 | + }); | |
1019 | + | |
625 | 1020 | saveCodecovBtn?.addEventListener("click", async () => { |
626 | 1021 | if (!codecovSaveMsg) return; |
627 | 1022 | saveCodecovBtn.setAttribute("disabled", "true"); |
| @@ -764,5 +1159,6 @@ const repo = { | ||
764 | 1159 | } |
765 | 1160 | }); |
766 | 1161 | |
1162 | + loadExternalCiSummary(); | |
767 | 1163 | loadQualitySummary(); |
768 | 1164 | </script> |
769 | 1165 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci.ts
+163-0
| @@ -0,0 +1,163 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, desc, eq, inArray } from "drizzle-orm"; | |
4 | +import crypto from "crypto"; | |
5 | +import { z } from "zod"; | |
6 | +import { getDatabase, schema } from "@/db"; | |
7 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
8 | +import { withErrorHandler } from "@/lib/errors"; | |
9 | +import { getUserFromRequest } from "@/lib/auth"; | |
10 | +import { canReadRepo, canWriteRepo } from "@/lib/permissions"; | |
11 | +import { CI_PROVIDERS } from "@/lib/external-ci"; | |
12 | + | |
13 | +const SUPPORTED_PROVIDERS = ["gitlab", "circleci", "buildkite", "jenkins"] as const; | |
14 | + | |
15 | +const createSchema = z.object({ | |
16 | + provider: z.enum(SUPPORTED_PROVIDERS), | |
17 | + name: z.string().min(1).max(120).optional(), | |
18 | + baseUrl: z.string().url(), | |
19 | + apiToken: z.string().min(1).optional(), | |
20 | + projectId: z.string().min(1).max(300).optional(), | |
21 | + isEnabled: z.boolean().optional(), | |
22 | + syncStatus: z.boolean().optional(), | |
23 | +}); | |
24 | + | |
25 | +async function resolveRepository(owner: string, repoName: string) { | |
26 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
27 | + const repoOwner = await db.query.users.findFirst({ | |
28 | + where: eq(schema.users.username, owner), | |
29 | + }); | |
30 | + if (!repoOwner) return null; | |
31 | + | |
32 | + return db.query.repositories.findFirst({ | |
33 | + where: and( | |
34 | + eq(schema.repositories.ownerId, repoOwner.id), | |
35 | + eq(schema.repositories.name, repoName) | |
36 | + ), | |
37 | + }); | |
38 | +} | |
39 | + | |
40 | +function toProviderEntry(provider: (typeof SUPPORTED_PROVIDERS)[number]) { | |
41 | + const source = CI_PROVIDERS[provider]; | |
42 | + return { | |
43 | + id: provider, | |
44 | + ...source, | |
45 | + }; | |
46 | +} | |
47 | + | |
48 | +function redactConfig(config: any) { | |
49 | + return { | |
50 | + ...config, | |
51 | + apiToken: undefined, | |
52 | + }; | |
53 | +} | |
54 | + | |
55 | +export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) => { | |
56 | + const owner = params.owner; | |
57 | + const repoName = params.repo; | |
58 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
59 | + | |
60 | + const user = await getUserFromRequest(request); | |
61 | + const repository = await resolveRepository(owner, repoName); | |
62 | + if (!repository) return notFound("Repository not found"); | |
63 | + | |
64 | + if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) { | |
65 | + return notFound("Repository not found"); | |
66 | + } | |
67 | + | |
68 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
69 | + const includeSummary = url.searchParams.get("summary") === "1"; | |
70 | + | |
71 | + const configs = await db.query.externalCIConfigs.findMany({ | |
72 | + where: and( | |
73 | + eq(schema.externalCIConfigs.repositoryId, repository.id), | |
74 | + inArray(schema.externalCIConfigs.provider, SUPPORTED_PROVIDERS as unknown as string[]) | |
75 | + ), | |
76 | + orderBy: [desc(schema.externalCIConfigs.updatedAt)], | |
77 | + }); | |
78 | + | |
79 | + if (!includeSummary) { | |
80 | + return success({ | |
81 | + providers: SUPPORTED_PROVIDERS.map(toProviderEntry), | |
82 | + configs: configs.map(redactConfig), | |
83 | + }); | |
84 | + } | |
85 | + | |
86 | + const configIds = configs.map((config) => config.id); | |
87 | + const builds = configIds.length | |
88 | + ? await db.query.externalBuilds.findMany({ | |
89 | + where: inArray(schema.externalBuilds.configId, configIds), | |
90 | + orderBy: [desc(schema.externalBuilds.createdAt)], | |
91 | + limit: 50, | |
92 | + }) | |
93 | + : []; | |
94 | + | |
95 | + return success({ | |
96 | + providers: SUPPORTED_PROVIDERS.map(toProviderEntry), | |
97 | + configs: configs.map(redactConfig), | |
98 | + builds, | |
99 | + }); | |
100 | +}); | |
101 | + | |
102 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
103 | + const owner = params.owner; | |
104 | + const repoName = params.repo; | |
105 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
106 | + | |
107 | + const user = await getUserFromRequest(request); | |
108 | + if (!user) return unauthorized(); | |
109 | + | |
110 | + const repository = await resolveRepository(owner, repoName); | |
111 | + if (!repository) return notFound("Repository not found"); | |
112 | + | |
113 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
114 | + return forbidden(); | |
115 | + } | |
116 | + | |
117 | + const parsed = await parseBody(request, createSchema); | |
118 | + if ("error" in parsed) return parsed.error; | |
119 | + | |
120 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
121 | + const now = new Date(); | |
122 | + const existing = await db.query.externalCIConfigs.findFirst({ | |
123 | + where: and( | |
124 | + eq(schema.externalCIConfigs.repositoryId, repository.id), | |
125 | + eq(schema.externalCIConfigs.provider, parsed.data.provider) | |
126 | + ), | |
127 | + }); | |
128 | + | |
129 | + const payload = { | |
130 | + provider: parsed.data.provider, | |
131 | + name: parsed.data.name || CI_PROVIDERS[parsed.data.provider].name, | |
132 | + baseUrl: parsed.data.baseUrl, | |
133 | + projectId: parsed.data.projectId || null, | |
134 | + ...(parsed.data.apiToken ? { apiToken: parsed.data.apiToken } : {}), | |
135 | + isEnabled: parsed.data.isEnabled ?? true, | |
136 | + syncStatus: parsed.data.syncStatus ?? true, | |
137 | + updatedAt: now, | |
138 | + }; | |
139 | + | |
140 | + if (existing) { | |
141 | + await db.update(schema.externalCIConfigs) | |
142 | + .set(payload) | |
143 | + .where(eq(schema.externalCIConfigs.id, existing.id)); | |
144 | + | |
145 | + const updated = await db.query.externalCIConfigs.findFirst({ | |
146 | + where: eq(schema.externalCIConfigs.id, existing.id), | |
147 | + }); | |
148 | + | |
149 | + return success(updated ? redactConfig(updated) : null); | |
150 | + } | |
151 | + | |
152 | + const created = { | |
153 | + id: crypto.randomUUID(), | |
154 | + repositoryId: repository.id, | |
155 | + webhookSecret: crypto.randomUUID(), | |
156 | + createdAt: now, | |
157 | + ...payload, | |
158 | + }; | |
159 | + | |
160 | + await db.insert(schema.externalCIConfigs).values(created); | |
161 | + | |
162 | + return success(redactConfig(created)); | |
163 | +}); | |
0 | 164 | |
▼src/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id].ts
+120-0
| @@ -0,0 +1,120 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canWriteRepo } from "@/lib/permissions"; | |
10 | + | |
11 | +const patchSchema = z.object({ | |
12 | + name: z.string().min(1).max(120).optional(), | |
13 | + baseUrl: z.string().url().optional(), | |
14 | + apiToken: z.string().min(1).optional(), | |
15 | + projectId: z.string().min(1).max(300).optional(), | |
16 | + isEnabled: z.boolean().optional(), | |
17 | + syncStatus: z.boolean().optional(), | |
18 | +}); | |
19 | + | |
20 | +async function resolveRepository(owner: string, repoName: string) { | |
21 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
22 | + const repoOwner = await db.query.users.findFirst({ | |
23 | + where: eq(schema.users.username, owner), | |
24 | + }); | |
25 | + if (!repoOwner) return null; | |
26 | + | |
27 | + return db.query.repositories.findFirst({ | |
28 | + where: and( | |
29 | + eq(schema.repositories.ownerId, repoOwner.id), | |
30 | + eq(schema.repositories.name, repoName) | |
31 | + ), | |
32 | + }); | |
33 | +} | |
34 | + | |
35 | +function redactConfig(config: any) { | |
36 | + return { | |
37 | + ...config, | |
38 | + apiToken: undefined, | |
39 | + }; | |
40 | +} | |
41 | + | |
42 | +export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => { | |
43 | + const owner = params.owner; | |
44 | + const repoName = params.repo; | |
45 | + const id = params.id; | |
46 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
47 | + | |
48 | + const user = await getUserFromRequest(request); | |
49 | + if (!user) return unauthorized(); | |
50 | + | |
51 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
52 | + const repository = await resolveRepository(owner, repoName); | |
53 | + if (!repository) return notFound("Repository not found"); | |
54 | + | |
55 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
56 | + return forbidden(); | |
57 | + } | |
58 | + | |
59 | + const config = await db.query.externalCIConfigs.findFirst({ | |
60 | + where: and( | |
61 | + eq(schema.externalCIConfigs.id, id), | |
62 | + eq(schema.externalCIConfigs.repositoryId, repository.id) | |
63 | + ), | |
64 | + }); | |
65 | + if (!config) return notFound("External CI config not found"); | |
66 | + | |
67 | + const parsed = await parseBody(request, patchSchema); | |
68 | + if ("error" in parsed) return parsed.error; | |
69 | + | |
70 | + const updates: Record<string, unknown> = { | |
71 | + updatedAt: new Date(), | |
72 | + }; | |
73 | + | |
74 | + if (parsed.data.name !== undefined) updates.name = parsed.data.name; | |
75 | + if (parsed.data.baseUrl !== undefined) updates.baseUrl = parsed.data.baseUrl; | |
76 | + if (parsed.data.apiToken !== undefined) updates.apiToken = parsed.data.apiToken; | |
77 | + if (parsed.data.projectId !== undefined) updates.projectId = parsed.data.projectId; | |
78 | + if (parsed.data.isEnabled !== undefined) updates.isEnabled = parsed.data.isEnabled; | |
79 | + if (parsed.data.syncStatus !== undefined) updates.syncStatus = parsed.data.syncStatus; | |
80 | + | |
81 | + await db.update(schema.externalCIConfigs) | |
82 | + .set(updates) | |
83 | + .where(eq(schema.externalCIConfigs.id, id)); | |
84 | + | |
85 | + const updated = await db.query.externalCIConfigs.findFirst({ | |
86 | + where: eq(schema.externalCIConfigs.id, id), | |
87 | + }); | |
88 | + | |
89 | + return success(updated ? redactConfig(updated) : null); | |
90 | +}); | |
91 | + | |
92 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { | |
93 | + const owner = params.owner; | |
94 | + const repoName = params.repo; | |
95 | + const id = params.id; | |
96 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
97 | + | |
98 | + const user = await getUserFromRequest(request); | |
99 | + if (!user) return unauthorized(); | |
100 | + | |
101 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
102 | + const repository = await resolveRepository(owner, repoName); | |
103 | + if (!repository) return notFound("Repository not found"); | |
104 | + | |
105 | + if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
106 | + return forbidden(); | |
107 | + } | |
108 | + | |
109 | + const config = await db.query.externalCIConfigs.findFirst({ | |
110 | + where: and( | |
111 | + eq(schema.externalCIConfigs.id, id), | |
112 | + eq(schema.externalCIConfigs.repositoryId, repository.id) | |
113 | + ), | |
114 | + }); | |
115 | + if (!config) return notFound("External CI config not found"); | |
116 | + | |
117 | + await db.delete(schema.externalCIConfigs).where(eq(schema.externalCIConfigs.id, id)); | |
118 | + | |
119 | + return success({ deleted: true }); | |
120 | +}); | |
0 | 121 | |
▼tests/integration/external-ci-integrations-route.test.ts
+186-0
| @@ -0,0 +1,186 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + canReadRepoMock, | |
5 | + canWriteRepoMock, | |
6 | + fakeSchema, | |
7 | +} = vi.hoisted(() => ({ | |
8 | + canReadRepoMock: vi.fn(async () => true), | |
9 | + canWriteRepoMock: vi.fn(async () => true), | |
10 | + fakeSchema: { | |
11 | + users: { username: {} }, | |
12 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
13 | + externalCIConfigs: { repositoryId: {}, provider: {}, updatedAt: {}, id: {} }, | |
14 | + externalBuilds: { configId: {}, createdAt: {} }, | |
15 | + } as any, | |
16 | +})); | |
17 | + | |
18 | +let mockDb: any; | |
19 | + | |
20 | +vi.mock("@/db", () => ({ | |
21 | + getDatabase: () => mockDb, | |
22 | + schema: fakeSchema, | |
23 | +})); | |
24 | + | |
25 | +vi.mock("@/lib/auth", () => ({ | |
26 | + getUserFromRequest: vi.fn(async () => ({ userId: "user-1", isAdmin: false })), | |
27 | +})); | |
28 | + | |
29 | +vi.mock("@/lib/permissions", () => ({ | |
30 | + canReadRepo: canReadRepoMock, | |
31 | + canWriteRepo: canWriteRepoMock, | |
32 | +})); | |
33 | + | |
34 | +import { GET as externalCiIntegrationsGet, POST as externalCiIntegrationsPost } from "@/pages/api/repos/[owner]/[repo]/integrations/external-ci"; | |
35 | +import { DELETE as externalCiIntegrationDelete, PATCH as externalCiIntegrationPatch } from "@/pages/api/repos/[owner]/[repo]/integrations/external-ci/[id]"; | |
36 | + | |
37 | +function makeDb() { | |
38 | + return { | |
39 | + query: { | |
40 | + users: { | |
41 | + findFirst: vi.fn(async () => ({ id: "owner-1" })), | |
42 | + }, | |
43 | + repositories: { | |
44 | + findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo" })), | |
45 | + }, | |
46 | + externalCIConfigs: { | |
47 | + findMany: vi.fn(async () => ([ | |
48 | + { | |
49 | + id: "cfg-gitlab", | |
50 | + repositoryId: "repo-1", | |
51 | + provider: "gitlab", | |
52 | + name: "GitLab CI", | |
53 | + baseUrl: "https://gitlab.com", | |
54 | + projectId: "acme%2Fdemo", | |
55 | + apiToken: "secret", | |
56 | + webhookSecret: "webhook-1", | |
57 | + isEnabled: true, | |
58 | + syncStatus: true, | |
59 | + updatedAt: new Date(), | |
60 | + }, | |
61 | + ])), | |
62 | + findFirst: vi.fn(async () => null), | |
63 | + }, | |
64 | + externalBuilds: { | |
65 | + findMany: vi.fn(async () => ([ | |
66 | + { | |
67 | + id: "build-1", | |
68 | + configId: "cfg-gitlab", | |
69 | + externalBuildId: "123", | |
70 | + buildNumber: "123", | |
71 | + status: "success", | |
72 | + createdAt: new Date(), | |
73 | + }, | |
74 | + ])), | |
75 | + }, | |
76 | + }, | |
77 | + insert: vi.fn(() => ({ | |
78 | + values: vi.fn(async () => undefined), | |
79 | + })), | |
80 | + update: vi.fn(() => ({ | |
81 | + set: vi.fn(() => ({ | |
82 | + where: vi.fn(async () => undefined), | |
83 | + })), | |
84 | + })), | |
85 | + delete: vi.fn(() => ({ | |
86 | + where: vi.fn(async () => undefined), | |
87 | + })), | |
88 | + }; | |
89 | +} | |
90 | + | |
91 | +async function readJson(response: Response): Promise<any> { | |
92 | + return response.json(); | |
93 | +} | |
94 | + | |
95 | +describe("external ci integrations route", () => { | |
96 | + beforeEach(() => { | |
97 | + vi.clearAllMocks(); | |
98 | + mockDb = makeDb(); | |
99 | + }); | |
100 | + | |
101 | + it("returns provider matrix and configs", async () => { | |
102 | + const response = await externalCiIntegrationsGet({ | |
103 | + params: { owner: "owner-1", repo: "demo" }, | |
104 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/external-ci"), | |
105 | + url: new URL("http://localhost/api/repos/owner-1/demo/integrations/external-ci"), | |
106 | + } as any); | |
107 | + | |
108 | + const body = await readJson(response); | |
109 | + expect(response.status).toBe(200); | |
110 | + expect(body?.data?.providers?.length).toBe(4); | |
111 | + expect(body?.data?.configs?.[0]?.provider).toBe("gitlab"); | |
112 | + expect(body?.data?.configs?.[0]?.apiToken).toBeUndefined(); | |
113 | + }); | |
114 | + | |
115 | + it("returns builds summary when summary=1", async () => { | |
116 | + const response = await externalCiIntegrationsGet({ | |
117 | + params: { owner: "owner-1", repo: "demo" }, | |
118 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/external-ci?summary=1"), | |
119 | + url: new URL("http://localhost/api/repos/owner-1/demo/integrations/external-ci?summary=1"), | |
120 | + } as any); | |
121 | + | |
122 | + const body = await readJson(response); | |
123 | + expect(response.status).toBe(200); | |
124 | + expect(body?.data?.builds?.length).toBe(1); | |
125 | + expect(body?.data?.builds?.[0]?.status).toBe("success"); | |
126 | + }); | |
127 | + | |
128 | + it("creates provider config", async () => { | |
129 | + mockDb.query.externalCIConfigs.findFirst = vi.fn(async () => null); | |
130 | + | |
131 | + const response = await externalCiIntegrationsPost({ | |
132 | + params: { owner: "owner-1", repo: "demo" }, | |
133 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/external-ci", { | |
134 | + method: "POST", | |
135 | + headers: { "content-type": "application/json" }, | |
136 | + body: JSON.stringify({ | |
137 | + provider: "gitlab", | |
138 | + name: "GitLab CI", | |
139 | + baseUrl: "https://gitlab.com", | |
140 | + projectId: "acme%2Fdemo", | |
141 | + apiToken: "token-1", | |
142 | + syncStatus: true, | |
143 | + isEnabled: true, | |
144 | + }), | |
145 | + }), | |
146 | + } as any); | |
147 | + | |
148 | + const body = await readJson(response); | |
149 | + expect(response.status).toBe(200); | |
150 | + expect(body?.data?.provider).toBe("gitlab"); | |
151 | + expect(body?.data?.apiToken).toBeUndefined(); | |
152 | + expect(mockDb.insert).toHaveBeenCalledTimes(1); | |
153 | + }); | |
154 | + | |
155 | + it("patches and deletes an external ci config", async () => { | |
156 | + mockDb.query.externalCIConfigs.findFirst = vi.fn(async () => ({ | |
157 | + id: "cfg-gitlab", | |
158 | + repositoryId: "repo-1", | |
159 | + provider: "gitlab", | |
160 | + })); | |
161 | + | |
162 | + const patchResponse = await externalCiIntegrationPatch({ | |
163 | + params: { owner: "owner-1", repo: "demo", id: "cfg-gitlab" }, | |
164 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/external-ci/cfg-gitlab", { | |
165 | + method: "PATCH", | |
166 | + headers: { "content-type": "application/json" }, | |
167 | + body: JSON.stringify({ isEnabled: false }), | |
168 | + }), | |
169 | + } as any); | |
170 | + | |
171 | + expect(patchResponse.status).toBe(200); | |
172 | + expect(mockDb.update).toHaveBeenCalledTimes(1); | |
173 | + | |
174 | + const deleteResponse = await externalCiIntegrationDelete({ | |
175 | + params: { owner: "owner-1", repo: "demo", id: "cfg-gitlab" }, | |
176 | + request: new Request("http://localhost/api/repos/owner-1/demo/integrations/external-ci/cfg-gitlab", { | |
177 | + method: "DELETE", | |
178 | + }), | |
179 | + } as any); | |
180 | + | |
181 | + const deleteBody = await readJson(deleteResponse); | |
182 | + expect(deleteResponse.status).toBe(200); | |
183 | + expect(deleteBody?.data?.deleted).toBe(true); | |
184 | + expect(mockDb.delete).toHaveBeenCalledTimes(1); | |
185 | + }); | |
186 | +}); | |
0 | 187 | |