Add GitLab CircleCI Buildkite Jenkins integration management

swadhinbiswas committed 8 months ago
fea3bac
1 parent 4e7efaa
commit fea3bac
5 files changed
+875 -10
▼doc/feature_audit.md
+8-8
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
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
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
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
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
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
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
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
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
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
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
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
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