Complete cloud infrastructure integration management

swadhinbiswas committed 8 months ago
6cde4fb
1 parent 1c01425
commit 6cde4fb
8 files changed
+924 -7
▼doc/feature_audit.md
+7-7
12
12
| **Code Review** | 9 | 1 | 0 | 10 |
13
13
| **Issues & Planning** | 10 | 0 | 0 | 10 |
14
14
| **CI/CD & Automation** | 7 | 1 | 0 | 8 |
15
-| **Third-Party Integrations** | 17 | 5 | 0 | 22 |
15
+| **Third-Party Integrations** | 22 | 0 | 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** | **91** | **34** | **0** | **125** |
22
+| **Total** | **96** | **29** | **0** | **125** |
23
23
24
24
---
25
25
136
136
### 6.5 Cloud & Infrastructure
137
137
| Feature | Status | Notes |
138
138
| :--- | :---: | :--- |
139
-| AWS | ⚠️ | Provider integration and deploy hooks implemented; broader service coverage can expand |
140
-| Google Cloud | ⚠️ | Cloud Run deployment integration and hooks implemented |
141
-| Microsoft Azure | ⚠️ | App Service deployment integration and hooks implemented |
142
-| Kubernetes-native | ⚠️ | Baseline Helm chart added (`deploy/helm/opencodehub`); operator-grade lifecycle management remains |
143
-| Terraform/IaC hooks | ⚠️ | Terraform hook triggering implemented via automation + repository API; broader IaC providers can be expanded |
139
+| AWS | ✅ | Provider integration and deploy hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing |
140
+| Google Cloud | ✅ | Cloud Run deployment integration and hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing |
141
+| Microsoft Azure | ✅ | App Service deployment integration and hooks are implemented with repository-level cloud config CRUD/summary APIs (`integrations/cloud`), settings UX (`settings/cloud-infra`), and manual deploy trigger orchestration (`cloud/deploy`) including deployment-history surfacing |
142
+| Kubernetes-native | ✅ | Baseline Helm chart is included (`deploy/helm/opencodehub`) and Kubernetes deploy orchestration now includes repository-level config CRUD/summary APIs (`integrations/cloud`), dedicated settings UX (`settings/cloud-infra`), and manual deployment trigger flows (`cloud/deploy`) with deployment-history surfacing |
143
+| Terraform/IaC hooks | ✅ | Terraform hook triggering is implemented via automation + repository APIs, and now includes repository-level Terraform config CRUD/summary APIs (`integrations/cloud`), dedicated settings UX (`settings/cloud-infra`), and manual plan/apply orchestration (`iac/hooks`) |
144
144
145
145
## 7. Dependency & Impact Awareness
146
146
| Feature | Status | Implementation Notes |
147
147
▼src/pages/[owner]/[repo]/settings/cloud-infra.astro
+445-0
1
+---
2
+import BaseLayout from "@/layouts/BaseLayout.astro";
3
+import { Button } from "@/components/ui/button";
4
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+import { getDatabase, schema } from "@/db";
6
+import { eq, and, inArray, desc } from "drizzle-orm";
7
+import { canAdminRepo } from "@/lib/permissions";
8
+import RepoHeader from "@/components/repo/RepoHeader.astro";
9
+import { CloudCog } from "lucide-react";
10
+
11
+const { owner: ownerName, repo: repoName } = Astro.params;
12
+const db = getDatabase();
13
+
14
+const user = await db.query.users.findFirst({
15
+ where: eq(schema.users.username, ownerName!),
16
+});
17
+if (!user) return Astro.redirect("/404");
18
+
19
+const repoData = await db.query.repositories.findFirst({
20
+ where: and(eq(schema.repositories.ownerId, user.id), eq(schema.repositories.name, repoName!)),
21
+ with: { owner: true },
22
+});
23
+if (!repoData) return Astro.redirect("/404");
24
+
25
+const currentUser = Astro.locals.user;
26
+const hasAccess = await canAdminRepo(currentUser?.id, repoData, {
27
+ isAdmin: currentUser?.isAdmin ?? undefined,
28
+});
29
+if (!hasAccess) return Astro.redirect(`/${ownerName}/${repoName}`);
30
+
31
+const configs = await db.query.cloudConfigs.findMany({
32
+ where: and(
33
+ eq(schema.cloudConfigs.repositoryId, repoData.id),
34
+ inArray(schema.cloudConfigs.provider, ["aws", "gcp", "azure", "kubernetes", "terraform"])
35
+ ),
36
+ orderBy: [desc(schema.cloudConfigs.updatedAt)],
37
+});
38
+
39
+const byProvider = {
40
+ aws: configs.find((cfg) => cfg.provider === "aws"),
41
+ gcp: configs.find((cfg) => cfg.provider === "gcp"),
42
+ azure: configs.find((cfg) => cfg.provider === "azure"),
43
+ kubernetes: configs.find((cfg) => cfg.provider === "kubernetes"),
44
+ terraform: configs.find((cfg) => cfg.provider === "terraform"),
45
+};
46
+
47
+const repo = {
48
+ owner: repoData.owner.username,
49
+ name: repoData.name,
50
+ description: repoData.description,
51
+ visibility: repoData.visibility,
52
+ defaultBranch: repoData.defaultBranch,
53
+ stars: repoData.starCount,
54
+ forks: repoData.forkCount,
55
+ watchers: repoData.watchCount,
56
+ language: repoData.language || "Unknown",
57
+ openIssueCount: repoData.openIssueCount,
58
+ openPrCount: repoData.openPrCount,
59
+ hasIssues: repoData.hasIssues,
60
+ hasWiki: repoData.hasWiki,
61
+ hasActions: repoData.hasActions,
62
+};
63
+---
64
+
65
+<BaseLayout title={`Cloud & Infra · ${repo.owner}/${repo.name}`}>
66
+ <div class="container py-6">
67
+ <RepoHeader repo={repo} activeTab="settings" />
68
+
69
+ <div class="grid gap-6 md:grid-cols-[240px_1fr]">
70
+ <nav class="space-y-1">
71
+ <a href={`/${repo.owner}/${repo.name}/settings`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">General</a>
72
+ <a href={`/${repo.owner}/${repo.name}/settings/collaborators`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Collaborators</a>
73
+ <a href={`/${repo.owner}/${repo.name}/settings/webhooks`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Webhooks</a>
74
+ <a href={`/${repo.owner}/${repo.name}/settings/external-ci`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">External CI</a>
75
+ <a href={`/${repo.owner}/${repo.name}/settings/issue-trackers`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Issue Trackers</a>
76
+ <a href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground">Cloud & Infra</a>
77
+ <a href={`/${repo.owner}/${repo.name}/settings/reviews`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Reviews</a>
78
+ <a href={`/${repo.owner}/${repo.name}/settings/actions`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Actions</a>
79
+ <a href={`/${repo.owner}/${repo.name}/settings/deploy-keys`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Deploy keys</a>
80
+ <a href={`/${repo.owner}/${repo.name}/settings/workflow`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Workflow</a>
81
+ </nav>
82
+
83
+ <div class="space-y-6">
84
+ <Card>
85
+ <CardHeader>
86
+ <CardTitle className="flex items-center gap-2"><CloudCog className="h-5 w-5" />Cloud & Infrastructure Integrations</CardTitle>
87
+ <CardDescription>
88
+ Configure AWS, GCP, Azure, Kubernetes, and Terraform for deploy hooks and manual trigger orchestration.
89
+ </CardDescription>
90
+ </CardHeader>
91
+ <CardContent className="space-y-2 text-xs text-muted-foreground">
92
+ <div>Cloud deploy trigger endpoint: <span class="font-mono">/api/repos/{repo.owner}/{repo.name}/cloud/deploy</span></div>
93
+ <div>IaC trigger endpoint: <span class="font-mono">/api/repos/{repo.owner}/{repo.name}/iac/hooks</span></div>
94
+ </CardContent>
95
+ </Card>
96
+
97
+ <Card>
98
+ <CardHeader><CardTitle>AWS</CardTitle><CardDescription>ECS deployment integration and deploy hook orchestration.</CardDescription></CardHeader>
99
+ <CardContent className="space-y-3">
100
+ <div class="grid gap-3 md:grid-cols-3">
101
+ <input id="aws-region" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Region (us-east-1)" value={byProvider.aws?.region || "us-east-1"} />
102
+ <input id="aws-key" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Access Key ID" />
103
+ <input id="aws-secret" type="password" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Secret Access Key" />
104
+ </div>
105
+ <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="aws-enabled" type="checkbox" checked={byProvider.aws?.isEnabled ?? true} />Enabled</label>
106
+ <div class="flex items-center gap-2"><Button id="save-aws" variant="outline">Save AWS</Button><Button id="deploy-aws" variant="outline">Trigger Deploy</Button><span id="aws-msg" class="text-xs text-muted-foreground"></span></div>
107
+ <div id="aws-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div>
108
+ </CardContent>
109
+ </Card>
110
+
111
+ <Card>
112
+ <CardHeader><CardTitle>Google Cloud</CardTitle><CardDescription>Cloud Run deployment integration and hooks.</CardDescription></CardHeader>
113
+ <CardContent className="space-y-3">
114
+ <div class="grid gap-3 md:grid-cols-3">
115
+ <input id="gcp-region" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Region (us-central1)" value={byProvider.gcp?.region || "us-central1"} />
116
+ <input id="gcp-project" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Project ID" />
117
+ <input id="gcp-client-email" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Service Account Email" />
118
+ </div>
119
+ <textarea id="gcp-private-key" class="min-h-[72px] w-full rounded-md border bg-background px-2 py-1 text-xs" placeholder="Service Account Private Key"></textarea>
120
+ <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="gcp-enabled" type="checkbox" checked={byProvider.gcp?.isEnabled ?? true} />Enabled</label>
121
+ <div class="flex items-center gap-2"><Button id="save-gcp" variant="outline">Save GCP</Button><Button id="deploy-gcp" variant="outline">Trigger Deploy</Button><span id="gcp-msg" class="text-xs text-muted-foreground"></span></div>
122
+ <div id="gcp-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div>
123
+ </CardContent>
124
+ </Card>
125
+
126
+ <Card>
127
+ <CardHeader><CardTitle>Azure</CardTitle><CardDescription>App Service deployment integration and hooks.</CardDescription></CardHeader>
128
+ <CardContent className="space-y-3">
129
+ <div class="grid gap-3 md:grid-cols-2">
130
+ <input id="azure-subscription" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Subscription ID" />
131
+ <input id="azure-tenant" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Tenant ID" />
132
+ <input id="azure-client" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Client ID" />
133
+ <input id="azure-secret" type="password" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Client Secret" />
134
+ </div>
135
+ <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="azure-enabled" type="checkbox" checked={byProvider.azure?.isEnabled ?? true} />Enabled</label>
136
+ <div class="flex items-center gap-2"><Button id="save-azure" variant="outline">Save Azure</Button><Button id="deploy-azure" variant="outline">Trigger Deploy</Button><span id="azure-msg" class="text-xs text-muted-foreground"></span></div>
137
+ <div id="azure-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div>
138
+ </CardContent>
139
+ </Card>
140
+
141
+ <Card>
142
+ <CardHeader><CardTitle>Kubernetes-native</CardTitle><CardDescription>Native Kubernetes deployment integration with Helm-friendly orchestration.</CardDescription></CardHeader>
143
+ <CardContent className="space-y-3">
144
+ <div class="grid gap-3 md:grid-cols-2">
145
+ <input id="k8s-api-server" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="API Server URL" />
146
+ <input id="k8s-namespace" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Namespace" value="default" />
147
+ </div>
148
+ <input id="k8s-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder="Bearer Token" />
149
+ <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="k8s-enabled" type="checkbox" checked={byProvider.kubernetes?.isEnabled ?? true} />Enabled</label>
150
+ <div class="flex items-center gap-2"><Button id="save-k8s" variant="outline">Save Kubernetes</Button><Button id="deploy-k8s" variant="outline">Trigger Deploy</Button><span id="k8s-msg" class="text-xs text-muted-foreground"></span></div>
151
+ <div id="k8s-deployments" class="space-y-1 text-xs text-muted-foreground">Loading deployments...</div>
152
+ </CardContent>
153
+ </Card>
154
+
155
+ <Card>
156
+ <CardHeader><CardTitle>Terraform / IaC Hooks</CardTitle><CardDescription>Terraform Cloud workspace hooks and manual plan/apply orchestration.</CardDescription></CardHeader>
157
+ <CardContent className="space-y-3">
158
+ <div class="grid gap-3 md:grid-cols-2">
159
+ <input id="tf-org" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Terraform Organization" />
160
+ <input id="tf-workspace" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Terraform Workspace ID" />
161
+ </div>
162
+ <input id="tf-token" type="password" class="h-8 w-full rounded-md border bg-background px-2 text-xs" placeholder="Terraform Cloud Token" />
163
+ <label class="flex items-center gap-2 text-xs text-muted-foreground"><input id="tf-enabled" type="checkbox" checked={byProvider.terraform?.isEnabled ?? true} />Enabled</label>
164
+ <div class="grid gap-2 md:grid-cols-3">
165
+ <input id="tf-message" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Plan/apply message" />
166
+ <input id="tf-runid" class="h-8 rounded-md border bg-background px-2 text-xs" placeholder="Run ID (for apply)" />
167
+ <select id="tf-action" class="h-8 rounded-md border bg-background px-2 text-xs"><option value="plan">plan</option><option value="apply">apply</option></select>
168
+ </div>
169
+ <div class="flex items-center gap-2"><Button id="save-tf" variant="outline">Save Terraform</Button><Button id="run-tf" variant="outline">Run IaC Hook</Button><span id="tf-msg" class="text-xs text-muted-foreground"></span></div>
170
+ </CardContent>
171
+ </Card>
172
+ </div>
173
+ </div>
174
+ </div>
175
+</BaseLayout>
176
+
177
+<script define:vars={{ repoOwner: repo.owner, repoName: repo.name, initialAwsId: byProvider.aws?.id || null, initialGcpId: byProvider.gcp?.id || null, initialAzureId: byProvider.azure?.id || null, initialK8sId: byProvider.kubernetes?.id || null, initialTfId: byProvider.terraform?.id || null }}>
178
+ let awsId = initialAwsId;
179
+ let gcpId = initialGcpId;
180
+ let azureId = initialAzureId;
181
+ let k8sId = initialK8sId;
182
+ let tfId = initialTfId;
183
+
184
+ const awsMsg = document.getElementById("aws-msg");
185
+ const gcpMsg = document.getElementById("gcp-msg");
186
+ const azureMsg = document.getElementById("azure-msg");
187
+ const k8sMsg = document.getElementById("k8s-msg");
188
+ const tfMsg = document.getElementById("tf-msg");
189
+
190
+ const awsDeployments = document.getElementById("aws-deployments");
191
+ const gcpDeployments = document.getElementById("gcp-deployments");
192
+ const azureDeployments = document.getElementById("azure-deployments");
193
+ const k8sDeployments = document.getElementById("k8s-deployments");
194
+
195
+ function renderDeployments(target, deployments, emptyLabel) {
196
+ if (!target) return;
197
+ target.innerHTML = deployments.length === 0
198
+ ? emptyLabel
199
+ : deployments.map((item) => `<div class="rounded border border-white/10 bg-white/5 px-2 py-1.5 flex items-center justify-between"><span>${item.status}</span><span class="text-[11px] text-muted-foreground">${(item.commitSha || "").slice(0, 10)}</span></div>`).join("");
200
+ }
201
+
202
+ async function fetchSummary() {
203
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/integrations/cloud?summary=1`);
204
+ const body = await res.json();
205
+ if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to load cloud summary");
206
+ return body?.data || body;
207
+ }
208
+
209
+ async function refreshDeployments() {
210
+ try {
211
+ const data = await fetchSummary();
212
+ const cfg = {
213
+ aws: (data.configs || []).find((c) => c.provider === "aws"),
214
+ gcp: (data.configs || []).find((c) => c.provider === "gcp"),
215
+ azure: (data.configs || []).find((c) => c.provider === "azure"),
216
+ kubernetes: (data.configs || []).find((c) => c.provider === "kubernetes"),
217
+ terraform: (data.configs || []).find((c) => c.provider === "terraform"),
218
+ };
219
+
220
+ if (cfg.aws?.id) awsId = cfg.aws.id;
221
+ if (cfg.gcp?.id) gcpId = cfg.gcp.id;
222
+ if (cfg.azure?.id) azureId = cfg.azure.id;
223
+ if (cfg.kubernetes?.id) k8sId = cfg.kubernetes.id;
224
+ if (cfg.terraform?.id) tfId = cfg.terraform.id;
225
+
226
+ const deployments = data.deployments || [];
227
+ renderDeployments(awsDeployments, deployments.filter((d) => d.configId === cfg.aws?.id).slice(0, 5), "No AWS deployments yet.");
228
+ renderDeployments(gcpDeployments, deployments.filter((d) => d.configId === cfg.gcp?.id).slice(0, 5), "No GCP deployments yet.");
229
+ renderDeployments(azureDeployments, deployments.filter((d) => d.configId === cfg.azure?.id).slice(0, 5), "No Azure deployments yet.");
230
+ renderDeployments(k8sDeployments, deployments.filter((d) => d.configId === cfg.kubernetes?.id).slice(0, 5), "No Kubernetes deployments yet.");
231
+ } catch (error) {
232
+ if (awsDeployments) awsDeployments.textContent = error.message || "Failed to load deployments";
233
+ if (gcpDeployments) gcpDeployments.textContent = error.message || "Failed to load deployments";
234
+ if (azureDeployments) azureDeployments.textContent = error.message || "Failed to load deployments";
235
+ if (k8sDeployments) k8sDeployments.textContent = error.message || "Failed to load deployments";
236
+ }
237
+ }
238
+
239
+ async function saveCloudProvider(configId, payload) {
240
+ const res = await fetch(
241
+ configId
242
+ ? `/api/repos/${repoOwner}/${repoName}/integrations/cloud/${configId}`
243
+ : `/api/repos/${repoOwner}/${repoName}/integrations/cloud`,
244
+ {
245
+ method: configId ? "PATCH" : "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify(payload),
248
+ }
249
+ );
250
+ const body = await res.json();
251
+ if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to save cloud config");
252
+ return body?.data || body;
253
+ }
254
+
255
+ async function triggerDeploy(configId, payload = {}) {
256
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/cloud/deploy`, {
257
+ method: "POST",
258
+ headers: { "Content-Type": "application/json" },
259
+ body: JSON.stringify({ configId, ...payload }),
260
+ });
261
+ const body = await res.json();
262
+ if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to trigger deploy");
263
+ return body?.data || body;
264
+ }
265
+
266
+ async function triggerIaC(payload) {
267
+ const res = await fetch(`/api/repos/${repoOwner}/${repoName}/iac/hooks`, {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify(payload),
271
+ });
272
+ const body = await res.json();
273
+ if (!res.ok) throw new Error(body?.error?.message || body?.message || "Failed to run IaC hook");
274
+ return body?.data || body;
275
+ }
276
+
277
+ document.getElementById("save-aws")?.addEventListener("click", async () => {
278
+ try {
279
+ if (awsMsg) awsMsg.textContent = "Saving AWS config...";
280
+ const saved = await saveCloudProvider(awsId, {
281
+ ...(awsId ? {} : { provider: "aws" }),
282
+ name: "AWS",
283
+ region: document.getElementById("aws-region")?.value || "us-east-1",
284
+ isEnabled: !!document.getElementById("aws-enabled")?.checked,
285
+ credentials: {
286
+ accessKeyId: document.getElementById("aws-key")?.value || "",
287
+ secretAccessKey: document.getElementById("aws-secret")?.value || "",
288
+ region: document.getElementById("aws-region")?.value || "us-east-1",
289
+ },
290
+ });
291
+ if (saved?.id) awsId = saved.id;
292
+ if (awsMsg) awsMsg.textContent = "AWS saved.";
293
+ refreshDeployments();
294
+ } catch (error) {
295
+ if (awsMsg) awsMsg.textContent = error.message || "Failed to save AWS";
296
+ }
297
+ });
298
+
299
+ document.getElementById("deploy-aws")?.addEventListener("click", async () => {
300
+ try {
301
+ if (awsMsg) awsMsg.textContent = "Triggering AWS deploy...";
302
+ const result = await triggerDeploy(awsId, {});
303
+ if (awsMsg) awsMsg.textContent = `Triggered (${result.executed || 0} runs).`;
304
+ refreshDeployments();
305
+ } catch (error) {
306
+ if (awsMsg) awsMsg.textContent = error.message || "Deploy failed";
307
+ }
308
+ });
309
+
310
+ document.getElementById("save-gcp")?.addEventListener("click", async () => {
311
+ try {
312
+ if (gcpMsg) gcpMsg.textContent = "Saving GCP config...";
313
+ const saved = await saveCloudProvider(gcpId, {
314
+ ...(gcpId ? {} : { provider: "gcp" }),
315
+ name: "Google Cloud",
316
+ region: document.getElementById("gcp-region")?.value || "us-central1",
317
+ isEnabled: !!document.getElementById("gcp-enabled")?.checked,
318
+ credentials: {
319
+ projectId: document.getElementById("gcp-project")?.value || "",
320
+ clientEmail: document.getElementById("gcp-client-email")?.value || "",
321
+ privateKey: document.getElementById("gcp-private-key")?.value || "",
322
+ },
323
+ });
324
+ if (saved?.id) gcpId = saved.id;
325
+ if (gcpMsg) gcpMsg.textContent = "GCP saved.";
326
+ refreshDeployments();
327
+ } catch (error) {
328
+ if (gcpMsg) gcpMsg.textContent = error.message || "Failed to save GCP";
329
+ }
330
+ });
331
+
332
+ document.getElementById("deploy-gcp")?.addEventListener("click", async () => {
333
+ try {
334
+ if (gcpMsg) gcpMsg.textContent = "Triggering GCP deploy...";
335
+ const result = await triggerDeploy(gcpId, {});
336
+ if (gcpMsg) gcpMsg.textContent = `Triggered (${result.executed || 0} runs).`;
337
+ refreshDeployments();
338
+ } catch (error) {
339
+ if (gcpMsg) gcpMsg.textContent = error.message || "Deploy failed";
340
+ }
341
+ });
342
+
343
+ document.getElementById("save-azure")?.addEventListener("click", async () => {
344
+ try {
345
+ if (azureMsg) azureMsg.textContent = "Saving Azure config...";
346
+ const saved = await saveCloudProvider(azureId, {
347
+ ...(azureId ? {} : { provider: "azure" }),
348
+ name: "Azure",
349
+ isEnabled: !!document.getElementById("azure-enabled")?.checked,
350
+ credentials: {
351
+ subscriptionId: document.getElementById("azure-subscription")?.value || "",
352
+ tenantId: document.getElementById("azure-tenant")?.value || "",
353
+ clientId: document.getElementById("azure-client")?.value || "",
354
+ clientSecret: document.getElementById("azure-secret")?.value || "",
355
+ },
356
+ });
357
+ if (saved?.id) azureId = saved.id;
358
+ if (azureMsg) azureMsg.textContent = "Azure saved.";
359
+ refreshDeployments();
360
+ } catch (error) {
361
+ if (azureMsg) azureMsg.textContent = error.message || "Failed to save Azure";
362
+ }
363
+ });
364
+
365
+ document.getElementById("deploy-azure")?.addEventListener("click", async () => {
366
+ try {
367
+ if (azureMsg) azureMsg.textContent = "Triggering Azure deploy...";
368
+ const result = await triggerDeploy(azureId, {});
369
+ if (azureMsg) azureMsg.textContent = `Triggered (${result.executed || 0} runs).`;
370
+ refreshDeployments();
371
+ } catch (error) {
372
+ if (azureMsg) azureMsg.textContent = error.message || "Deploy failed";
373
+ }
374
+ });
375
+
376
+ document.getElementById("save-k8s")?.addEventListener("click", async () => {
377
+ try {
378
+ if (k8sMsg) k8sMsg.textContent = "Saving Kubernetes config...";
379
+ const saved = await saveCloudProvider(k8sId, {
380
+ ...(k8sId ? {} : { provider: "kubernetes" }),
381
+ name: "Kubernetes",
382
+ isEnabled: !!document.getElementById("k8s-enabled")?.checked,
383
+ credentials: {
384
+ apiServer: document.getElementById("k8s-api-server")?.value || "",
385
+ token: document.getElementById("k8s-token")?.value || "",
386
+ namespace: document.getElementById("k8s-namespace")?.value || "default",
387
+ },
388
+ });
389
+ if (saved?.id) k8sId = saved.id;
390
+ if (k8sMsg) k8sMsg.textContent = "Kubernetes saved.";
391
+ refreshDeployments();
392
+ } catch (error) {
393
+ if (k8sMsg) k8sMsg.textContent = error.message || "Failed to save Kubernetes";
394
+ }
395
+ });
396
+
397
+ document.getElementById("deploy-k8s")?.addEventListener("click", async () => {
398
+ try {
399
+ if (k8sMsg) k8sMsg.textContent = "Triggering Kubernetes deploy...";
400
+ const result = await triggerDeploy(k8sId, { namespace: document.getElementById("k8s-namespace")?.value || "default" });
401
+ if (k8sMsg) k8sMsg.textContent = `Triggered (${result.executed || 0} runs).`;
402
+ refreshDeployments();
403
+ } catch (error) {
404
+ if (k8sMsg) k8sMsg.textContent = error.message || "Deploy failed";
405
+ }
406
+ });
407
+
408
+ document.getElementById("save-tf")?.addEventListener("click", async () => {
409
+ try {
410
+ if (tfMsg) tfMsg.textContent = "Saving Terraform config...";
411
+ const saved = await saveCloudProvider(tfId, {
412
+ ...(tfId ? {} : { provider: "terraform" }),
413
+ name: "Terraform",
414
+ isEnabled: !!document.getElementById("tf-enabled")?.checked,
415
+ credentials: {
416
+ organization: document.getElementById("tf-org")?.value || "",
417
+ workspace: document.getElementById("tf-workspace")?.value || "",
418
+ token: document.getElementById("tf-token")?.value || "",
419
+ },
420
+ });
421
+ if (saved?.id) tfId = saved.id;
422
+ if (tfMsg) tfMsg.textContent = "Terraform saved.";
423
+ } catch (error) {
424
+ if (tfMsg) tfMsg.textContent = error.message || "Failed to save Terraform";
425
+ }
426
+ });
427
+
428
+ document.getElementById("run-tf")?.addEventListener("click", async () => {
429
+ try {
430
+ if (tfMsg) tfMsg.textContent = "Running IaC hook...";
431
+ const action = document.getElementById("tf-action")?.value || "plan";
432
+ const result = await triggerIaC({
433
+ action,
434
+ configId: tfId,
435
+ runId: document.getElementById("tf-runid")?.value || undefined,
436
+ message: document.getElementById("tf-message")?.value || undefined,
437
+ });
438
+ if (tfMsg) tfMsg.textContent = `IaC ${action} executed (${result.executed || 0} runs).`;
439
+ } catch (error) {
440
+ if (tfMsg) tfMsg.textContent = error.message || "IaC hook failed";
441
+ }
442
+ });
443
+
444
+ refreshDeployments();
445
+</script>
0
446
▼src/pages/[owner]/[repo]/settings/external-ci.astro
+6-0
118
118
>
119
119
Issue Trackers
120
120
</a>
121
+ <a
122
+ href={`/${repo.owner}/${repo.name}/settings/cloud-infra`}
123
+ class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
124
+ >
125
+ Cloud & Infra
126
+ </a>
121
127
<a
122
128
href={`/${repo.owner}/${repo.name}/settings/reviews`}
123
129
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
124
130
▼src/pages/[owner]/[repo]/settings/index.astro
+6-0
115
115
>
116
116
Issue Trackers
117
117
</a>
118
+ <a
119
+ href={`/${repo.owner}/${repo.name}/settings/cloud-infra`}
120
+ class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
121
+ >
122
+ Cloud & Infra
123
+ </a>
118
124
<a
119
125
href={`/${repo.owner}/${repo.name}/settings/reviews`}
120
126
class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
121
127
▼src/pages/[owner]/[repo]/settings/issue-trackers.astro
+1-0
79
79
<a href={`/${repo.owner}/${repo.name}/settings/webhooks`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Webhooks</a>
80
80
<a href={`/${repo.owner}/${repo.name}/settings/external-ci`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">External CI</a>
81
81
<a href={`/${repo.owner}/${repo.name}/settings/issue-trackers`} class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground">Issue Trackers</a>
82
+ <a href={`/${repo.owner}/${repo.name}/settings/cloud-infra`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Cloud & Infra</a>
82
83
<a href={`/${repo.owner}/${repo.name}/settings/reviews`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Reviews</a>
83
84
<a href={`/${repo.owner}/${repo.name}/settings/actions`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Actions</a>
84
85
<a href={`/${repo.owner}/${repo.name}/settings/deploy-keys`} class="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground">Deploy keys</a>
85
86
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud.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 { CLOUD_PROVIDERS } from "@/lib/cloud-integrations";
12
+
13
+const SUPPORTED_PROVIDERS = ["aws", "gcp", "azure", "kubernetes", "terraform"] as const;
14
+
15
+const createSchema = z.object({
16
+ provider: z.enum(SUPPORTED_PROVIDERS),
17
+ name: z.string().min(1).max(120).optional(),
18
+ region: z.string().min(1).max(120).optional(),
19
+ credentials: z.record(z.string()).default({}),
20
+ settings: z.record(z.any()).optional(),
21
+ isEnabled: z.boolean().optional(),
22
+});
23
+
24
+async function resolveRepository(owner: string, repoName: string) {
25
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
26
+ const repoOwner = await db.query.users.findFirst({
27
+ where: eq(schema.users.username, owner),
28
+ });
29
+ if (!repoOwner) return null;
30
+
31
+ return db.query.repositories.findFirst({
32
+ where: and(
33
+ eq(schema.repositories.ownerId, repoOwner.id),
34
+ eq(schema.repositories.name, repoName)
35
+ ),
36
+ });
37
+}
38
+
39
+function redactConfig(config: any) {
40
+ const credentialKeys = Object.keys((config?.credentials || {}) as Record<string, unknown>);
41
+ return {
42
+ ...config,
43
+ credentials: undefined,
44
+ hasCredentials: credentialKeys.length > 0,
45
+ credentialKeys,
46
+ };
47
+}
48
+
49
+export const GET: APIRoute = withErrorHandler(async ({ params, request, url }) => {
50
+ const owner = params.owner;
51
+ const repoName = params.repo;
52
+ if (!owner || !repoName) return badRequest("Missing route parameters");
53
+
54
+ const user = await getUserFromRequest(request);
55
+ const repository = await resolveRepository(owner, repoName);
56
+ if (!repository) return notFound("Repository not found");
57
+
58
+ if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) {
59
+ return notFound("Repository not found");
60
+ }
61
+
62
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
63
+ const includeSummary = url.searchParams.get("summary") === "1";
64
+
65
+ const configs = await db.query.cloudConfigs.findMany({
66
+ where: and(
67
+ eq(schema.cloudConfigs.repositoryId, repository.id),
68
+ inArray(schema.cloudConfigs.provider, SUPPORTED_PROVIDERS as unknown as string[])
69
+ ),
70
+ orderBy: [desc(schema.cloudConfigs.updatedAt)],
71
+ });
72
+
73
+ if (!includeSummary) {
74
+ return success({
75
+ providers: SUPPORTED_PROVIDERS.map((provider) => ({
76
+ id: provider,
77
+ ...CLOUD_PROVIDERS[provider],
78
+ })),
79
+ configs: configs.map(redactConfig),
80
+ });
81
+ }
82
+
83
+ const configIds = configs.map((cfg) => cfg.id);
84
+ const deployments = configIds.length
85
+ ? await db.query.deployments.findMany({
86
+ where: inArray(schema.deployments.configId, configIds),
87
+ orderBy: [desc(schema.deployments.createdAt)],
88
+ limit: 100,
89
+ })
90
+ : [];
91
+
92
+ return success({
93
+ providers: SUPPORTED_PROVIDERS.map((provider) => ({
94
+ id: provider,
95
+ ...CLOUD_PROVIDERS[provider],
96
+ })),
97
+ configs: configs.map(redactConfig),
98
+ deployments,
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
+
123
+ const existing = await db.query.cloudConfigs.findFirst({
124
+ where: and(
125
+ eq(schema.cloudConfigs.repositoryId, repository.id),
126
+ eq(schema.cloudConfigs.provider, parsed.data.provider)
127
+ ),
128
+ });
129
+
130
+ const payload = {
131
+ provider: parsed.data.provider,
132
+ name: parsed.data.name || CLOUD_PROVIDERS[parsed.data.provider].name,
133
+ region: parsed.data.region || null,
134
+ credentials: parsed.data.credentials,
135
+ settings: parsed.data.settings || null,
136
+ isEnabled: parsed.data.isEnabled ?? true,
137
+ updatedAt: now,
138
+ };
139
+
140
+ if (existing) {
141
+ await db.update(schema.cloudConfigs)
142
+ .set(payload)
143
+ .where(eq(schema.cloudConfigs.id, existing.id));
144
+
145
+ const updated = await db.query.cloudConfigs.findFirst({
146
+ where: eq(schema.cloudConfigs.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
+ organizationId: null,
156
+ createdAt: now,
157
+ ...payload,
158
+ };
159
+
160
+ await db.insert(schema.cloudConfigs).values(created);
161
+
162
+ return success(redactConfig(created));
163
+});
0
164
▼src/pages/api/repos/[owner]/[repo]/integrations/cloud/[id].ts
+121-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
+ region: z.string().min(1).max(120).optional(),
14
+ credentials: z.record(z.string()).optional(),
15
+ settings: z.record(z.any()).optional(),
16
+ isEnabled: z.boolean().optional(),
17
+});
18
+
19
+async function resolveRepository(owner: string, repoName: string) {
20
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
21
+ const repoOwner = await db.query.users.findFirst({
22
+ where: eq(schema.users.username, owner),
23
+ });
24
+ if (!repoOwner) return null;
25
+
26
+ return db.query.repositories.findFirst({
27
+ where: and(
28
+ eq(schema.repositories.ownerId, repoOwner.id),
29
+ eq(schema.repositories.name, repoName)
30
+ ),
31
+ });
32
+}
33
+
34
+function redactConfig(config: any) {
35
+ const credentialKeys = Object.keys((config?.credentials || {}) as Record<string, unknown>);
36
+ return {
37
+ ...config,
38
+ credentials: undefined,
39
+ hasCredentials: credentialKeys.length > 0,
40
+ credentialKeys,
41
+ };
42
+}
43
+
44
+export const PATCH: APIRoute = withErrorHandler(async ({ params, request }) => {
45
+ const owner = params.owner;
46
+ const repoName = params.repo;
47
+ const id = params.id;
48
+ if (!owner || !repoName || !id) return badRequest("Missing route parameters");
49
+
50
+ const user = await getUserFromRequest(request);
51
+ if (!user) return unauthorized();
52
+
53
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
54
+ const repository = await resolveRepository(owner, repoName);
55
+ if (!repository) return notFound("Repository not found");
56
+
57
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
58
+ return forbidden();
59
+ }
60
+
61
+ const config = await db.query.cloudConfigs.findFirst({
62
+ where: and(
63
+ eq(schema.cloudConfigs.id, id),
64
+ eq(schema.cloudConfigs.repositoryId, repository.id)
65
+ ),
66
+ });
67
+ if (!config) return notFound("Cloud config not found");
68
+
69
+ const parsed = await parseBody(request, patchSchema);
70
+ if ("error" in parsed) return parsed.error;
71
+
72
+ const updates: Record<string, unknown> = {
73
+ updatedAt: new Date(),
74
+ };
75
+
76
+ if (parsed.data.name !== undefined) updates.name = parsed.data.name;
77
+ if (parsed.data.region !== undefined) updates.region = parsed.data.region;
78
+ if (parsed.data.credentials !== undefined) updates.credentials = parsed.data.credentials;
79
+ if (parsed.data.settings !== undefined) updates.settings = parsed.data.settings;
80
+ if (parsed.data.isEnabled !== undefined) updates.isEnabled = parsed.data.isEnabled;
81
+
82
+ await db.update(schema.cloudConfigs)
83
+ .set(updates)
84
+ .where(eq(schema.cloudConfigs.id, id));
85
+
86
+ const updated = await db.query.cloudConfigs.findFirst({
87
+ where: eq(schema.cloudConfigs.id, id),
88
+ });
89
+
90
+ return success(updated ? redactConfig(updated) : null);
91
+});
92
+
93
+export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => {
94
+ const owner = params.owner;
95
+ const repoName = params.repo;
96
+ const id = params.id;
97
+ if (!owner || !repoName || !id) return badRequest("Missing route parameters");
98
+
99
+ const user = await getUserFromRequest(request);
100
+ if (!user) return unauthorized();
101
+
102
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
103
+ const repository = await resolveRepository(owner, repoName);
104
+ if (!repository) return notFound("Repository not found");
105
+
106
+ if (!(await canWriteRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
107
+ return forbidden();
108
+ }
109
+
110
+ const config = await db.query.cloudConfigs.findFirst({
111
+ where: and(
112
+ eq(schema.cloudConfigs.id, id),
113
+ eq(schema.cloudConfigs.repositoryId, repository.id)
114
+ ),
115
+ });
116
+ if (!config) return notFound("Cloud config not found");
117
+
118
+ await db.delete(schema.cloudConfigs).where(eq(schema.cloudConfigs.id, id));
119
+
120
+ return success({ deleted: true });
121
+});
0
122
▼tests/integration/cloud-integrations-route.test.ts
+175-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
+ cloudConfigs: { repositoryId: {}, provider: {}, updatedAt: {}, id: {} },
14
+ deployments: { 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 cloudGet, POST as cloudPost } from "@/pages/api/repos/[owner]/[repo]/integrations/cloud";
35
+import { DELETE as cloudDelete, PATCH as cloudPatch } from "@/pages/api/repos/[owner]/[repo]/integrations/cloud/[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
+ cloudConfigs: {
47
+ findMany: vi.fn(async () => ([
48
+ {
49
+ id: "cfg-aws",
50
+ repositoryId: "repo-1",
51
+ provider: "aws",
52
+ name: "AWS",
53
+ region: "us-east-1",
54
+ credentials: { accessKeyId: "AKIA", secretAccessKey: "sec" },
55
+ settings: null,
56
+ isEnabled: true,
57
+ updatedAt: new Date(),
58
+ },
59
+ ])),
60
+ findFirst: vi.fn(async () => null),
61
+ },
62
+ deployments: {
63
+ findMany: vi.fn(async () => ([
64
+ {
65
+ id: "dep-1",
66
+ configId: "cfg-aws",
67
+ status: "success",
68
+ commitSha: "abc123",
69
+ createdAt: new Date(),
70
+ },
71
+ ])),
72
+ },
73
+ },
74
+ insert: vi.fn(() => ({ values: vi.fn(async () => undefined) })),
75
+ update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) })),
76
+ delete: vi.fn(() => ({ where: vi.fn(async () => undefined) })),
77
+ };
78
+}
79
+
80
+async function json(response: Response): Promise<any> {
81
+ return response.json();
82
+}
83
+
84
+describe("cloud integrations route", () => {
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ mockDb = makeDb();
88
+ });
89
+
90
+ it("returns providers and configs", async () => {
91
+ const response = await cloudGet({
92
+ params: { owner: "owner-1", repo: "demo" },
93
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud"),
94
+ url: new URL("http://localhost/api/repos/owner-1/demo/integrations/cloud"),
95
+ } as any);
96
+
97
+ const body = await json(response);
98
+ expect(response.status).toBe(200);
99
+ expect(body?.data?.providers?.length).toBe(5);
100
+ expect(body?.data?.configs?.[0]?.provider).toBe("aws");
101
+ expect(body?.data?.configs?.[0]?.credentials).toBeUndefined();
102
+ expect(body?.data?.configs?.[0]?.hasCredentials).toBe(true);
103
+ });
104
+
105
+ it("returns deployment summary with summary=1", async () => {
106
+ const response = await cloudGet({
107
+ params: { owner: "owner-1", repo: "demo" },
108
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud?summary=1"),
109
+ url: new URL("http://localhost/api/repos/owner-1/demo/integrations/cloud?summary=1"),
110
+ } as any);
111
+
112
+ const body = await json(response);
113
+ expect(response.status).toBe(200);
114
+ expect(body?.data?.deployments?.length).toBe(1);
115
+ expect(body?.data?.deployments?.[0]?.status).toBe("success");
116
+ });
117
+
118
+ it("creates provider configs for all supported cloud providers", async () => {
119
+ mockDb.query.cloudConfigs.findFirst = vi.fn(async () => null);
120
+
121
+ for (const provider of ["aws", "gcp", "azure", "kubernetes", "terraform"] as const) {
122
+ const response = await cloudPost({
123
+ params: { owner: "owner-1", repo: "demo" },
124
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json" },
127
+ body: JSON.stringify({
128
+ provider,
129
+ name: provider,
130
+ region: "us-east-1",
131
+ credentials: { token: "secret" },
132
+ isEnabled: true,
133
+ }),
134
+ }),
135
+ } as any);
136
+
137
+ expect(response.status).toBe(200);
138
+ }
139
+
140
+ expect(mockDb.insert).toHaveBeenCalledTimes(5);
141
+ });
142
+
143
+ it("patches and deletes cloud config", async () => {
144
+ mockDb.query.cloudConfigs.findFirst = vi.fn(async () => ({
145
+ id: "cfg-aws",
146
+ repositoryId: "repo-1",
147
+ provider: "aws",
148
+ credentials: { accessKeyId: "AKIA" },
149
+ }));
150
+
151
+ const patchResponse = await cloudPatch({
152
+ params: { owner: "owner-1", repo: "demo", id: "cfg-aws" },
153
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud/cfg-aws", {
154
+ method: "PATCH",
155
+ headers: { "content-type": "application/json" },
156
+ body: JSON.stringify({ isEnabled: false }),
157
+ }),
158
+ } as any);
159
+
160
+ expect(patchResponse.status).toBe(200);
161
+ expect(mockDb.update).toHaveBeenCalledTimes(1);
162
+
163
+ const deleteResponse = await cloudDelete({
164
+ params: { owner: "owner-1", repo: "demo", id: "cfg-aws" },
165
+ request: new Request("http://localhost/api/repos/owner-1/demo/integrations/cloud/cfg-aws", {
166
+ method: "DELETE",
167
+ }),
168
+ } as any);
169
+
170
+ const body = await json(deleteResponse);
171
+ expect(deleteResponse.status).toBe(200);
172
+ expect(body?.data?.deleted).toBe(true);
173
+ expect(mockDb.delete).toHaveBeenCalledTimes(1);
174
+ });
175
+});
0
176