From 24bf4706e1ac9604df4c7b2611444ed96aa750af Mon Sep 17 00:00:00 2001 From: erangel1 Date: Mon, 11 May 2026 21:20:12 +0200 Subject: [PATCH] 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 --- AGENTS.md | 29 +- CHANGELOG.md | 26 +- README.md | 13 +- frontend/src/App.tsx | 2 + frontend/src/api/queries/environments.ts | 157 +++++++ frontend/src/components/layout/Sidebar.tsx | 6 +- frontend/src/pages/EnvironmentsPage.tsx | 433 ++++++++++++++++++ frontend/src/pages/RepoPage.tsx | 33 ++ frontend/src/types/api.ts | 39 ++ internal/api/handlers/environment.go | 377 +++++++++++++++ internal/api/router.go | 23 +- internal/models/environment.go | 43 ++ internal/models/migrations/001_init.go | 5 +- .../models/migrations/010_environments.go | 13 + 14 files changed, 1168 insertions(+), 31 deletions(-) create mode 100644 frontend/src/api/queries/environments.ts create mode 100644 frontend/src/pages/EnvironmentsPage.tsx create mode 100644 internal/api/handlers/environment.go create mode 100644 internal/models/environment.go create mode 100644 internal/models/migrations/010_environments.go diff --git a/AGENTS.md b/AGENTS.md index f59d8b2..2582707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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** | | 2A | NATS event bus, WebSocket hub upgrade, audit log | **Complete** | | 2B | CI orchestrator, runner manager, Docker executor, artifact registry | **Complete** | -| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Active** | -| 3A | Environment model + deployment tracking | Planned | +| 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette wiring | **Complete** | +| 3A | Environment model + deployment tracking | **Active** | | 3B | Unified operational timeline | Planned | | 3C | Secret management hierarchy | 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. -### 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. -2. **`queries/pipelines.ts`** — Needs `useRuns`, `useRunDetail`, `useJobLogs`, cancel/retry mutations aligned with correct types. -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). -4. **`PipelinesPage`** — Currently an empty placeholder. Replace with real cross-repo runs list. -5. **`PipelineRunPage`** — New page at `/repos/:owner/:repo/runs/:runId`. Shows run header + DAG + step log viewer. -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. -8. **Command palette** — Add pipeline runs to search results. +**Backend:** +1. `internal/models/environment.go` — `Environment` (id, repoId, name, url, protectionRules JSON) + `Deployment` (id, envId, repoId, sha, ref, status, triggeredBy, description, runId, startedAt, finishedAt) +2. `internal/models/migrations/010_environments.go` — `Run010()` syncing both structs; call from `001_init.go` +3. `internal/api/handlers/environment.go` — `ListEnvironments`, `CreateEnvironment`, `GetEnvironment`, `UpdateEnvironment`, `DeleteEnvironment`, `ListDeployments`, `CreateDeployment`, `UpdateDeploymentStatus`; publish `deployment.*` NATS events +4. `internal/api/router.go` — wire routes under `/{owner}/{repo}/environments` and `/{owner}/{repo}/environments/{envName}/deployments` + +**Frontend:** +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` --- diff --git a/CHANGELOG.md b/CHANGELOG.md index fc6b480..e6c329b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,26 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -### In Progress — Phase 2C (CI Legibility) -- Pipeline DAG visualization (PipelineRunPage with real job/step graph) -- Dashboard CI command center upgrade (replace placeholder with live recent runs) -- Command palette wiring (pipeline runs in search, Pipelines quick-nav) -- Global cross-repo pipeline runs feed (`/pipelines` page) -- Per-step log viewer (collapsible, streamed from backend) +### In Progress — Phase 3A (Environment model + deployment tracking) +- `Environment` model per repo (name, URL, protection rules) +- `Deployment` model (sha, ref, status, triggered_by, run_id link) +- Full CRUD API for environments +- Deployment trigger + status update API +- 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) - GitOps controller with reconciliation loops diff --git a/README.md b/README.md index ce94fba..8754c79 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth | Build artifact storage | Done (Phase 2B) | | Pipeline cancellation + job retry | Done (Phase 2B) | | NATS event bus + WebSocket live push | Done (Phase 2A) | -| Pipeline DAG visualization (frontend) | **In progress (Phase 2C)** | -| Dashboard CI command center | **In progress (Phase 2C)** | -| Pipeline log viewer (per-step, collapsible) | **In progress (Phase 2C)** | +| Pipeline DAG visualization (frontend) | Done (Phase 2C) | +| Dashboard CI command center | Done (Phase 2C) | +| Pipeline log viewer (per-step, collapsible) | Done (Phase 2C) | | Kubernetes / Firecracker runner backends | Planned (Phase 2D) | | Forgejo Actions gRPC integration | Planned | | Matrix builds + reusable workflow templates | Planned | @@ -75,7 +75,7 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth ### GitOps + Environments | Feature | Status | |---------|--------| -| Environment model + deployment tracking | Planned (Phase 3A) | +| Environment model + deployment tracking | **In progress (Phase 3A)** | | Unified operational timeline | Planned (Phase 3B) | | Secret management hierarchy | Planned (Phase 3C) | | 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 2A | NATS event bus, WebSocket hub, audit log | 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 3A–F | GitOps, environments, timeline, secrets, drift detection, federation | Planned | +| Phase 2C | Pipeline DAG visualization, dashboard CI upgrade, command palette | Done | +| 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 | --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d26526f..3e34fcc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,7 @@ const PRsPage = lazy(() => import('./pages/PRsPage')) const PipelinesPage = lazy(() => import('./pages/PipelinesPage')) const PipelineRunPage = lazy(() => import('./pages/PipelineRunPage')) const RepoPipelinesPage = lazy(() => import('./pages/RepoPipelinesPage')) +const EnvironmentsPage = lazy(() => import('./pages/EnvironmentsPage')) const ProfilePage = lazy(() => import('./pages/ProfilePage')) const ExplorePage = lazy(() => import('./pages/ExplorePage')) const SettingsPage = lazy(() => import('./pages/SettingsPage')) @@ -85,6 +86,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/queries/environments.ts b/frontend/src/api/queries/environments.ts new file mode 100644 index 0000000..4a08f2d --- /dev/null +++ b/frontend/src/api/queries/environments.ts @@ -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( + `/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( + `/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( + `/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( + `/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( + `/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( + `/api/v1/repos/${owner}/${repo}/environments/${envName}`, + z.unknown() as z.ZodType, + ), + 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( + `/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( + `/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] }) + }, + }) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c188918..fc4465b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -161,8 +161,9 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) { { label: 'Branches', to: `${base}/branches`, icon: }, { label: 'Pull requests', to: `${base}/pulls`, icon: }, { label: 'Issues', to: `${base}/issues`, icon: }, - { label: 'Pipelines', to: `${base}/pipelines`, icon: }, - { label: 'Settings', to: `${base}/settings`, icon: }, + { label: 'Pipelines', to: `${base}/pipelines`, icon: }, + { label: 'Environments', to: `${base}/environments`, icon: }, + { label: 'Settings', to: `${base}/settings`, icon: }, ] return (
@@ -203,4 +204,5 @@ const CommitsIcon = () => const IssueIcon = () => const PipelineIcon = () => +const EnvIcon = () => const SettingsSmIcon = () => diff --git a/frontend/src/pages/EnvironmentsPage.tsx b/frontend/src/pages/EnvironmentsPage.tsx new file mode 100644 index 0000000..f3709be --- /dev/null +++ b/frontend/src/pages/EnvironmentsPage.tsx @@ -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 = { + 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 = { + 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 = { + pending: 'Pending', + in_progress: 'In progress', + success: 'Active', + failure: 'Failed', + cancelled: 'Cancelled', +} + +function DeployBadge({ status }: { status: DeployStatus }) { + return ( + + + {DEPLOY_LABEL[status]} + + ) +} + +// ── 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 ( +
+ {[1, 2, 3].map(i => )} +
+ ) + } + if (!deploys?.length) { + return

No deployments yet.

+ } + + return ( +
+ {deploys.map(d => ( +
+ +
+
+ {shortSHA(d.sha)} + {d.ref && ( + {shortRef(d.ref)} + )} + +
+

+ by {d.triggeredBy} + {d.description && · {d.description}} +

+
+ {timeAgo(d.createdAt)} +
+ ))} +
+ ) +} + +// ── 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 ( +
+
e.stopPropagation()}> +
+

Deploy to {envName}

+ +
+
+
+ + 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 + /> +
+
+ + 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)]" + /> +
+
+ + 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)]" + /> +
+
+ + +
+ {createDeployment.isError && ( +

+ {(createDeployment.error as Error)?.message ?? 'Deployment failed'} +

+ )} +
+
+
+ ) +} + +// ── 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 ( +
+
e.stopPropagation()}> +
+

New environment

+ +
+
+
+ + 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 + /> +
+
+ + 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)]" + /> +
+
+ + +
+ {createEnv.isError && ( +

+ {(createEnv.error as Error)?.message ?? 'Failed to create environment'} +

+ )} +
+
+
+ ) +} + +// ── 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 ( +
+ {/* Card header */} +
+
+
+

{env.name}

+ {latest && } +
+ {env.url && ( + + {env.url} + + )} + {latest ? ( +
+ {shortSHA(latest.sha)} + {latest.ref && {shortRef(latest.ref)}} + by {latest.triggeredBy} + {timeAgo(latest.createdAt)} +
+ ) : ( +

No deployments yet

+ )} +
+ + {/* Actions */} +
+ + + +
+
+ + {/* History panel */} + {expanded && ( +
+

+ Deployment history +

+ +
+ )} + + {/* Deploy modal */} + {deploying && ( + setDeploying(false)} /> + )} +
+ ) +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function EnvironmentsPage() { + const { owner = '', repo = '' } = useParams() + const { data: envs, isLoading } = useEnvironments(owner, repo) + const [showNewEnv, setShowNewEnv] = useState(false) + + return ( +
+ + {/* Header */} +
+
+

Environments

+

+ Deployment targets for {owner}/{repo} +

+
+ +
+ + {/* Environment cards */} + {isLoading ? ( +
+ {[1, 2, 3].map(i => ( +
+ + +
+ ))} +
+ ) : !envs?.length ? ( +
+
+ + + +
+
+

No environments yet

+

+ Create environments like production, staging, or dev to track where your code is deployed. +

+
+ +
+ ) : ( +
+ {envs.map(env => ( + + ))} +
+ )} + + {showNewEnv && setShowNewEnv(false)} />} +
+ ) +} diff --git a/frontend/src/pages/RepoPage.tsx b/frontend/src/pages/RepoPage.tsx index f90ff7c..5c1b28c 100644 --- a/frontend/src/pages/RepoPage.tsx +++ b/frontend/src/pages/RepoPage.tsx @@ -3,6 +3,7 @@ import { useParams, useSearchParams, Link } from 'react-router-dom' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos' +import { useEnvironments } from '../api/queries/environments' import { TreeBrowser } from '../components/repos/TreeBrowser' import { RepoListSkeleton } from '../ui/Skeleton' import { RepoAvatar } from '../ui/RepoAvatar' @@ -21,6 +22,7 @@ export default function RepoPage() { const { data: repo, isLoading, isError } = useRepo(owner, repoName) const { data: branches } = useRepoBranches(owner, repoName) + const { data: environments } = useEnvironments(owner, repoName) const { track } = useRecentRepos() useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName]) @@ -65,6 +67,37 @@ export default function RepoPage() { {repo.description && (

{repo.description}

)} + + {/* Deployment status badges */} + {environments && environments.length > 0 && ( +
+ {environments.map(env => { + const status = env.latestDeployment?.status + const dot: Record = { + 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 ( + + + {env.name} + {env.latestDeployment?.sha && ( + + {env.latestDeployment.sha.slice(0, 7)} + + )} + + ) + })} +
+ )}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 380830c..cb26a9c 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -221,6 +221,45 @@ export interface Webhook { 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 { error: string status: number diff --git a/internal/api/handlers/environment.go b/internal/api/handlers/environment.go new file mode 100644 index 0000000..6517181 --- /dev/null +++ b/internal/api/handlers/environment.go @@ -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, + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index cd17a8b..f01dd60 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -58,6 +58,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even auditH := handlers.NewAuditHandler(engine) artifactH := handlers.NewArtifactHandler(engine, artifactRoot) runnerH := handlers.NewRunnerHandler(engine) + envH := handlers.NewEnvironmentHandler(engine, bus) // ── Git smart-HTTP transport ─────────────────────────────────────────────── // Regex constraint ensures only *.git paths match, so asset/SPA URLs @@ -203,14 +204,28 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even }) r.Get("/default-description", prSettingsH.GetDefaultDescription) r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription) - r.Get("/excluded-files", prSettingsH.GetExcludedFiles) - r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles) - r.Get("/lfs-settings", lfsH.Get) - r.With(csrf).Put("/lfs-settings", lfsH.Update) + r.Get("/excluded-files", prSettingsH.GetExcludedFiles) + r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles) + r.Get("/lfs-settings", lfsH.Get) + 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) + }) + }) }) }) }) }) + }) r.With(auth.Optional).Get("/ws", wsH.Hub) diff --git a/internal/models/environment.go b/internal/models/environment.go new file mode 100644 index 0000000..6e6d46a --- /dev/null +++ b/internal/models/environment.go @@ -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"` +} diff --git a/internal/models/migrations/001_init.go b/internal/models/migrations/001_init.go index 92e01bb..f6b6cd3 100644 --- a/internal/models/migrations/001_init.go +++ b/internal/models/migrations/001_init.go @@ -37,5 +37,8 @@ func Run(engine *xorm.Engine) error { if err := Run008(engine); err != nil { return err } - return Run009(engine) + if err := Run009(engine); err != nil { + return err + } + return Run010(engine) } diff --git a/internal/models/migrations/010_environments.go b/internal/models/migrations/010_environments.go new file mode 100644 index 0000000..d42b6ba --- /dev/null +++ b/internal/models/migrations/010_environments.go @@ -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{}, + ) +}