feat: environment model + deployment tracking (phase 3a)
- Environment/Deployment XORM models + migration 010 - Full CRUD API: GET/POST/PATCH/DELETE /environments + /deployments - Deployment status update endpoint, publishes deployment.* NATS events - EnvironmentsPage with deploy cards, history accordion, deploy modal - Sidebar Environments nav item between Pipelines and Settings - Repo page deployment status badges (env name + SHA pill per environment) - Environment/Deployment types in types/api.ts + environments.ts query hooks
This commit is contained in:
@@ -58,8 +58,8 @@ Understand the phases before adding code — don't build Phase 3 infrastructure
|
|||||||
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** |
|
| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks, LFS, design system, 20-page SPA | **Complete** |
|
||||||
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
|
| 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** |
|
||||||
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** |
|
| 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** |
|
||||||
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Active** |
|
| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** |
|
||||||
| 3A | Environment model + deployment tracking | Planned |
|
| 3A | Environment model + deployment tracking | **Active** |
|
||||||
| 3B | Unified operational timeline | Planned |
|
| 3B | Unified operational timeline | Planned |
|
||||||
| 3C | Secret management hierarchy | Planned |
|
| 3C | Secret management hierarchy | Planned |
|
||||||
| 3D | GitOps controller + drift detection | Planned |
|
| 3D | GitOps controller + drift detection | Planned |
|
||||||
@@ -69,18 +69,23 @@ Understand the phases before adding code — don't build Phase 3 infrastructure
|
|||||||
|
|
||||||
Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` directory is an intentional stub — the data model exists but no HTTP handlers should be wired until Phase 3F.
|
Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` directory is an intentional stub — the data model exists but no HTTP handlers should be wired until Phase 3F.
|
||||||
|
|
||||||
### Phase 2C — What's Left to Build
|
### Phase 3A — What to Build
|
||||||
|
|
||||||
All backend APIs for CI are complete. Phase 2C is entirely frontend work:
|
Backend and frontend are both net-new for Phase 3A. Nothing exists yet.
|
||||||
|
|
||||||
1. **`types/api.ts`** — `Pipeline` type uses stale fields (`ref`, `status`). Must be updated to match backend (`name`, `filePath`). Add `PipelineRun`, `PipelineJob`, `PipelineStep`, `PipelineStepLog` types.
|
**Backend:**
|
||||||
2. **`queries/pipelines.ts`** — Needs `useRuns`, `useRunDetail`, `useJobLogs`, cancel/retry mutations aligned with correct types.
|
1. `internal/models/environment.go` — `Environment` (id, repoId, name, url, protectionRules JSON) + `Deployment` (id, envId, repoId, sha, ref, status, triggeredBy, description, runId, startedAt, finishedAt)
|
||||||
3. **`GET /api/v1/pipelines/runs`** — A new backend endpoint returning recent runs across all repos owned by the current user (needed by the global `/pipelines` page and dashboard widget).
|
2. `internal/models/migrations/010_environments.go` — `Run010()` syncing both structs; call from `001_init.go`
|
||||||
4. **`PipelinesPage`** — Currently an empty placeholder. Replace with real cross-repo runs list.
|
3. `internal/api/handlers/environment.go` — `ListEnvironments`, `CreateEnvironment`, `GetEnvironment`, `UpdateEnvironment`, `DeleteEnvironment`, `ListDeployments`, `CreateDeployment`, `UpdateDeploymentStatus`; publish `deployment.*` NATS events
|
||||||
5. **`PipelineRunPage`** — New page at `/repos/:owner/:repo/runs/:runId`. Shows run header + DAG + step log viewer.
|
4. `internal/api/router.go` — wire routes under `/{owner}/{repo}/environments` and `/{owner}/{repo}/environments/{envName}/deployments`
|
||||||
6. **`PipelineWaterfall`** — Currently uses mock data. Rewrite to accept real `PipelineJob[]` with `needs` dependency graph.
|
|
||||||
7. **Dashboard CI widget** — Replace hardcoded "Pipeline integration coming soon." with live recent runs.
|
**Frontend:**
|
||||||
8. **Command palette** — Add pipeline runs to search results.
|
5. `frontend/src/types/api.ts` — add `Environment`, `Deployment`, `DeployStatus` types
|
||||||
|
6. `frontend/src/api/queries/environments.ts` — `useEnvironments`, `useEnvironment`, `useCreateEnvironment`, `useUpdateEnvironment`, `useDeleteEnvironment`, `useDeployments`, `useCreateDeployment`, `useUpdateDeploymentStatus`
|
||||||
|
7. `frontend/src/pages/EnvironmentsPage.tsx` — environment cards each showing latest deployment status, SHA, who deployed, time; "New environment" flow; deployment history per env
|
||||||
|
8. `frontend/src/components/layout/Sidebar.tsx` — add `Environments` nav item between Pipelines and Settings in `RepoSubNav`
|
||||||
|
9. `frontend/src/pages/RepoPage.tsx` — surface deployment status badges in the repo header (latest deploy per env at a glance)
|
||||||
|
10. `frontend/src/App.tsx` — add route `repos/:owner/:repo/environments`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+20
-6
@@ -9,12 +9,26 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### In Progress — Phase 2C (CI Legibility)
|
### In Progress — Phase 3A (Environment model + deployment tracking)
|
||||||
- Pipeline DAG visualization (PipelineRunPage with real job/step graph)
|
- `Environment` model per repo (name, URL, protection rules)
|
||||||
- Dashboard CI command center upgrade (replace placeholder with live recent runs)
|
- `Deployment` model (sha, ref, status, triggered_by, run_id link)
|
||||||
- Command palette wiring (pipeline runs in search, Pipelines quick-nav)
|
- Full CRUD API for environments
|
||||||
- Global cross-repo pipeline runs feed (`/pipelines` page)
|
- Deployment trigger + status update API
|
||||||
- Per-step log viewer (collapsible, streamed from backend)
|
- NATS event publishing for `deployment.*` subjects
|
||||||
|
- `EnvironmentsPage` per repo — environment cards with live deployment status
|
||||||
|
- Deployment history per environment
|
||||||
|
- Sidebar "Environments" nav item
|
||||||
|
- Repo page deployment status badges
|
||||||
|
|
||||||
|
### Completed — Phase 2C (CI Legibility)
|
||||||
|
- `PipelinesPage` — real cross-repo runs feed with status filter tabs
|
||||||
|
- `RepoPipelinesPage` — repo-scoped runs list at `/repos/:owner/:repo/pipelines`
|
||||||
|
- `PipelineRunPage` — run detail with topological DAG visualization + step log viewer
|
||||||
|
- `PipelineWaterfall` — rewritten to accept real `PipelineJob[]` data with `needs` graph
|
||||||
|
- Dashboard CI widget — live recent runs replacing "coming soon" placeholder
|
||||||
|
- Command palette — pipeline run results + Pipelines quick-nav
|
||||||
|
- `GET /api/v1/pipelines/runs` — cross-repo recent runs endpoint
|
||||||
|
- Dashboard `recentRuns[]` field added
|
||||||
|
|
||||||
### Planned — Phase 3 (GitOps + Observability + Federation)
|
### Planned — Phase 3 (GitOps + Observability + Federation)
|
||||||
- GitOps controller with reconciliation loops
|
- GitOps controller with reconciliation loops
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
|
|||||||
| Build artifact storage | Done (Phase 2B) |
|
| Build artifact storage | Done (Phase 2B) |
|
||||||
| Pipeline cancellation + job retry | Done (Phase 2B) |
|
| Pipeline cancellation + job retry | Done (Phase 2B) |
|
||||||
| NATS event bus + WebSocket live push | Done (Phase 2A) |
|
| NATS event bus + WebSocket live push | Done (Phase 2A) |
|
||||||
| Pipeline DAG visualization (frontend) | **In progress (Phase 2C)** |
|
| Pipeline DAG visualization (frontend) | Done (Phase 2C) |
|
||||||
| Dashboard CI command center | **In progress (Phase 2C)** |
|
| Dashboard CI command center | Done (Phase 2C) |
|
||||||
| Pipeline log viewer (per-step, collapsible) | **In progress (Phase 2C)** |
|
| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) |
|
||||||
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
|
| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
|
||||||
| Forgejo Actions gRPC integration | Planned |
|
| Forgejo Actions gRPC integration | Planned |
|
||||||
| Matrix builds + reusable workflow templates | Planned |
|
| Matrix builds + reusable workflow templates | Planned |
|
||||||
@@ -75,7 +75,7 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
|
|||||||
### GitOps + Environments
|
### GitOps + Environments
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Environment model + deployment tracking | Planned (Phase 3A) |
|
| Environment model + deployment tracking | **In progress (Phase 3A)** |
|
||||||
| Unified operational timeline | Planned (Phase 3B) |
|
| Unified operational timeline | Planned (Phase 3B) |
|
||||||
| Secret management hierarchy | Planned (Phase 3C) |
|
| Secret management hierarchy | Planned (Phase 3C) |
|
||||||
| GitOps controller + drift detection | Planned (Phase 3D) |
|
| GitOps controller + drift detection | Planned (Phase 3D) |
|
||||||
@@ -221,8 +221,9 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
|
|||||||
| Phase 1 | Core Git hosting, auth, PRs, issues, RBAC, design system | Done |
|
| Phase 1 | Core Git hosting, auth, PRs, issues, RBAC, design system | Done |
|
||||||
| Phase 2A | NATS event bus, WebSocket hub, audit log | Done |
|
| Phase 2A | NATS event bus, WebSocket hub, audit log | Done |
|
||||||
| Phase 2B | CI orchestrator, runner manager, Docker backend, artifact registry | Done |
|
| Phase 2B | CI orchestrator, runner manager, Docker backend, artifact registry | Done |
|
||||||
| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | **In progress** |
|
| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done |
|
||||||
| Phase 3A–F | GitOps, environments, timeline, secrets, drift detection, federation | Planned |
|
| Phase 3A | Environment model + deployment tracking | **In progress** |
|
||||||
|
| Phase 3B–F | Unified timeline, secrets, drift detection, federation, observability | Planned |
|
||||||
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
|
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const PRsPage = lazy(() => import('./pages/PRsPage'))
|
|||||||
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||||
const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage'))
|
const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage'))
|
||||||
const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage'))
|
||||||
|
const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage'))
|
||||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||||
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||||
@@ -85,6 +86,7 @@ export default function App() {
|
|||||||
<Route path="repos/:owner/:repo/pulls/new" element={<S><CreatePRPage /></S>} />
|
<Route path="repos/:owner/:repo/pulls/new" element={<S><CreatePRPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
|
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
|
||||||
|
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
|
||||||
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
|
||||||
|
|
||||||
<Route path="starred" element={<S><StarredPage /></S>} />
|
<Route path="starred" element={<S><StarredPage /></S>} />
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api } from '../client'
|
||||||
|
import type { Environment, EnvironmentWithLatest, Deployment, DeployStatus } from '../../types/api'
|
||||||
|
|
||||||
|
// ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deployStatusSchema = z.enum(['pending', 'in_progress', 'success', 'failure', 'cancelled'])
|
||||||
|
|
||||||
|
const deploymentSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
envId: z.number(),
|
||||||
|
repoId: z.number(),
|
||||||
|
sha: z.string(),
|
||||||
|
ref: z.string(),
|
||||||
|
status: deployStatusSchema,
|
||||||
|
triggeredBy: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
runId: z.number().nullable(),
|
||||||
|
startedAt: z.string().nullable(),
|
||||||
|
finishedAt: z.string().nullable(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const environmentSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
repoId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
protectionRules: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const environmentWithLatestSchema = environmentSchema.extend({
|
||||||
|
latestDeployment: deploymentSchema.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useEnvironments(owner: string, repo: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'environments'],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<EnvironmentWithLatest[]>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments`,
|
||||||
|
z.array(environmentWithLatestSchema),
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnvironment(owner: string, repo: string, envName: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'environments', envName],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<EnvironmentWithLatest>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
|
||||||
|
environmentWithLatestSchema,
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo && envName),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeployments(owner: string, repo: string, envName: string, limit = 30) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['repos', owner, repo, 'environments', envName, 'deployments', limit],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<Deployment[]>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments?limit=${limit}`,
|
||||||
|
z.array(deploymentSchema),
|
||||||
|
),
|
||||||
|
enabled: Boolean(owner && repo && envName),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCreateEnvironment(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { name: string; url?: string; protectionRules?: string }) =>
|
||||||
|
api.post<Environment>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments`,
|
||||||
|
environmentSchema,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEnvironment(owner: string, repo: string, envName: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { url?: string; protectionRules?: string }) =>
|
||||||
|
api.patch<Environment>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
|
||||||
|
environmentSchema,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteEnvironment(owner: string, repo: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (envName: string) =>
|
||||||
|
api.delete<void>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}`,
|
||||||
|
z.unknown() as z.ZodType<void>,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDeployment(owner: string, repo: string, envName: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: { sha: string; ref?: string; description?: string; runId?: number }) =>
|
||||||
|
api.post<Deployment>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments`,
|
||||||
|
deploymentSchema,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDeploymentStatus(owner: string, repo: string, envName: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ deployId, status, description }: { deployId: number; status: DeployStatus; description?: string }) =>
|
||||||
|
api.patch<Deployment>(
|
||||||
|
`/api/v1/repos/${owner}/${repo}/environments/${envName}/deployments/${deployId}/status`,
|
||||||
|
deploymentSchema,
|
||||||
|
{ status, description },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'environments', envName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -162,6 +162,7 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
|
|||||||
{ label: 'Pull requests', to: `${base}/pulls`, icon: <PRIcon /> },
|
{ label: 'Pull requests', to: `${base}/pulls`, icon: <PRIcon /> },
|
||||||
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
|
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
|
||||||
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
|
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
|
||||||
|
{ label: 'Environments', to: `${base}/environments`, icon: <EnvIcon /> },
|
||||||
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
|
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
@@ -203,4 +204,5 @@ const CommitsIcon = () => <I d={['M12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5
|
|||||||
const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
|
const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25 0 0 1 2.25 2.25v3.75A2.25 2.25 0 0 1 6 12H5.25A2.25 2.25 0 0 0 3 14.25v2.25A2.25 2.25 0 0 0 5.25 18.75H6a2.25 2.25 0 0 0 2.25-2.25V15m0 0a3 3 0 1 0 6 0 3 3 0 0 0-6 0Zm0 0h3" />
|
||||||
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
|
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
|
||||||
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||||
|
const EnvIcon = () => <I d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
|
||||||
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
|
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
useEnvironments,
|
||||||
|
useDeployments,
|
||||||
|
useCreateEnvironment,
|
||||||
|
useDeleteEnvironment,
|
||||||
|
useCreateDeployment,
|
||||||
|
} from '../api/queries/environments'
|
||||||
|
import { Skeleton } from '../ui/Skeleton'
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
import type { DeployStatus, EnvironmentWithLatest } from '../types/api'
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'never'
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const min = Math.floor(diff / 60_000)
|
||||||
|
if (min < 1) return 'just now'
|
||||||
|
if (min < 60) return `${min}m ago`
|
||||||
|
const hr = Math.floor(min / 60)
|
||||||
|
if (hr < 24) return `${hr}h ago`
|
||||||
|
return `${Math.floor(hr / 24)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSHA(sha: string): string {
|
||||||
|
return sha.slice(0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortRef(ref: string): string {
|
||||||
|
return ref.replace('refs/heads/', '').replace('refs/tags/', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deploy status helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEPLOY_DOT: Record<DeployStatus, string> = {
|
||||||
|
pending: 'bg-[var(--c-subtle)]',
|
||||||
|
in_progress: 'bg-[var(--c-brand)] animate-pulse',
|
||||||
|
success: 'bg-[var(--c-success)]',
|
||||||
|
failure: 'bg-[var(--c-danger)]',
|
||||||
|
cancelled: 'bg-[var(--c-subtle)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPLOY_BADGE: Record<DeployStatus, string> = {
|
||||||
|
pending: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
|
||||||
|
in_progress: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]',
|
||||||
|
success: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]',
|
||||||
|
failure: 'bg-[var(--c-danger-tint)] border-[#FF8F73] text-[var(--c-danger-dark)]',
|
||||||
|
cancelled: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPLOY_LABEL: Record<DeployStatus, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In progress',
|
||||||
|
success: 'Active',
|
||||||
|
failure: 'Failed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeployBadge({ status }: { status: DeployStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full border',
|
||||||
|
DEPLOY_BADGE[status],
|
||||||
|
)}>
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DEPLOY_DOT[status])} />
|
||||||
|
{DEPLOY_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deployment history panel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DeploymentHistory({ owner, repo, envName }: { owner: string; repo: string; envName: string }) {
|
||||||
|
const { data: deploys, isLoading } = useDeployments(owner, repo, envName, 10)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{[1, 2, 3].map(i => <Skeleton key={i} className="h-8 rounded" />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!deploys?.length) {
|
||||||
|
return <p className="text-xs text-[var(--c-muted)] px-4 py-3">No deployments yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-[var(--c-border)]">
|
||||||
|
{deploys.map(d => (
|
||||||
|
<div key={d.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', DEPLOY_DOT[d.status])} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-mono text-[var(--c-text)]">{shortSHA(d.sha)}</span>
|
||||||
|
{d.ref && (
|
||||||
|
<span className="text-[10px] font-mono text-[var(--c-muted)]">{shortRef(d.ref)}</span>
|
||||||
|
)}
|
||||||
|
<DeployBadge status={d.status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--c-muted)] mt-0.5">
|
||||||
|
by {d.triggeredBy}
|
||||||
|
{d.description && <span> · {d.description}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--c-subtle)] shrink-0">{timeAgo(d.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New deploy modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DeployModal({
|
||||||
|
owner, repo, envName,
|
||||||
|
onClose,
|
||||||
|
}: { owner: string; repo: string; envName: string; onClose: () => void }) {
|
||||||
|
const [sha, setSHA] = useState('')
|
||||||
|
const [ref, setRef] = useState('')
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
const createDeployment = useCreateDeployment(owner, repo, envName)
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!sha.trim()) return
|
||||||
|
createDeployment.mutate(
|
||||||
|
{ sha: sha.trim(), ref: ref.trim(), description: desc.trim() },
|
||||||
|
{ onSuccess: onClose },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md bg-[var(--c-surface)] border border-[var(--c-border)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--c-border)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--c-text)]">Deploy to {envName}</h3>
|
||||||
|
<button onClick={onClose} className="text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">
|
||||||
|
Commit SHA <span className="text-[var(--c-danger)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={sha} onChange={e => setSHA(e.target.value)}
|
||||||
|
placeholder="abc1234 or full 40-char SHA"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)] font-mono"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">Branch / tag (optional)</label>
|
||||||
|
<input
|
||||||
|
value={ref} onChange={e => setRef(e.target.value)}
|
||||||
|
placeholder="main"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">Description (optional)</label>
|
||||||
|
<input
|
||||||
|
value={desc} onChange={e => setDesc(e.target.value)}
|
||||||
|
placeholder="Release 1.2.3"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!sha.trim() || createDeployment.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createDeployment.isPending ? 'Deploying…' : 'Deploy'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 border border-[var(--c-border)] text-sm text-[var(--c-text)] rounded-lg hover:bg-[var(--c-surface-muted)] transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createDeployment.isError && (
|
||||||
|
<p className="text-xs text-[var(--c-danger)]">
|
||||||
|
{(createDeployment.error as Error)?.message ?? 'Deployment failed'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New environment modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NewEnvModal({ owner, repo, onClose }: { owner: string; repo: string; onClose: () => void }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [url, setURL] = useState('')
|
||||||
|
const createEnv = useCreateEnvironment(owner, repo)
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
createEnv.mutate({ name: name.trim(), url: url.trim() }, { onSuccess: onClose })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md bg-[var(--c-surface)] border border-[var(--c-border)] rounded-xl shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--c-border)]">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--c-text)]">New environment</h3>
|
||||||
|
<button onClick={onClose} className="text-[var(--c-muted)] hover:text-[var(--c-text)] transition-colors">
|
||||||
|
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">
|
||||||
|
Name <span className="text-[var(--c-danger)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name} onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="production"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-[var(--c-text)] mb-1.5">URL (optional)</label>
|
||||||
|
<input
|
||||||
|
value={url} onChange={e => setURL(e.target.value)}
|
||||||
|
placeholder="https://api.example.com"
|
||||||
|
className="w-full px-3 py-2 text-sm bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded-lg text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!name.trim() || createEnv.isPending}
|
||||||
|
className="flex-1 px-4 py-2 bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createEnv.isPending ? 'Creating…' : 'Create environment'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="px-4 py-2 border border-[var(--c-border)] text-sm text-[var(--c-text)] rounded-lg hover:bg-[var(--c-surface-muted)] transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createEnv.isError && (
|
||||||
|
<p className="text-xs text-[var(--c-danger)]">
|
||||||
|
{(createEnv.error as Error)?.message ?? 'Failed to create environment'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Environment card ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EnvironmentCard({
|
||||||
|
env, owner, repo,
|
||||||
|
}: { env: EnvironmentWithLatest; owner: string; repo: string }) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [deploying, setDeploying] = useState(false)
|
||||||
|
const deleteEnv = useDeleteEnvironment(owner, repo)
|
||||||
|
|
||||||
|
const latest = env.latestDeployment
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)] overflow-hidden">
|
||||||
|
{/* Card header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 px-5 py-4">
|
||||||
|
<div className="space-y-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--c-text)]">{env.name}</h3>
|
||||||
|
{latest && <DeployBadge status={latest.status} />}
|
||||||
|
</div>
|
||||||
|
{env.url && (
|
||||||
|
<a
|
||||||
|
href={env.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-[var(--c-brand)] hover:underline font-mono truncate block max-w-xs"
|
||||||
|
>
|
||||||
|
{env.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{latest ? (
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-[var(--c-muted)] mt-1 flex-wrap">
|
||||||
|
<span className="font-mono">{shortSHA(latest.sha)}</span>
|
||||||
|
{latest.ref && <span className="font-mono">{shortRef(latest.ref)}</span>}
|
||||||
|
<span>by {latest.triggeredBy}</span>
|
||||||
|
<span>{timeAgo(latest.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-[var(--c-muted)] mt-1">No deployments yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeploying(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||||
|
</svg>
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-muted)] hover:text-[var(--c-text)] hover:border-[var(--c-brand-focus)] transition-colors"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
<svg
|
||||||
|
width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
|
||||||
|
className={cn('transition-transform', expanded ? 'rotate-180' : '')}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete environment "${env.name}"? This will also delete all deployment records.`)) {
|
||||||
|
deleteEnv.mutate(env.name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteEnv.isPending}
|
||||||
|
className="p-1.5 text-[var(--c-muted)] hover:text-[var(--c-danger)] transition-colors disabled:opacity-50"
|
||||||
|
title="Delete environment"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History panel */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-[var(--c-border)] bg-[var(--c-surface-muted)]">
|
||||||
|
<p className="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--c-muted)]">
|
||||||
|
Deployment history
|
||||||
|
</p>
|
||||||
|
<DeploymentHistory owner={owner} repo={repo} envName={env.name} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deploy modal */}
|
||||||
|
{deploying && (
|
||||||
|
<DeployModal owner={owner} repo={repo} envName={env.name} onClose={() => setDeploying(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EnvironmentsPage() {
|
||||||
|
const { owner = '', repo = '' } = useParams()
|
||||||
|
const { data: envs, isLoading } = useEnvironments(owner, repo)
|
||||||
|
const [showNewEnv, setShowNewEnv] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-6 py-5 space-y-5">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-[var(--c-text)]">Environments</h1>
|
||||||
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
||||||
|
Deployment targets for <span className="font-mono">{owner}/{repo}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewEnv(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-sm font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
New environment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment cards */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="border border-[var(--c-border)] rounded-lg p-5 space-y-3">
|
||||||
|
<Skeleton className="h-4 w-32 rounded" />
|
||||||
|
<Skeleton className="h-3 w-48 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : !envs?.length ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 border border-dashed border-[var(--c-border)] rounded-lg gap-3 text-center">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-[var(--c-surface-muted)] flex items-center justify-center">
|
||||||
|
<svg width="20" height="20" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--c-text)]">No environments yet</p>
|
||||||
|
<p className="text-xs text-[var(--c-muted)] mt-1 max-w-xs">
|
||||||
|
Create environments like production, staging, or dev to track where your code is deployed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewEnv(true)}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-[var(--c-brand)] hover:bg-[var(--c-brand-hover)] text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Create first environment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{envs.map(env => (
|
||||||
|
<EnvironmentCard key={env.id} env={env} owner={owner} repo={repo} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewEnv && <NewEnvModal owner={owner} repo={repo} onClose={() => setShowNewEnv(false)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useParams, useSearchParams, Link } from 'react-router-dom'
|
|||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
|
||||||
|
import { useEnvironments } from '../api/queries/environments'
|
||||||
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
import { TreeBrowser } from '../components/repos/TreeBrowser'
|
||||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||||
import { RepoAvatar } from '../ui/RepoAvatar'
|
import { RepoAvatar } from '../ui/RepoAvatar'
|
||||||
@@ -21,6 +22,7 @@ export default function RepoPage() {
|
|||||||
|
|
||||||
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
|
||||||
const { data: branches } = useRepoBranches(owner, repoName)
|
const { data: branches } = useRepoBranches(owner, repoName)
|
||||||
|
const { data: environments } = useEnvironments(owner, repoName)
|
||||||
const { track } = useRecentRepos()
|
const { track } = useRecentRepos()
|
||||||
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
|
||||||
|
|
||||||
@@ -65,6 +67,37 @@ export default function RepoPage() {
|
|||||||
{repo.description && (
|
{repo.description && (
|
||||||
<p className="text-sm text-[var(--c-muted)]">{repo.description}</p>
|
<p className="text-sm text-[var(--c-muted)]">{repo.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Deployment status badges */}
|
||||||
|
{environments && environments.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
||||||
|
{environments.map(env => {
|
||||||
|
const status = env.latestDeployment?.status
|
||||||
|
const dot: Record<string, string> = {
|
||||||
|
success: 'bg-[var(--c-success)]',
|
||||||
|
in_progress: 'bg-[var(--c-brand)] animate-pulse',
|
||||||
|
failure: 'bg-[var(--c-danger)]',
|
||||||
|
pending: 'bg-[var(--c-subtle)]',
|
||||||
|
cancelled: 'bg-[var(--c-subtle)]',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={env.id}
|
||||||
|
to={`/repos/${owner}/${repoName}/environments`}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-[var(--c-border)] bg-[var(--c-surface-muted)] hover:border-[var(--c-brand-focus)] transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${status ? (dot[status] ?? 'bg-[var(--c-subtle)]') : 'bg-[var(--c-subtle)]'}`} />
|
||||||
|
<span className="text-[10px] font-medium text-[var(--c-muted)]">{env.name}</span>
|
||||||
|
{env.latestDeployment?.sha && (
|
||||||
|
<span className="text-[10px] font-mono text-[var(--c-subtle)]">
|
||||||
|
{env.latestDeployment.sha.slice(0, 7)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -221,6 +221,45 @@ export interface Webhook {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Environment + Deployment (Phase 3A) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export type DeployStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'success'
|
||||||
|
| 'failure'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export interface Environment {
|
||||||
|
id: number
|
||||||
|
repoId: number
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
protectionRules: string // JSON: {"require_approval":true,"required_reviewers":1}
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: number
|
||||||
|
envId: number
|
||||||
|
repoId: number
|
||||||
|
sha: string
|
||||||
|
ref: string
|
||||||
|
status: DeployStatus
|
||||||
|
triggeredBy: string
|
||||||
|
description: string
|
||||||
|
runId: number | null
|
||||||
|
startedAt: string | null
|
||||||
|
finishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvironmentWithLatest is what ListEnvironments returns — env + most recent deploy.
|
||||||
|
export interface EnvironmentWithLatest extends Environment {
|
||||||
|
latestDeployment: Deployment | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
error: string
|
error: string
|
||||||
status: number
|
status: number
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnvironmentHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
bus events.EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnvironmentHandler(db *xorm.Engine, bus events.EventBus) *EnvironmentHandler {
|
||||||
|
return &EnvironmentHandler{db: db, bus: bus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Environment CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListEnvironments returns all environments for a repository, each annotated
|
||||||
|
// with its most recent deployment (or nil if none).
|
||||||
|
func (h *EnvironmentHandler) ListEnvironments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, ok := h.resolveRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var envs []models.Environment
|
||||||
|
if err := h.db.Where("repo_id = ?", repoID).Asc("name").Find(&envs); err != nil {
|
||||||
|
jsonError(w, "could not list environments", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if envs == nil {
|
||||||
|
envs = []models.Environment{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type envResponse struct {
|
||||||
|
models.Environment
|
||||||
|
LatestDeployment *models.Deployment `json:"latestDeployment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]envResponse, len(envs))
|
||||||
|
for i, env := range envs {
|
||||||
|
var latest models.Deployment
|
||||||
|
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
|
||||||
|
er := envResponse{Environment: env}
|
||||||
|
if found {
|
||||||
|
er.LatestDeployment = &latest
|
||||||
|
}
|
||||||
|
result[i] = er
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOK(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEnvironment creates a new named environment for a repository.
|
||||||
|
func (h *EnvironmentHandler) CreateEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, ok := h.resolveRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
ProtectionRules string `json:"protectionRules"` // raw JSON string
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
jsonError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject duplicate name within repo.
|
||||||
|
var existing models.Environment
|
||||||
|
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, body.Name).Get(&existing); found {
|
||||||
|
jsonError(w, "environment with this name already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
env := &models.Environment{
|
||||||
|
RepoID: repoID,
|
||||||
|
Name: body.Name,
|
||||||
|
URL: body.URL,
|
||||||
|
ProtectionRules: body.ProtectionRules,
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(env); err != nil {
|
||||||
|
jsonError(w, "could not create environment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(env) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvironment returns a single environment with its latest deployment.
|
||||||
|
func (h *EnvironmentHandler) GetEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest models.Deployment
|
||||||
|
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
|
||||||
|
|
||||||
|
type envResponse struct {
|
||||||
|
models.Environment
|
||||||
|
LatestDeployment *models.Deployment `json:"latestDeployment"`
|
||||||
|
}
|
||||||
|
resp := envResponse{Environment: *env}
|
||||||
|
if found {
|
||||||
|
resp.LatestDeployment = &latest
|
||||||
|
}
|
||||||
|
jsonOK(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEnvironment patches the URL and/or protection rules of an environment.
|
||||||
|
func (h *EnvironmentHandler) UpdateEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
URL *string `json:"url"`
|
||||||
|
ProtectionRules *string `json:"protectionRules"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := []string{}
|
||||||
|
if body.URL != nil {
|
||||||
|
env.URL = *body.URL
|
||||||
|
cols = append(cols, "url")
|
||||||
|
}
|
||||||
|
if body.ProtectionRules != nil {
|
||||||
|
env.ProtectionRules = *body.ProtectionRules
|
||||||
|
cols = append(cols, "protection_rules")
|
||||||
|
}
|
||||||
|
if len(cols) > 0 {
|
||||||
|
if _, err := h.db.ID(env.ID).Cols(cols...).Update(env); err != nil {
|
||||||
|
jsonError(w, "could not update environment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEnvironment removes an environment and all its deployment records.
|
||||||
|
func (h *EnvironmentHandler) DeleteEnvironment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade-delete deployments first (XORM has no cascade by default).
|
||||||
|
h.db.Where("env_id = ?", env.ID).Delete(&models.Deployment{}) //nolint:errcheck
|
||||||
|
if _, err := h.db.ID(env.ID).Delete(&models.Environment{}); err != nil {
|
||||||
|
jsonError(w, "could not delete environment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deployment endpoints ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ListDeployments returns all deployments for an environment, newest first.
|
||||||
|
func (h *EnvironmentHandler) ListDeployments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 30
|
||||||
|
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploys []models.Deployment
|
||||||
|
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&deploys); err != nil {
|
||||||
|
jsonError(w, "could not list deployments", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deploys == nil {
|
||||||
|
deploys = []models.Deployment{}
|
||||||
|
}
|
||||||
|
jsonOK(w, deploys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeployment records a new deployment event (pending → triggers the deploy workflow).
|
||||||
|
func (h *EnvironmentHandler) CreateDeployment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := middleware.UserIDFromContext(r.Context())
|
||||||
|
var actor models.User
|
||||||
|
h.db.ID(userID).Cols("username").Get(&actor)
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
RunID *int64 `json:"runId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.SHA == "" {
|
||||||
|
jsonError(w, "sha is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
deploy := &models.Deployment{
|
||||||
|
EnvID: env.ID,
|
||||||
|
RepoID: env.RepoID,
|
||||||
|
SHA: body.SHA,
|
||||||
|
Ref: body.Ref,
|
||||||
|
Status: models.DeployStatusPending,
|
||||||
|
TriggeredBy: actor.Username,
|
||||||
|
Description: body.Description,
|
||||||
|
RunID: body.RunID,
|
||||||
|
StartedAt: &now,
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(deploy); err != nil {
|
||||||
|
jsonError(w, "could not create deployment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.publishDeployEvent("deployment.started", env, deploy)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(deploy) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDeploymentStatus allows external systems (CI runners, webhooks) to
|
||||||
|
// advance a deployment through its lifecycle states.
|
||||||
|
func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
env, ok := h.resolveEnv(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployID, err := strconv.ParseInt(chi.URLParam(r, "deployID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid deployment ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var deploy models.Deployment
|
||||||
|
if found, _ := h.db.Where("id = ? AND env_id = ?", deployID, env.ID).Get(&deploy); !found {
|
||||||
|
jsonError(w, "deployment not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := map[string]bool{
|
||||||
|
"in_progress": true,
|
||||||
|
"success": true,
|
||||||
|
"failure": true,
|
||||||
|
"cancelled": true,
|
||||||
|
}
|
||||||
|
if !allowed[body.Status] {
|
||||||
|
jsonError(w, "invalid status; must be in_progress, success, failure, or cancelled", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy.Status = models.DeployStatus(body.Status)
|
||||||
|
if body.Description != "" {
|
||||||
|
deploy.Description = body.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := []string{"status", "description"}
|
||||||
|
if deploy.Status == models.DeployStatusSuccess ||
|
||||||
|
deploy.Status == models.DeployStatusFailure ||
|
||||||
|
deploy.Status == models.DeployStatusCancelled {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
deploy.FinishedAt = &now
|
||||||
|
cols = append(cols, "finished_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.db.ID(deploy.ID).Cols(cols...).Update(&deploy); err != nil {
|
||||||
|
jsonError(w, "could not update deployment status", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := map[string]string{
|
||||||
|
"success": "deployment.succeeded",
|
||||||
|
"failure": "deployment.failed",
|
||||||
|
"cancelled": "deployment.failed",
|
||||||
|
}[body.Status]
|
||||||
|
if subject != "" {
|
||||||
|
h.publishDeployEvent(subject, env, &deploy)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOK(w, deploy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
owner := chi.URLParam(r, "owner")
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
var u models.User
|
||||||
|
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var repo models.Repository
|
||||||
|
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return repo.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
|
||||||
|
repoID, ok := h.resolveRepo(w, r)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
envName := chi.URLParam(r, "envName")
|
||||||
|
var env models.Environment
|
||||||
|
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
|
||||||
|
jsonError(w, "environment not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &env, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type deployEventPayload struct {
|
||||||
|
DeploymentID int64 `json:"deploymentId"`
|
||||||
|
EnvID int64 `json:"envId"`
|
||||||
|
EnvName string `json:"envName"`
|
||||||
|
RepoID int64 `json:"repoId"`
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Status models.DeployStatus `json:"status"`
|
||||||
|
TriggeredBy string `json:"triggeredBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
|
||||||
|
h.bus.Publish(subject, deployEventPayload{ //nolint:errcheck
|
||||||
|
DeploymentID: d.ID,
|
||||||
|
EnvID: env.ID,
|
||||||
|
EnvName: env.Name,
|
||||||
|
RepoID: d.RepoID,
|
||||||
|
SHA: d.SHA,
|
||||||
|
Ref: d.Ref,
|
||||||
|
Status: d.Status,
|
||||||
|
TriggeredBy: d.TriggeredBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
auditH := handlers.NewAuditHandler(engine)
|
auditH := handlers.NewAuditHandler(engine)
|
||||||
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
||||||
runnerH := handlers.NewRunnerHandler(engine)
|
runnerH := handlers.NewRunnerHandler(engine)
|
||||||
|
envH := handlers.NewEnvironmentHandler(engine, bus)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||||
@@ -207,6 +208,20 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
|
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
|
||||||
r.Get("/lfs-settings", lfsH.Get)
|
r.Get("/lfs-settings", lfsH.Get)
|
||||||
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
r.With(csrf).Put("/lfs-settings", lfsH.Update)
|
||||||
|
r.Route("/environments", func(r chi.Router) {
|
||||||
|
r.Get("/", envH.ListEnvironments)
|
||||||
|
r.With(csrf).Post("/", envH.CreateEnvironment)
|
||||||
|
r.Route("/{envName}", func(r chi.Router) {
|
||||||
|
r.Get("/", envH.GetEnvironment)
|
||||||
|
r.With(csrf).Patch("/", envH.UpdateEnvironment)
|
||||||
|
r.With(csrf).Delete("/", envH.DeleteEnvironment)
|
||||||
|
r.Route("/deployments", func(r chi.Router) {
|
||||||
|
r.Get("/", envH.ListDeployments)
|
||||||
|
r.With(csrf).Post("/", envH.CreateDeployment)
|
||||||
|
r.With(csrf).Patch("/{deployID}/status", envH.UpdateDeploymentStatus)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DeployStatus represents the lifecycle state of a deployment.
|
||||||
|
type DeployStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeployStatusPending DeployStatus = "pending"
|
||||||
|
DeployStatusInProgress DeployStatus = "in_progress"
|
||||||
|
DeployStatusSuccess DeployStatus = "success"
|
||||||
|
DeployStatusFailure DeployStatus = "failure"
|
||||||
|
DeployStatusCancelled DeployStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment is a named deployment target for a repository (e.g. production, staging, dev).
|
||||||
|
// ProtectionRules is a JSON blob: {"require_approval":true,"required_reviewers":1}
|
||||||
|
type Environment struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||||
|
Name string `xorm:"'name' varchar(100) notnull" json:"name"`
|
||||||
|
URL string `xorm:"'url' varchar(500)" json:"url"`
|
||||||
|
ProtectionRules string `xorm:"'protection_rules' text" json:"protectionRules"` // JSON
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deployment records a single deploy event to an Environment.
|
||||||
|
// RunID optionally links the deployment to a PipelineRun that triggered it.
|
||||||
|
type Deployment struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
EnvID int64 `xorm:"'env_id' notnull index" json:"envId"`
|
||||||
|
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||||
|
SHA string `xorm:"'sha' varchar(40) notnull" json:"sha"`
|
||||||
|
Ref string `xorm:"'ref' varchar(255)" json:"ref"` // refs/heads/main or tag
|
||||||
|
Status DeployStatus `xorm:"'status' varchar(20) notnull" json:"status"`
|
||||||
|
TriggeredBy string `xorm:"'triggered_by' varchar(64)" json:"triggeredBy"` // username
|
||||||
|
Description string `xorm:"'description' text" json:"description"`
|
||||||
|
RunID *int64 `xorm:"'run_id'" json:"runId"` // optional PipelineRun link
|
||||||
|
StartedAt *time.Time `xorm:"'started_at'" json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `xorm:"'finished_at'" json:"finishedAt"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -37,5 +37,8 @@ func Run(engine *xorm.Engine) error {
|
|||||||
if err := Run008(engine); err != nil {
|
if err := Run008(engine); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return Run009(engine)
|
if err := Run009(engine); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return Run010(engine)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run010(engine *xorm.Engine) error {
|
||||||
|
return engine.Sync2(
|
||||||
|
&models.Environment{},
|
||||||
|
&models.Deployment{},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user