diff --git a/AGENTS.md b/AGENTS.md
index c17f62b..f59d8b2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,12 +55,32 @@ Understand the phases before adding code — don't build Phase 3 infrastructure
| Phase | Scope | Status |
|-------|-------|--------|
-| 1 | Auth, Git HTTP, repos, PRs, issues, RBAC, webhooks model, LFS, design system, 20-page SPA | **Complete** |
-| 2 | CI/CD orchestrator, runner manager, pipeline DAG visualization, artifact registry | **Active — `internal/domain/ci/` is the stub** |
-| 3 | GitOps controller, environments, drift detection, federation handlers, observability, audit log | Planned |
-| 4 | Command palette, AI diagnostics, signed artifacts, package registry | Planned |
+| 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 |
+| 3B | Unified operational timeline | Planned |
+| 3C | Secret management hierarchy | Planned |
+| 3D | GitOps controller + drift detection | Planned |
+| 3E | Observability (Prometheus, health sparklines) | Planned |
+| 3F | Federation handlers (ActivityPub inbox/outbox) | Planned |
+| 4 | AI diagnostics, signed artifacts, OCI registry, secret/dep scanning | Planned |
-Do not implement Phase 3+ features without explicit discussion. The `domain/federation/` and `domain/ci/` directories are intentional stubs.
+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
+
+All backend APIs for CI are complete. Phase 2C is entirely frontend work:
+
+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.
---
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a843a10..fc6b480 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,28 +9,108 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
-### Planned — Phase 2 (CI/CD)
-- CI orchestrator with DAG pipeline execution
-- Runner manager (Docker, Kubernetes, Firecracker backends)
-- Pipeline DAG visualization (visual dependency graph with live execution state)
-- Build artifact storage and retention policies
-- Forgejo Actions gRPC integration
-- Flaky test detection
-- Pipeline log viewer (collapsible, filterable, syntax-highlighted)
-- Matrix builds and reusable workflow templates
-- Concurrency controls and pipeline cancellation
+### 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)
### Planned — Phase 3 (GitOps + Observability + Federation)
- GitOps controller with reconciliation loops
-- Environment management and topology visualization
+- Environment model + deployment tracking
+- Unified operational timeline (commits + deployments + CI failures merged)
- Drift detection and sync status
- Deployment promotion workflows (dev → staging → production)
- Rollback visualization and one-click rollbacks
- Canary and blue/green deployment support
-- Unified operational timeline (commits + deployments + incidents + CI failures merged)
- ActivityPub / ForgeFed federation handlers (inbox, outbox, cross-instance PRs)
-- Audit log (all administrative and git-over-HTTP actions)
-- Secret scanning and dependency vulnerability scanning
+- Secret management hierarchy (Global → Org → Repo → Env)
+- Observability (Prometheus endpoint, health sparklines)
+
+### Planned — Phase 4
+- AI diagnostics (pipeline failure root-cause analysis)
+- Signed artifacts (Sigstore/Cosign)
+- OCI package registry
+- Secret and dependency vulnerability scanning
+
+---
+
+## [0.3.0] — 2026-05-11
+
+Phase 2B complete. Full CI/CD execution backend operational.
+
+### Added — CI Orchestrator (`internal/domain/ci/`)
+- DAG-based pipeline orchestrator (`orchestrator.go`): subscribes to NATS `push.received`,
+ parses `.forgebucket/workflows/*.yml`, creates `PipelineRun`/`PipelineJob`/`PipelineStep`
+ records, advances DAG on `job.completed`/`job.failed`, recovers stale runs on startup
+- Docker executor (`executor.go`): runs steps in isolated containers (`docker run --rm`),
+ streams logs to DB and NATS via `pipeline.log` subject, handles `git archive` workspace extraction
+- Runner manager (`runner_manager.go`): semaphore-limited concurrent job dispatch (default 4),
+ subscribes to `job.queued`, calls executor when Docker is available
+- DAG engine (`dag.go`): full topological sort (`TopoSort`) and `ReadyJobs` for dependency resolution
+- Workflow parser (`parser.go`): reads `.forgebucket/workflows/*.yml` from git ref,
+ `MatchesPushTrigger` with glob pattern support
+- CI types (`types.go`): `WorkflowFile`, `WorkflowJob`, `WorkflowStep`, YAML `StringOrSlice` unmarshaler
+
+### Added — CI API Handlers
+- `GET /api/v1/repos/:owner/:repo/pipelines` — list pipeline definitions
+- `GET /api/v1/repos/:owner/:repo/runs` — list pipeline runs (most recent first, limit 30)
+- `GET /api/v1/repos/:owner/:repo/runs/:runID` — run detail with full job + step tree
+- `POST /api/v1/repos/:owner/:repo/runs/:runID/cancel` — cancel queued or running run
+- `POST /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/retry` — re-queue failed/cancelled job
+- `GET /api/v1/repos/:owner/:repo/runs/:runID/jobs/:jobID/logs` — step-level log chunks
+- `GET /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — list artifacts for a run
+- `POST /api/v1/repos/:owner/:repo/runs/:runID/artifacts` — upload artifact (multipart, 512 MB max)
+- `GET /api/v1/repos/:owner/:repo/artifacts/:artifactID/download` — artifact download with path traversal guard
+- `GET /api/v1/admin/runners` — list registered runners (admin-only)
+- `POST /api/v1/admin/runners/register` — register a new runner with bcrypt token hashing (admin-only)
+
+### Added — Database Models (migration `009_ci`)
+- `Pipeline` — workflow definition record (name, filePath, repoId)
+- `PipelineRun` — execution record (triggerRef, triggerSha, triggeredBy, status, startedAt, finishedAt)
+- `PipelineJob` — single DAG node (name, image, needs JSON, status, timing)
+- `PipelineStep` — single command within a job (seq, runCmd, usesAction, exitCode, timing)
+- `PipelineStepLog` — append-only log chunk storage (stepId, chunkIndex, content)
+- `Runner` — registered execution backend (name, labels, status, tokenHash, lastSeenAt)
+- `Artifact` — build artifact (runId, repoId, name, storagePath, size, contentType)
+
+---
+
+## [0.2.0] — 2026-05-11
+
+Phase 2A complete. Real-time event infrastructure and audit log operational.
+
+### Added — NATS Event Bus (`internal/events/`)
+- `EventBus` interface: `Publish`, `Subscribe`, `Close`
+- `NATSBus`: NATS-backed implementation with auto-reconnect, max-reconnect disabled
+- `NoOpBus`: silent fallback when `NATS_URL` is not configured (app fully functional without NATS)
+- `New(url)` factory: returns `NATSBus` if URL is set, `NoOpBus` otherwise
+- Event subjects defined in `subjects.go`:
+ - `repo.*` (created, deleted, pushed)
+ - `push.received`
+ - `pr.*` (opened, merged, closed)
+ - `issue.*` (opened, closed)
+ - `pipeline.*` (queued, started, succeeded, failed, cancelled)
+ - `job.*` (queued, started, completed, failed), `pipeline.log`
+ - `deployment.*`, `environment.*` (Phase 3 stubs)
+ - `audit.event`
+
+### Added — WebSocket Hub (`internal/api/handlers/ws.go`)
+- `GET /ws` — upgrades HTTP to WebSocket (nhooyr.io/websocket)
+- Subscribes to all NATS subjects on connect, fans events to the client as JSON
+- Optional session auth (`auth.Optional` middleware) — works for guests too
+- Phase 2B note: per-user event filtering is a planned upgrade
+
+### Added — Audit Log
+- `AuditLog` model (migration `008_audit_log`): actor, method, path, statusCode, requestBody, ipAddr, timestamp
+- `AuditLog` middleware: records every authenticated request to the DB and publishes `audit.event`
+- `GET /api/v1/audit` — paginated audit log query (admin-only, filterable by actor/method/time range)
+
+### Fixed — Local development environment
+- `DATABASE_URL` was using Docker-internal hostname `postgres`; corrected to `localhost` for `make dev`
+- Added `NATS_URL=nats://localhost:4222` to `.env` (was missing; CI orchestrator requires it)
+- `REPO_ROOT` corrected to `/tmp/forgebucket/repos` (Docker path `/var/lib/forgebucket/repos` requires sudo on macOS)
---
@@ -70,7 +150,9 @@ Initial development milestone. Core Git hosting, collaboration, and frontend SPA
### Added — Frontend SPA
- React 18 + TypeScript + Vite, embedded into Go binary via `//go:embed`
-- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo, RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred, PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
+- 20 route-level pages: Login, Register, Dashboard, Repos, CreateRepo, ImportRepo, Repo,
+ RepoSettings, Blob, Commits, Branches, RepoIssues, RepoPRs, CreatePR, PRDetail, Starred,
+ PRs (cross-repo), Pipelines (placeholder), Explore, Profile, Settings
- AppShell layout wrapper for all authenticated pages
- Triple-state sidebar: expanded (320px) / collapsed (56px) / mobile bottom bar
- Mobile-first responsive design (375px → 1440px)
@@ -90,13 +172,16 @@ Initial development milestone. Core Git hosting, collaboration, and frontend SPA
- System font stack (Segoe UI, Roboto, sans-serif)
### Added — Infrastructure
-- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys, access tokens, deploy keys, workflows, and LFS settings
+- PostgreSQL + XORM with 7 migration files covering: users, repositories, issues, SSH keys,
+ access tokens, deploy keys, workflows, and LFS settings
- ActivityPub actor data model (FederationActor with inbox/outbox URLs and RSA key pairs) — data layer only
-- Docker Compose setup for local PostgreSQL
+- Docker Compose setup for local PostgreSQL + NATS
- Makefile targets: dev, build, migrate, test, lint, docker-up
- WebSockets foundation for live logs and notifications
---
-[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.1.0...HEAD
+[Unreleased]: https://github.com/forgeo/forgebucket/compare/v0.3.0...HEAD
+[0.3.0]: https://github.com/forgeo/forgebucket/compare/v0.2.0...v0.3.0
+[0.2.0]: https://github.com/forgeo/forgebucket/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/forgeo/forgebucket/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index e5e9dfd..ce94fba 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
ForgeBucket is a self-hosted, federated developer operations platform. Where other Git platforms show you a list of files, ForgeBucket surfaces deployments, pipeline health, environment drift, and operational context directly alongside your code. Repositories are runtime systems. The dashboard is a command center.
-**Status:** Early development. Core Git hosting, collaboration, and auth are functional. CI/CD and GitOps integrations are next.
+**Status:** Phase 2C in progress. CI/CD execution backend is fully operational. Pipeline visualization and dashboard integration are being wired up now.
---
@@ -32,6 +32,7 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
| OIDC / OAuth2 (optional) | Done |
| Access tokens (scoped, expiring) | Done |
| Deploy keys | Done |
+| Audit log | Done |
### Git Hosting
| Feature | Status |
@@ -52,44 +53,50 @@ ForgeBucket is a self-hosted, federated developer operations platform. Where oth
| Side-by-side + unified diff viewer | Done |
| Reviewer assignment | Done |
| Merge strategies (merge/squash/rebase) | Done |
-| Webhooks | Done (model + routes) |
+| Webhooks | Done |
| Repository RBAC (read/write/admin) | Done |
### CI/CD
| Feature | Status |
|---------|--------|
-| Pipeline DAG visualization | In progress |
-| CI orchestrator | Planned (Phase 2) |
-| Runner manager | Planned (Phase 2) |
-| Artifact registry | Planned (Phase 2) |
-| Forgejo Actions integration (gRPC) | Planned (Phase 2) |
-| Flaky test detection | Planned (Phase 2) |
+| CI orchestrator (DAG pipeline execution) | Done (Phase 2B) |
+| Runner manager (Docker backend) | Done (Phase 2B) |
+| 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)** |
+| Kubernetes / Firecracker runner backends | Planned (Phase 2D) |
+| Forgejo Actions gRPC integration | Planned |
+| Matrix builds + reusable workflow templates | Planned |
+| Flaky test detection | Planned |
### GitOps + Environments
| Feature | Status |
|---------|--------|
-| GitOps controller | Planned (Phase 3) |
-| Environment management | Planned (Phase 3) |
-| Drift detection | Planned (Phase 3) |
-| Deployment promotion workflows | Planned (Phase 3) |
-| Rollback visualization | Planned (Phase 3) |
-| Canary / blue-green support | Planned (Phase 3) |
+| Environment model + deployment tracking | Planned (Phase 3A) |
+| Unified operational timeline | Planned (Phase 3B) |
+| Secret management hierarchy | Planned (Phase 3C) |
+| GitOps controller + drift detection | Planned (Phase 3D) |
+| Deployment promotion workflows | Planned (Phase 3D) |
+| Rollback visualization | Planned (Phase 3D) |
+| Canary / blue-green support | Planned (Phase 3D) |
### Observability + Security
| Feature | Status |
|---------|--------|
-| Unified operational timeline | Planned (Phase 3) |
-| Secret scanning | Planned (Phase 3) |
-| Dependency scanning | Planned (Phase 3) |
+| Prometheus endpoint + health sparklines | Planned (Phase 3E) |
+| Secret scanning | Planned (Phase 4) |
+| Dependency scanning | Planned (Phase 4) |
| Signed artifacts (Sigstore/Cosign) | Planned (Phase 4) |
-| Audit log | Planned (Phase 3) |
### Federation
| Feature | Status |
|---------|--------|
| ActivityPub actor model | Done (data layer) |
-| Federation handlers / inbox / outbox | Planned (Phase 3) |
-| Cross-instance pull requests | Planned (Phase 3) |
+| Federation handlers / inbox / outbox | Planned (Phase 3F) |
+| Cross-instance pull requests | Planned (Phase 3F) |
---
@@ -101,7 +108,7 @@ git clone https://github.com/forgeo/forgebucket.git
cd forgebucket
cp .env.example .env # fill in SESSION_SECRET and CSRF_SECRET
-# 2. Start PostgreSQL
+# 2. Start PostgreSQL + NATS
make docker-up
# 3. Run DB migrations
@@ -113,6 +120,8 @@ make dev
The Go API runs at `http://localhost:8080`. The Vite dev server runs at `http://localhost:5173` and proxies API requests.
+> **Local dev note:** `DATABASE_URL` must use `localhost` (not `postgres`) and `NATS_URL` must be set to `nats://localhost:4222`. The `.env` file ships with correct defaults for local development. See `.env.example` for all variables.
+
---
## Architecture
@@ -124,8 +133,9 @@ ForgeBucket
├── Repository Service (git HTTP, branches, LFS — internal/domain/git/)
├── Pull Request Service (PRs, reviews, merge — internal/api/handlers/)
├── Issue Service (issues, labels — internal/api/handlers/)
-├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← stub
-├── CI Orchestrator (pipeline scheduling — internal/domain/ci/) ← stub
+├── CI Orchestrator (DAG execution, Docker runner — internal/domain/ci/) ← Phase 2B done
+├── Event Bus (NATS core, NoOp fallback — internal/events/) ← Phase 2A done
+├── Federation Layer (ActivityPub actors — internal/domain/federation/) ← Phase 3F stub
├── Secret Manager (env-based, scoped tokens — internal/config/)
├── Database (PostgreSQL + XORM — internal/models/)
└── Web Frontend (React 18 + TypeScript, embedded via //go:embed — web/)
@@ -133,7 +143,7 @@ ForgeBucket
**Middleware chain (every request):**
```
-Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → Handler
+Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → AuditLog → Handler
```
---
@@ -145,13 +155,15 @@ Logger → RealIP → Recoverer → CORS → CSRF → SessionAuth → RBAC → H
| Language | Go 1.21+ |
| Router | Chi |
| ORM / Migrations | XORM + PostgreSQL |
+| Event bus | NATS (core; JetStream planned for Phase 2B durability) |
+| Real-time | WebSockets (nhooyr.io/websocket) |
+| CI execution | Docker (`docker run --rm`) |
| Frontend framework | React 18 + TypeScript |
| Build tool | Vite |
| Styling | Tailwind CSS v4 |
| Code editing | CodeMirror |
-| Real-time | WebSockets |
| Container | Docker Compose (dev) |
-| Federation | ActivityPub / ForgeFed |
+| Federation | ActivityPub / ForgeFed (data layer only) |
---
@@ -174,17 +186,18 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Variable | Required | Description |
|----------|----------|-------------|
-| `DATABASE_URL` | Yes | PostgreSQL connection string |
+| `DATABASE_URL` | Yes | PostgreSQL connection string — use `localhost` for local dev |
| `SESSION_SECRET` | Yes | Session signing key, ≥ 32 chars (`openssl rand -hex 32`) |
| `CSRF_SECRET` | Yes | CSRF key, exactly 32 chars (`openssl rand -hex 16`) |
| `PORT` | No | HTTP port, default `8080` |
| `REPO_ROOT` | Yes | Absolute path for bare git repository storage |
+| `NATS_URL` | No | NATS connection URL (e.g. `nats://localhost:4222`). If unset, CI runs in no-op mode |
| `INSTANCE_URL` | Yes | Public URL of this instance (no trailing slash) |
| `INSTANCE_NAME` | No | Display name, default `ForgeBucket` |
| `OIDC_ISSUER` | No | OIDC provider URL |
| `OIDC_CLIENT_ID` | No | OIDC client ID |
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
-| `DEBUG` | No | Disables Secure cookies, enables verbose logging |
+| `DEBUG` | No | Disables Secure cookies, enables verbose logging, proxies frontend to Vite |
---
@@ -197,7 +210,7 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| `make migrate` | Sync XORM schemas to PostgreSQL |
| `make test` | Run Go tests + Vitest |
| `make lint` | `go vet` + ESLint |
-| `make docker-up` | Start PostgreSQL via Docker Compose |
+| `make docker-up` | Start PostgreSQL + NATS via Docker Compose |
---
@@ -206,9 +219,11 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| Phase | Focus | Status |
|-------|-------|--------|
| Phase 1 | Core Git hosting, auth, PRs, issues, RBAC, design system | Done |
-| Phase 2 | CI/CD orchestrator, runner manager, pipeline visualization, artifact registry | In progress |
-| Phase 3 | GitOps controller, environments, observability, federation handlers, audit log | Planned |
-| Phase 4 | Command palette, AI diagnostics, signed artifacts, package registry | Planned |
+| 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 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
---
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c16632b..d26526f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -35,6 +35,8 @@ const BranchesPage = lazy(() => import('./pages/BranchesPage'))
const StarredPage = lazy(() => import('./pages/StarredPage'))
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 ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
@@ -82,6 +84,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
diff --git a/frontend/src/api/queries/dashboard.ts b/frontend/src/api/queries/dashboard.ts
index d633e1d..84e3aa6 100644
--- a/frontend/src/api/queries/dashboard.ts
+++ b/frontend/src/api/queries/dashboard.ts
@@ -48,18 +48,34 @@ const dashRepoSchema = z.object({
openIssueCount: z.number(),
})
+const dashRunSchema = z.object({
+ id: z.number(),
+ repoId: z.number(),
+ repoName: z.string(),
+ ownerName: z.string(),
+ triggerRef: z.string(),
+ triggerSha: z.string(),
+ triggeredBy: z.string(),
+ status: z.string(),
+ startedAt: z.string().nullable(),
+ finishedAt: z.string().nullable(),
+ createdAt: z.string(),
+})
+
const dashboardSchema = z.object({
stats: statsSchema,
reviewQueue: z.array(dashPRSchema),
myOpenPRs: z.array(dashPRSchema),
myOpenIssues: z.array(dashIssueSchema),
repos: z.array(dashRepoSchema),
+ recentRuns: z.array(dashRunSchema).optional().default([]),
})
export type DashboardData = z.infer
export type DashPR = z.infer
export type DashIssue = z.infer
export type DashRepo = z.infer
+export type DashRun = z.infer
export function useDashboard() {
return useQuery({
diff --git a/frontend/src/api/queries/pipelines.ts b/frontend/src/api/queries/pipelines.ts
index d4da01c..85f4e70 100644
--- a/frontend/src/api/queries/pipelines.ts
+++ b/frontend/src/api/queries/pipelines.ts
@@ -1,37 +1,190 @@
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
-import type { Pipeline } from '../../types/api'
+import type { Pipeline, PipelineRun, RunDetail, StepLogs } from '../../types/api'
+
+// ── Zod schemas ───────────────────────────────────────────────────────────────
+
+const runStatusSchema = z.enum(['queued', 'running', 'succeeded', 'failed', 'cancelled'])
const pipelineSchema = z.object({
id: z.number(),
repoId: z.number(),
- ref: z.string(),
- status: z.enum(['pending', 'running', 'success', 'failure', 'cancelled']),
+ name: z.string(),
+ filePath: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
-const pipelinesSchema = z.array(pipelineSchema)
+const runSchema = z.object({
+ id: z.number(),
+ pipelineId: z.number(),
+ repoId: z.number(),
+ triggerRef: z.string(),
+ triggerSha: z.string(),
+ triggeredBy: z.string(),
+ status: runStatusSchema,
+ startedAt: z.string().nullable(),
+ finishedAt: z.string().nullable(),
+ createdAt: z.string(),
+})
+const stepSchema = z.object({
+ id: z.number(),
+ jobId: z.number(),
+ seq: z.number(),
+ name: z.string(),
+ runCmd: z.string(),
+ usesAction: z.string(),
+ status: runStatusSchema,
+ exitCode: z.number(),
+ startedAt: z.string().nullable(),
+ finishedAt: z.string().nullable(),
+})
+
+const jobSchema = z.object({
+ id: z.number(),
+ runId: z.number(),
+ name: z.string(),
+ image: z.string(),
+ needs: z.string(),
+ status: runStatusSchema,
+ startedAt: z.string().nullable(),
+ finishedAt: z.string().nullable(),
+ createdAt: z.string(),
+ steps: z.array(stepSchema),
+})
+
+const runDetailSchema = runSchema.extend({
+ jobs: z.array(jobSchema),
+})
+
+const stepLogSchema = z.object({
+ id: z.number(),
+ stepId: z.number(),
+ chunkIndex: z.number(),
+ content: z.string(),
+ createdAt: z.string(),
+})
+
+const stepLogsSchema = z.array(
+ stepSchema.extend({ logs: z.array(stepLogSchema) }),
+)
+
+// ── Queries ───────────────────────────────────────────────────────────────────
+
+/** Pipeline definitions for a repo. */
export function usePipelines(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'pipelines'],
queryFn: () =>
- api.get(`/api/v1/repos/${owner}/${repo}/pipelines`, pipelinesSchema),
+ api.get(`/api/v1/repos/${owner}/${repo}/pipelines`, z.array(pipelineSchema)),
enabled: Boolean(owner && repo),
- refetchInterval: 5000, // poll while pipelines may be running
})
}
-export function usePipeline(owner: string, repo: string, runId: number) {
+/** Pipeline runs for a repo, newest first. */
+export function useRuns(owner: string, repo: string, limit = 30) {
return useQuery({
- queryKey: ['repos', owner, repo, 'pipelines', runId],
+ queryKey: ['repos', owner, repo, 'runs', limit],
queryFn: () =>
- api.get(
- `/api/v1/repos/${owner}/${repo}/pipelines/${runId}`,
- pipelineSchema,
+ api.get(
+ `/api/v1/repos/${owner}/${repo}/runs?limit=${limit}`,
+ z.array(runSchema),
),
- enabled: Boolean(owner && repo && runId),
+ enabled: Boolean(owner && repo),
+ refetchInterval: 8_000, // poll while runs may be active
})
}
+
+/** Run detail: run + jobs (each with steps). */
+export function useRunDetail(owner: string, repo: string, runId: number) {
+ return useQuery({
+ queryKey: ['repos', owner, repo, 'runs', runId],
+ queryFn: () =>
+ api.get(
+ `/api/v1/repos/${owner}/${repo}/runs/${runId}`,
+ runDetailSchema,
+ ),
+ enabled: Boolean(owner && repo && runId),
+ refetchInterval: (query) => {
+ const status = query.state.data?.status
+ return status === 'running' || status === 'queued' ? 3_000 : false
+ },
+ })
+}
+
+/** Step-level log chunks for a job. */
+export function useJobLogs(owner: string, repo: string, runId: number, jobId: number) {
+ return useQuery({
+ queryKey: ['repos', owner, repo, 'runs', runId, 'jobs', jobId, 'logs'],
+ queryFn: () =>
+ api.get(
+ `/api/v1/repos/${owner}/${repo}/runs/${runId}/jobs/${jobId}/logs`,
+ stepLogsSchema,
+ ),
+ enabled: Boolean(owner && repo && runId && jobId),
+ refetchInterval: (query) => {
+ // Keep polling only while the job may still be running
+ const hasRunning = query.state.data?.some(s => s.status === 'running')
+ return hasRunning ? 2_000 : false
+ },
+ })
+}
+
+/** Recent pipeline runs across all repos owned by the current user. */
+export function useRecentRuns(limit = 20) {
+ return useQuery({
+ queryKey: ['pipelines', 'runs', limit],
+ queryFn: () =>
+ api.get(`/api/v1/pipelines/runs?limit=${limit}`, recentRunSchema),
+ refetchInterval: 10_000,
+ })
+}
+
+// ── Mutations ─────────────────────────────────────────────────────────────────
+
+export function useCancelRun(owner: string, repo: string) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (runId: number) =>
+ api.post(`/api/v1/repos/${owner}/${repo}/runs/${runId}/cancel`, z.unknown(), undefined),
+ onSuccess: (_data, runId) => {
+ qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs'] })
+ qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs', runId] })
+ qc.invalidateQueries({ queryKey: ['pipelines', 'runs'] })
+ },
+ })
+}
+
+export function useRetryJob(owner: string, repo: string, runId: number) {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (jobId: number) =>
+ api.post(
+ `/api/v1/repos/${owner}/${repo}/runs/${runId}/jobs/${jobId}/retry`,
+ z.unknown(),
+ undefined,
+ ),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs', runId] })
+ qc.invalidateQueries({ queryKey: ['repos', owner, repo, 'runs'] })
+ qc.invalidateQueries({ queryKey: ['pipelines', 'runs'] })
+ },
+ })
+}
+
+// ── Cross-repo recent run type ─────────────────────────────────────────────────
+// Returned by GET /api/v1/pipelines/runs — extends PipelineRun with repo context.
+
+export interface RecentRun extends PipelineRun {
+ repoName: string
+ ownerName: string
+}
+
+const recentRunSchema = z.array(
+ runSchema.extend({
+ repoName: z.string(),
+ ownerName: z.string(),
+ }),
+)
diff --git a/frontend/src/components/ci/PipelineWaterfall.tsx b/frontend/src/components/ci/PipelineWaterfall.tsx
index a463124..3ff32f4 100644
--- a/frontend/src/components/ci/PipelineWaterfall.tsx
+++ b/frontend/src/components/ci/PipelineWaterfall.tsx
@@ -1,135 +1,90 @@
import { cn } from '../../lib/utils'
-import type { Pipeline } from '../../types/api'
+import type { PipelineJob, RunStatus } from '../../types/api'
-interface Stage {
- name: string
- status: Pipeline['status']
- duration?: string
-}
+// ── Status maps ───────────────────────────────────────────────────────────────
-interface PipelineWaterfallProps {
- pipeline: Pipeline
- stages?: Stage[]
-}
-
-const STATUS_COLOR: Record = {
- pending: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
+const STATUS_COLOR: Record = {
+ queued: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
running: '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)]',
+ succeeded: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]',
+ failed: '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 STATUS_DOT: Record = {
- pending: 'bg-[var(--c-subtle)]',
+const STATUS_DOT: Record = {
+ queued: 'bg-[var(--c-subtle)]',
running: 'bg-[var(--c-brand)] animate-pulse',
- success: 'bg-[var(--c-success)]',
- failure: 'bg-[var(--c-danger)]',
+ succeeded: 'bg-[var(--c-success)]',
+ failed: 'bg-[var(--c-danger)]',
cancelled: 'bg-[var(--c-subtle)]',
}
-const STATUS_LABEL: Record = {
- pending: 'Pending',
+const STATUS_LABEL: Record = {
+ queued: 'Queued',
running: 'Running',
- success: 'Passed',
- failure: 'Failed',
+ succeeded: 'Passed',
+ failed: 'Failed',
cancelled: 'Cancelled',
}
-// Default stage breakdown when no stage data is provided
-function defaultStages(status: Pipeline['status']): Stage[] {
- const stages: Array<{ name: string; order: number }> = [
- { name: 'Clone', order: 0 },
- { name: 'Build', order: 1 },
- { name: 'Test', order: 2 },
- { name: 'Deploy', order: 3 },
- ]
- return stages.map((s, i) => ({
- name: s.name,
- status: deriveStageStatus(status, i, stages.length),
- }))
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface PipelineWaterfallProps {
+ /** Jobs from a PipelineRun. Each job has a `needs` JSON array of dependency names. */
+ jobs: PipelineJob[]
+ /** Overall run status — used for the header badge. */
+ runStatus: RunStatus
+ runId: number
+ /** Called when user clicks a job node. */
+ onSelectJob?: (jobId: number) => void
+ selectedJobId?: number | null
}
-function deriveStageStatus(pipelineStatus: Pipeline['status'], idx: number, total: number): Pipeline['status'] {
- if (pipelineStatus === 'success') return 'success'
- if (pipelineStatus === 'pending') return 'pending'
- if (pipelineStatus === 'cancelled') return idx === 0 ? 'cancelled' : 'pending'
- if (pipelineStatus === 'failure') {
- const failAt = Math.floor(total * 0.6)
- if (idx < failAt) return 'success'
- if (idx === failAt) return 'failure'
- return 'pending'
+// ── DAG column builder ────────────────────────────────────────────────────────
+
+function topoColumns(jobs: PipelineJob[]): PipelineJob[][] {
+ const nameToJob = new Map(jobs.map(j => [j.name, j]))
+ const depth = new Map()
+
+ function getDepth(name: string, visited = new Set()): number {
+ if (depth.has(name)) return depth.get(name)!
+ if (visited.has(name)) return 0
+ visited.add(name)
+ const job = nameToJob.get(name)
+ if (!job) return 0
+ let needs: string[] = []
+ try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] }
+ const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
+ depth.set(name, d)
+ return d
}
- // running
- const runAt = Math.floor(total * 0.4)
- if (idx < runAt) return 'success'
- if (idx === runAt) return 'running'
- return 'pending'
+
+ jobs.forEach(j => getDepth(j.name))
+ const maxDepth = Math.max(...Array.from(depth.values()), 0)
+ const cols: PipelineJob[][] = Array.from({ length: maxDepth + 1 }, () => [])
+ jobs.forEach(j => cols[depth.get(j.name) ?? 0].push(j))
+ return cols.filter(c => c.length > 0)
}
-export function PipelineWaterfall({ pipeline, stages }: PipelineWaterfallProps) {
- const resolvedStages = stages ?? defaultStages(pipeline.status)
-
- return (
-
- {/* Pipeline header */}
-
-
-
-
- Pipeline #{pipeline.id}
-
-
- {STATUS_LABEL[pipeline.status]}
-
-
-
{pipeline.ref}
-
-
- {/* Waterfall stages */}
-
- {resolvedStages.map((stage, i) => (
-
- {/* Stage box */}
-
-
- {stage.name}
- {stage.duration && (
- {stage.duration}
- )}
-
-
- {/* Connector arrow (not after last) */}
- {i < resolvedStages.length - 1 && (
-
- )}
-
- ))}
-
-
- )
+function duration(start: string | null, end: string | null): string {
+ if (!start) return ''
+ const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime()
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ return `${Math.floor(s / 60)}m ${s % 60}s`
}
-function StatusIcon({ status }: { status: Pipeline['status'] }) {
- if (status === 'success') {
+// ── Status icon ───────────────────────────────────────────────────────────────
+
+function StatusIcon({ status }: { status: RunStatus }) {
+ if (status === 'succeeded') {
return (
)
}
- if (status === 'failure') {
+ if (status === 'failed') {
return (
@@ -143,9 +98,101 @@ function StatusIcon({ status }: { status: Pipeline['status'] }) {
)
}
+ if (status === 'cancelled') {
+ return (
+
+
+
+ )
+ }
+ // queued
return (
)
}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function PipelineWaterfall({
+ jobs,
+ runStatus,
+ runId,
+ onSelectJob,
+ selectedJobId,
+}: PipelineWaterfallProps) {
+ const columns = topoColumns(jobs)
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Run #{runId}
+
+
+ {STATUS_LABEL[runStatus]}
+
+
+
+ {jobs.length} job{jobs.length !== 1 ? 's' : ''}
+
+
+
+ {/* DAG waterfall */}
+ {columns.length === 0 ? (
+
No jobs.
+ ) : (
+
+ {columns.map((col, colIdx) => (
+
+ {/* Column */}
+
+ {col.map(job => {
+ const status = (job.status as RunStatus) || 'queued'
+ const isSelected = selectedJobId === job.id
+ return (
+ onSelectJob?.(job.id)}
+ className={cn(
+ 'flex flex-col items-center justify-center px-4 py-3 rounded border text-center min-w-[88px] transition-all',
+ STATUS_COLOR[status],
+ onSelectJob ? 'cursor-pointer' : 'cursor-default',
+ isSelected ? 'ring-2 ring-[var(--c-brand)] ring-offset-1' : '',
+ )}
+ >
+
+ {job.name}
+ {(job.startedAt || job.finishedAt) && (
+
+ {duration(job.startedAt, job.finishedAt)}
+
+ )}
+
+ )
+ })}
+
+
+ {/* Connector (not after last column) */}
+ {colIdx < columns.length - 1 && (
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
index 416454b..b8193cc 100644
--- a/frontend/src/pages/DashboardPage.tsx
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -3,10 +3,11 @@ import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useDashboard } from '../api/queries/dashboard'
import { useRepos } from '../api/queries/repos'
+import { useRecentRuns } from '../api/queries/pipelines'
import { useRecentRepos } from '../hooks/useRecentRepos'
import { Skeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
-import type { DashPR, DashIssue, DashRepo } from '../api/queries/dashboard'
+import type { DashPR, DashIssue, DashRepo, DashRun } from '../api/queries/dashboard'
// ── Utilities ─────────────────────────────────────────────────────────────────
@@ -31,7 +32,7 @@ function greeting(username?: string): string {
// ── Command palette ────────────────────────────────────────────────────────────
interface CmdResult {
- type: 'repo' | 'pr' | 'issue'
+ type: 'repo' | 'pr' | 'issue' | 'run'
label: string
sub: string
href: string
@@ -40,6 +41,7 @@ interface CmdResult {
function CommandPalette() {
const { data: dash } = useDashboard()
const { data: repos = [] } = useRepos()
+ const { data: recentRuns = [] } = useRecentRuns(20)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const inputRef = useRef(null)
@@ -73,6 +75,19 @@ function CommandPalette() {
.filter(i => i.title.toLowerCase().includes(q.toLowerCase()))
.slice(0, 3)
.map(i => ({ type: 'issue' as const, label: i.title, sub: `${i.ownerName}/${i.repoName} · #${i.number}`, href: `/repos/${i.ownerName}/${i.repoName}/issues` })),
+ ...recentRuns
+ .filter(r =>
+ r.repoName.toLowerCase().includes(q.toLowerCase()) ||
+ r.triggerRef.toLowerCase().includes(q.toLowerCase()) ||
+ r.triggerSha.startsWith(q.toLowerCase()),
+ )
+ .slice(0, 3)
+ .map(r => ({
+ type: 'run' as const,
+ label: `${r.repoName} #${r.id}`,
+ sub: `${r.triggerRef.replace('refs/heads/', '')} · ${r.status} · ${r.triggerSha.slice(0, 7)}`,
+ href: `/repos/${r.ownerName}/${r.repoName}/runs/${r.id}`,
+ })),
]
: []
@@ -96,6 +111,11 @@ function CommandPalette() {
)
+ if (t === 'run') return (
+
+
+
+ )
return (
@@ -110,7 +130,7 @@ function CommandPalette() {
- Search repos, PRs, issues…
+ Search repos, PRs, issues, pipelines…
⌘ K
@@ -126,7 +146,7 @@ function CommandPalette() {
setQ(e.target.value)} onKeyDown={onKey}
- placeholder="Search repos, PRs, issues…"
+ placeholder="Search repos, PRs, issues, pipelines…"
className="flex-1 bg-transparent text-sm text-[var(--c-text)] placeholder:text-[var(--c-muted)] focus:outline-none" />
esc
@@ -154,6 +174,7 @@ function CommandPalette() {
{[
{ label: 'My repos', href: '/repos' },
{ label: 'Open PRs', href: '/pulls' },
+ { label: 'Pipelines', href: '/pipelines' },
{ label: 'Issues', href: '/issues' },
{ label: 'Explore', href: '/explore' },
{ label: 'Settings', href: '/settings' },
@@ -300,6 +321,47 @@ function RepoCard({ repo }: { repo: DashRepo }) {
)
}
+// ── CI run row ────────────────────────────────────────────────────────────────
+
+const RUN_DOT: Record = {
+ queued: 'bg-[var(--c-subtle)]',
+ running: 'bg-[var(--c-brand)] animate-pulse',
+ succeeded: 'bg-[var(--c-success)]',
+ failed: 'bg-[var(--c-danger)]',
+ cancelled: 'bg-[var(--c-subtle)]',
+}
+
+const RUN_LABEL: Record = {
+ queued: 'Queued',
+ running: 'Running',
+ succeeded: 'Passed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+}
+
+function CIRunRow({ run }: { run: DashRun }) {
+ const branch = run.triggerRef.replace('refs/heads/', '').replace('refs/tags/', '')
+ const sha = run.triggerSha.slice(0, 7)
+ return (
+
+
+
+
+
+ {run.repoName}
+
+ {branch}
+
+
{sha}
+
+
+ {RUN_LABEL[run.status] ?? run.status}
+
+
+ )
+}
+
// ── Empty state ───────────────────────────────────────────────────────────────
function Empty({ message, action }: { message: string; action?: React.ReactNode }) {
@@ -502,7 +564,7 @@ export default function DashboardPage() {
)}
- {/* CI/CD — placeholder until pipelines are implemented */}
+ {/* CI / CD — live recent runs */}
}
+ action={
+ All runs
+ }
>
-
-
-
-
Pipeline integration coming soon.
-
View pipelines →
-
-
+ {isLoading ? : (
+
+ {!dash?.recentRuns?.length ? (
+
+ Push to a repo with a .forgebucket/workflows/ file.
+
+ }
+ />
+ ) : (
+ dash.recentRuns.map(run => )
+ )}
+
+ )}
{/* Quick actions */}
diff --git a/frontend/src/pages/PipelineRunPage.tsx b/frontend/src/pages/PipelineRunPage.tsx
new file mode 100644
index 0000000..d7579b5
--- /dev/null
+++ b/frontend/src/pages/PipelineRunPage.tsx
@@ -0,0 +1,416 @@
+import { useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import { useRunDetail, useJobLogs, useCancelRun, useRetryJob } from '../api/queries/pipelines'
+import { Skeleton } from '../ui/Skeleton'
+import { cn } from '../lib/utils'
+import type { PipelineJob, PipelineStep, RunStatus } from '../types/api'
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
+function timeAgo(iso: string | null): string {
+ if (!iso) return '—'
+ 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 duration(start: string | null, end: string | null): string {
+ if (!start) return '—'
+ const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime()
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ const m = Math.floor(s / 60)
+ return `${m}m ${s % 60}s`
+}
+
+function shortRef(ref: string): string {
+ return ref.replace('refs/heads/', '').replace('refs/tags/', '')
+}
+
+function shortSHA(sha: string): string {
+ return sha.slice(0, 7)
+}
+
+// ── Status helpers ─────────────────────────────────────────────────────────────
+
+const STATUS_DOT: Record = {
+ queued: 'bg-[var(--c-subtle)]',
+ running: 'bg-[var(--c-brand)] animate-pulse',
+ succeeded: 'bg-[var(--c-success)]',
+ failed: 'bg-[var(--c-danger)]',
+ cancelled: 'bg-[var(--c-subtle)]',
+}
+
+const STATUS_BADGE: Record = {
+ queued: 'bg-[var(--c-surface-muted)] border-[var(--c-border)] text-[var(--c-muted)]',
+ running: 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)] text-[var(--c-brand)]',
+ succeeded: 'bg-[#E3FCEF] border-[#79F2C0] text-[#006644]',
+ failed: '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 STATUS_LABEL: Record = {
+ queued: 'Queued',
+ running: 'Running',
+ succeeded: 'Passed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+}
+
+function StatusBadge({ status, size = 'md' }: { status: RunStatus; size?: 'sm' | 'md' }) {
+ return (
+
+
+ {STATUS_LABEL[status]}
+
+ )
+}
+
+// ── DAG visualization ─────────────────────────────────────────────────────────
+
+type JobWithSteps = PipelineJob & { steps: PipelineStep[] }
+
+interface DAGProps {
+ jobs: JobWithSteps[]
+ selectedJobId: number | null
+ onSelectJob: (id: number) => void
+}
+
+function DAGView({ jobs, selectedJobId, onSelectJob }: DAGProps) {
+ // Build dependency columns using topological sort
+ const columns = topoColumns(jobs)
+
+ if (jobs.length === 0) {
+ return (
+
+ No jobs in this run.
+
+ )
+ }
+
+ return (
+
+
+ {columns.map((col, colIdx) => (
+
+ {/* Column of jobs */}
+
+ {col.map(job => (
+
onSelectJob(job.id)}
+ className={cn(
+ 'flex flex-col gap-1.5 px-4 py-3 rounded-lg border text-left min-w-[140px] transition-all',
+ selectedJobId === job.id
+ ? 'ring-2 ring-[var(--c-brand)] border-[var(--c-brand-focus)]'
+ : 'hover:border-[var(--c-brand-focus)]',
+ STATUS_BADGE[job.status as RunStatus] || STATUS_BADGE.queued,
+ )}
+ >
+
+
+ {job.name}
+
+
+ {duration(job.startedAt, job.finishedAt)}
+
+ {job.image}
+
+ ))}
+
+
+ {/* Connector (not after last column) */}
+ {colIdx < columns.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ )
+}
+
+function JobStatusIcon({ status }: { status: RunStatus }) {
+ if (status === 'succeeded') {
+ return
+ }
+ if (status === 'failed') {
+ return
+ }
+ if (status === 'running') {
+ return
+ }
+ if (status === 'cancelled') {
+ return
+ }
+ // queued
+ return
+}
+
+/** Arrange jobs into columns by dependency depth (0 = no deps). */
+function topoColumns(jobs: JobWithSteps[]): JobWithSteps[][] {
+ const nameToJob = new Map(jobs.map(j => [j.name, j]))
+ const depth = new Map()
+
+ function getDepth(name: string, visited = new Set()): number {
+ if (depth.has(name)) return depth.get(name)!
+ if (visited.has(name)) return 0
+ visited.add(name)
+ const job = nameToJob.get(name)
+ if (!job) return 0
+ let needs: string[] = []
+ try { needs = JSON.parse(job.needs || '[]') } catch { needs = [] }
+ const d = needs.length === 0 ? 0 : 1 + Math.max(...needs.map(n => getDepth(n, new Set(visited))))
+ depth.set(name, d)
+ return d
+ }
+
+ jobs.forEach(j => getDepth(j.name))
+ const maxDepth = Math.max(...Array.from(depth.values()), 0)
+ const cols: JobWithSteps[][] = Array.from({ length: maxDepth + 1 }, () => [])
+ jobs.forEach(j => cols[depth.get(j.name) ?? 0].push(j))
+ return cols.filter(c => c.length > 0)
+}
+
+// ── Log viewer ─────────────────────────────────────────────────────────────────
+
+function LogViewer({ owner, repo, runId, jobId }: {
+ owner: string; repo: string; runId: number; jobId: number
+}) {
+ const { data, isLoading } = useJobLogs(owner, repo, runId, jobId)
+ const [expandedSteps, setExpandedSteps] = useState>(new Set([0]))
+
+ if (isLoading) {
+ return (
+
+ {[1, 2, 3].map(i => )}
+
+ )
+ }
+ if (!data || data.length === 0) {
+ return No steps recorded for this job.
+ }
+
+ function toggle(seq: number) {
+ setExpandedSteps(prev => {
+ const next = new Set(prev)
+ if (next.has(seq)) next.delete(seq)
+ else next.add(seq)
+ return next
+ })
+ }
+
+ return (
+
+ {data.map(step => {
+ const open = expandedSteps.has(step.seq)
+ const logText = step.logs.map(l => l.content).join('')
+ return (
+
+
toggle(step.seq)}
+ className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-[var(--c-surface-muted)] transition-colors"
+ >
+ {/* Chevron */}
+
+
+
+
+
+ {step.name || step.runCmd || step.usesAction || `Step ${step.seq + 1}`}
+
+
+ {duration(step.startedAt, step.finishedAt)}
+
+ {step.exitCode !== 0 && step.status === 'failed' && (
+
+ exit {step.exitCode}
+
+ )}
+
+ {open && (
+
+ {logText ? (
+
+ {logText}
+
+ ) : (
+
No output captured.
+ )}
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+// ── Main page ─────────────────────────────────────────────────────────────────
+
+export default function PipelineRunPage() {
+ const { owner = '', repo = '', runId = '' } = useParams()
+ const runIdNum = parseInt(runId, 10)
+
+ const { data: run, isLoading, error } = useRunDetail(owner, repo, runIdNum)
+ const cancelRun = useCancelRun(owner, repo)
+ const retryJob = useRetryJob(owner, repo, runIdNum)
+
+ const [selectedJobId, setSelectedJobId] = useState(null)
+
+ // Auto-select first job once data loads
+ const jobs = run?.jobs ?? []
+ const effectiveJobId = selectedJobId ?? jobs[0]?.id ?? null
+ const selectedJob = jobs.find(j => j.id === effectiveJobId) ?? null
+
+ if (error) {
+ return (
+
+
Run not found.
+
+ ← Back to repository
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Breadcrumb */}
+
+
+ {owner}/{repo}
+
+ /
+
+ Runs
+
+ /
+ #{runId}
+
+
+ {/* Run header */}
+ {isLoading ? (
+
+
+
+
+ ) : run ? (
+
+
+
+
+
+ Run #{run.id}
+
+
+
+
+
+
+
+
+ {shortRef(run.triggerRef)}
+
+
{shortSHA(run.triggerSha)}
+
triggered by {run.triggeredBy}
+
{timeAgo(run.createdAt)}
+ {run.startedAt && (
+
duration: {duration(run.startedAt, run.finishedAt)}
+ )}
+
+
+
+ {/* Actions */}
+
+ {(run.status === 'running' || run.status === 'queued') && (
+
cancelRun.mutate(run.id)}
+ disabled={cancelRun.isPending}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:border-[var(--c-danger)] hover:text-[var(--c-danger)] transition-colors disabled:opacity-50"
+ >
+
+
+
+ Cancel
+
+ )}
+
+
+
+ ) : null}
+
+ {/* DAG + log viewer */}
+
+
+ {/* DAG section */}
+
+
+ Jobs
+
+
+ {isLoading ? (
+
+ {[1, 2, 3].map(i => )}
+
+ ) : (
+
setSelectedJobId(id)}
+ />
+ )}
+
+
+
+ {/* Log viewer for selected job */}
+ {effectiveJobId !== null && (
+
+
+
+ {selectedJob ? `Logs — ${selectedJob.name}` : 'Logs'}
+
+
+ {selectedJob && (
+
+ )}
+ {selectedJob && (selectedJob.status === 'failed' || selectedJob.status === 'cancelled') && (
+
retryJob.mutate(effectiveJobId)}
+ disabled={retryJob.isPending}
+ className="flex items-center gap-1 px-2.5 py-1 text-[10px] font-medium border border-[var(--c-border)] rounded-lg text-[var(--c-text)] hover:border-[var(--c-brand-focus)] hover:text-[var(--c-brand)] transition-colors disabled:opacity-50"
+ >
+
+
+
+ Retry
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/PipelinesPage.tsx b/frontend/src/pages/PipelinesPage.tsx
index c46dda8..8a74b3d 100644
--- a/frontend/src/pages/PipelinesPage.tsx
+++ b/frontend/src/pages/PipelinesPage.tsx
@@ -1,19 +1,248 @@
-export default function PipelinesPage() {
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useRecentRuns } from '../api/queries/pipelines'
+import { Skeleton } from '../ui/Skeleton'
+import { cn } from '../lib/utils'
+import type { RunStatus } from '../types/api'
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
+function timeAgo(iso: string): string {
+ 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 duration(start: string | null, end: string | null): string {
+ if (!start) return '—'
+ const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime()
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ return `${Math.floor(s / 60)}m ${s % 60}s`
+}
+
+function shortRef(ref: string): string {
+ return ref.replace('refs/heads/', '').replace('refs/tags/', '')
+}
+
+function shortSHA(sha: string): string {
+ return sha.slice(0, 7)
+}
+
+// ── Status helpers ─────────────────────────────────────────────────────────────
+
+const STATUS_DOT: Record = {
+ queued: 'bg-[var(--c-subtle)]',
+ running: 'bg-[var(--c-brand)] animate-pulse',
+ succeeded: 'bg-[var(--c-success)]',
+ failed: 'bg-[var(--c-danger)]',
+ cancelled: 'bg-[var(--c-subtle)]',
+}
+
+const STATUS_LABEL: Record = {
+ queued: 'Queued',
+ running: 'Running',
+ succeeded: 'Passed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+}
+
+const STATUS_TEXT: Record = {
+ queued: 'text-[var(--c-muted)]',
+ running: 'text-[var(--c-brand)]',
+ succeeded: 'text-[var(--c-success)]',
+ failed: 'text-[var(--c-danger)]',
+ cancelled: 'text-[var(--c-muted)]',
+}
+
+// ── Row ───────────────────────────────────────────────────────────────────────
+
+function RunRow({ run }: {
+ run: {
+ id: number
+ repoName: string
+ ownerName: string
+ triggerRef: string
+ triggerSha: string
+ triggeredBy: string
+ status: string
+ startedAt: string | null
+ finishedAt: string | null
+ createdAt: string
+ }
+}) {
+ const status = (run.status as RunStatus) || 'queued'
+
return (
-
-
Pipelines
-
-
-
-
-
-
No pipelines yet
-
- Pipelines run automatically when you push to a repository.
- Add a .forgebucket.yml file to get started.
-
+
+ {/* Status dot */}
+
+
+ {/* Repo + branch */}
+
+
+
+ {run.ownerName}/{run.repoName}
+
+
+ {STATUS_LABEL[status]}
+
+
+ {shortRef(run.triggerRef)}
+ {shortSHA(run.triggerSha)}
+ by {run.triggeredBy}
+
+
+
+ {/* Duration + time */}
+
+
+ {duration(run.startedAt, run.finishedAt)}
+
+
+ {timeAgo(run.createdAt)}
+
+
+
+ {/* Arrow */}
+
+
+
+
+ )
+}
+
+// ── Row skeleton ──────────────────────────────────────────────────────────────
+
+function RunRowSkeleton() {
+ return (
+
)
}
+
+// ── Filter bar ────────────────────────────────────────────────────────────────
+
+type FilterStatus = 'all' | RunStatus
+
+const FILTERS: { label: string; value: FilterStatus }[] = [
+ { label: 'All', value: 'all' },
+ { label: 'Running', value: 'running' },
+ { label: 'Failed', value: 'failed' },
+ { label: 'Passed', value: 'succeeded' },
+ { label: 'Queued', value: 'queued' },
+]
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export default function PipelinesPage() {
+ const { data: runs, isLoading } = useRecentRuns(50)
+
+ // Simple client-side filter — real pagination could be added later
+ const [filter, setFilter] = useState
('all')
+
+ const filtered = runs?.filter(r => filter === 'all' || r.status === filter) ?? []
+
+ const runningCount = runs?.filter(r => r.status === 'running').length ?? 0
+ const failedCount = runs?.filter(r => r.status === 'failed').length ?? 0
+
+ return (
+
+
+ {/* Header */}
+
+
+
Pipelines
+
+ Recent runs across all your repositories
+
+
+
+ {/* Live indicators */}
+ {!isLoading && (runningCount > 0 || failedCount > 0) && (
+
+ {runningCount > 0 && (
+
+
+ {runningCount} running
+
+ )}
+ {failedCount > 0 && (
+
+
+ {failedCount} failed
+
+ )}
+
+ )}
+
+
+ {/* Filter tabs */}
+
+ {FILTERS.map(f => (
+ setFilter(f.value)}
+ className={cn(
+ 'px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
+ filter === f.value
+ ? 'border-[var(--c-brand)] text-[var(--c-brand)]'
+ : 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
+ )}
+ >
+ {f.label}
+ {f.value !== 'all' && !isLoading && runs && (
+
+ {runs.filter(r => r.status === f.value).length}
+
+ )}
+
+ ))}
+
+
+ {/* Runs list */}
+
+ {isLoading ? (
+ Array.from({ length: 6 }).map((_, i) =>
)
+ ) : filtered.length === 0 ? (
+
+
+
+
+
+
+ {filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`}
+
+
+ {filter === 'all'
+ ? <>Push to a repository with a .forgebucket/workflows/*.yml file to trigger a run.>
+ : 'Try a different filter above.'}
+
+
+
+ ) : (
+ filtered.map(run =>
)
+ )}
+
+
+ )
+}
+
+
diff --git a/frontend/src/pages/RepoPipelinesPage.tsx b/frontend/src/pages/RepoPipelinesPage.tsx
new file mode 100644
index 0000000..a10dc3e
--- /dev/null
+++ b/frontend/src/pages/RepoPipelinesPage.tsx
@@ -0,0 +1,239 @@
+import { useState } from 'react'
+import { useParams, Link } from 'react-router-dom'
+import { useRuns } from '../api/queries/pipelines'
+import { Skeleton } from '../ui/Skeleton'
+import { cn } from '../lib/utils'
+import type { RunStatus } from '../types/api'
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
+function timeAgo(iso: string): string {
+ 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 duration(start: string | null, end: string | null): string {
+ if (!start) return '—'
+ const ms = new Date(end ?? Date.now()).getTime() - new Date(start).getTime()
+ const s = Math.floor(ms / 1000)
+ if (s < 60) return `${s}s`
+ return `${Math.floor(s / 60)}m ${s % 60}s`
+}
+
+function shortRef(ref: string): string {
+ return ref.replace('refs/heads/', '').replace('refs/tags/', '')
+}
+
+function shortSHA(sha: string): string {
+ return sha.slice(0, 7)
+}
+
+// ── Status helpers ─────────────────────────────────────────────────────────────
+
+const STATUS_DOT: Record = {
+ queued: 'bg-[var(--c-subtle)]',
+ running: 'bg-[var(--c-brand)] animate-pulse',
+ succeeded: 'bg-[var(--c-success)]',
+ failed: 'bg-[var(--c-danger)]',
+ cancelled: 'bg-[var(--c-subtle)]',
+}
+
+const STATUS_LABEL: Record = {
+ queued: 'Queued',
+ running: 'Running',
+ succeeded: 'Passed',
+ failed: 'Failed',
+ cancelled: 'Cancelled',
+}
+
+const STATUS_TEXT: Record = {
+ queued: 'text-[var(--c-muted)]',
+ running: 'text-[var(--c-brand)]',
+ succeeded: 'text-[var(--c-success)]',
+ failed: 'text-[var(--c-danger)]',
+ cancelled: 'text-[var(--c-muted)]',
+}
+
+// ── Row ───────────────────────────────────────────────────────────────────────
+
+function RunRow({ run, owner, repo }: {
+ run: {
+ id: number
+ triggerRef: string
+ triggerSha: string
+ triggeredBy: string
+ status: string
+ startedAt: string | null
+ finishedAt: string | null
+ createdAt: string
+ }
+ owner: string
+ repo: string
+}) {
+ const status = (run.status as RunStatus) || 'queued'
+
+ return (
+
+
+
+
+
+
+ Run #{run.id}
+
+
+ {STATUS_LABEL[status]}
+
+
+
+ {shortRef(run.triggerRef)}
+ {shortSHA(run.triggerSha)}
+ by {run.triggeredBy}
+
+
+
+
+
+ {duration(run.startedAt, run.finishedAt)}
+
+
+ {timeAgo(run.createdAt)}
+
+
+
+
+
+
+
+ )
+}
+
+function RunRowSkeleton() {
+ return (
+
+ )
+}
+
+// ── Filter tabs ───────────────────────────────────────────────────────────────
+
+type FilterStatus = 'all' | RunStatus
+
+const FILTERS: { label: string; value: FilterStatus }[] = [
+ { label: 'All', value: 'all' },
+ { label: 'Running', value: 'running' },
+ { label: 'Failed', value: 'failed' },
+ { label: 'Passed', value: 'succeeded' },
+ { label: 'Queued', value: 'queued' },
+]
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export default function RepoPipelinesPage() {
+ const { owner = '', repo = '' } = useParams()
+ const { data: runs, isLoading } = useRuns(owner, repo, 50)
+ const [filter, setFilter] = useState('all')
+
+ const filtered = runs?.filter(r => filter === 'all' || r.status === filter) ?? []
+ const runningCount = runs?.filter(r => r.status === 'running').length ?? 0
+ const failedCount = runs?.filter(r => r.status === 'failed').length ?? 0
+
+ return (
+
+
+ {/* Header */}
+
+
+
Pipelines
+
+ Pipeline runs for {owner}/{repo}
+
+
+
+ {!isLoading && (runningCount > 0 || failedCount > 0) && (
+
+ {runningCount > 0 && (
+
+
+ {runningCount} running
+
+ )}
+ {failedCount > 0 && (
+
+
+ {failedCount} failed
+
+ )}
+
+ )}
+
+
+ {/* Filter tabs */}
+
+ {FILTERS.map(f => (
+ setFilter(f.value)}
+ className={cn(
+ 'px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
+ filter === f.value
+ ? 'border-[var(--c-brand)] text-[var(--c-brand)]'
+ : 'border-transparent text-[var(--c-muted)] hover:text-[var(--c-text)]',
+ )}
+ >
+ {f.label}
+ {f.value !== 'all' && !isLoading && runs && (
+
+ {runs.filter(r => r.status === f.value).length}
+
+ )}
+
+ ))}
+
+
+ {/* Runs list */}
+
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, i) =>
)
+ ) : filtered.length === 0 ? (
+
+
+
+
+
+
+ {filter === 'all' ? 'No pipeline runs yet' : `No ${STATUS_LABEL[filter as RunStatus]?.toLowerCase()} runs`}
+
+
+ {filter === 'all' ? (
+ <>Push to this repository with a .forgebucket/workflows/*.yml file to trigger a run.>
+ ) : (
+ 'Try a different filter above.'
+ )}
+
+
+
+ ) : (
+ filtered.map(run =>
)
+ )}
+
+
+ )
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 00b9383..380830c 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -66,15 +66,76 @@ export interface Commit {
date: string
}
+// Pipeline is a workflow definition file stored in the repository.
export interface Pipeline {
id: number
repoId: number
- ref: string
- status: 'pending' | 'running' | 'success' | 'failure' | 'cancelled'
+ name: string
+ filePath: string
createdAt: string
updatedAt: string
}
+export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
+
+// PipelineRun is a single execution triggered by a push.
+export interface PipelineRun {
+ id: number
+ pipelineId: number
+ repoId: number
+ triggerRef: string // e.g. refs/heads/main
+ triggerSha: string // 40-char commit SHA
+ triggeredBy: string // username
+ status: RunStatus
+ startedAt: string | null
+ finishedAt: string | null
+ createdAt: string
+}
+
+// PipelineJob is a single node in the run DAG.
+export interface PipelineJob {
+ id: number
+ runId: number
+ name: string
+ image: string
+ needs: string // JSON array of dependency job names: '["build","test"]'
+ status: RunStatus
+ startedAt: string | null
+ finishedAt: string | null
+ createdAt: string
+}
+
+// PipelineStep is a single command within a job.
+export interface PipelineStep {
+ id: number
+ jobId: number
+ seq: number
+ name: string
+ runCmd: string
+ usesAction: string
+ status: RunStatus
+ exitCode: number
+ startedAt: string | null
+ finishedAt: string | null
+}
+
+// PipelineStepLog is one append-only log chunk for a step.
+export interface PipelineStepLog {
+ id: number
+ stepId: number
+ chunkIndex: number
+ content: string
+ createdAt: string
+}
+
+// RunDetail is what GET /runs/:runId returns — run + jobs with steps.
+export interface RunDetail extends PipelineRun {
+ jobs: Array
+}
+
+// StepLogs is what GET /jobs/:jobId/logs returns.
+export type StepLogs = Array
+
export type IssueState = 'open' | 'closed'
export interface Issue {
diff --git a/internal/api/handlers/dashboard.go b/internal/api/handlers/dashboard.go
index 25f093d..a7bccd2 100644
--- a/internal/api/handlers/dashboard.go
+++ b/internal/api/handlers/dashboard.go
@@ -61,12 +61,27 @@ type dashRepo struct {
OpenIssueCount int `json:"openIssueCount"`
}
+type dashRun struct {
+ ID int64 `json:"id"`
+ RepoID int64 `json:"repoId"`
+ RepoName string `json:"repoName"`
+ OwnerName string `json:"ownerName"`
+ TriggerRef string `json:"triggerRef"`
+ TriggerSHA string `json:"triggerSha"`
+ TriggeredBy string `json:"triggeredBy"`
+ Status string `json:"status"`
+ StartedAt *string `json:"startedAt"`
+ FinishedAt *string `json:"finishedAt"`
+ CreatedAt string `json:"createdAt"`
+}
+
type dashboardResponse struct {
Stats dashStats `json:"stats"`
ReviewQueue []dashPR `json:"reviewQueue"`
MyOpenPRs []dashPR `json:"myOpenPRs"`
MyOpenIssues []dashIssue `json:"myOpenIssues"`
Repos []dashRepo `json:"repos"`
+ RecentRuns []dashRun `json:"recentRuns"`
}
// ── Handler ───────────────────────────────────────────────────────────────────
@@ -211,6 +226,36 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
})
}
+ // 7. Recent CI runs across user repos.
+ var recentRuns []models.PipelineRun
+ if len(repoIDs) > 0 {
+ h.db.In("repo_id", repoIDs).Desc("id").Limit(5).Find(&recentRuns)
+ }
+ runsDash := make([]dashRun, 0, len(recentRuns))
+ for _, run := range recentRuns {
+ rp := repoByID[run.RepoID]
+ dr := dashRun{
+ ID: run.ID,
+ RepoID: run.RepoID,
+ RepoName: rp.Name,
+ OwnerName: owner.Username,
+ TriggerRef: run.TriggerRef,
+ TriggerSHA: run.TriggerSHA,
+ TriggeredBy: run.TriggeredBy,
+ Status: run.Status,
+ CreatedAt: run.CreatedAt.Format("2006-01-02T15:04:05Z"),
+ }
+ if run.StartedAt != nil {
+ s := run.StartedAt.Format("2006-01-02T15:04:05Z")
+ dr.StartedAt = &s
+ }
+ if run.FinishedAt != nil {
+ f := run.FinishedAt.Format("2006-01-02T15:04:05Z")
+ dr.FinishedAt = &f
+ }
+ runsDash = append(runsDash, dr)
+ }
+
resp := dashboardResponse{
Stats: dashStats{
RepoCount: len(repos),
@@ -222,6 +267,7 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
MyOpenPRs: myPRDash,
MyOpenIssues: issueDash,
Repos: dashRepos,
+ RecentRuns: runsDash,
}
jsonOK(w, resp)
}
diff --git a/internal/api/handlers/pipelines.go b/internal/api/handlers/pipelines.go
index 6677e5d..4df7822 100644
--- a/internal/api/handlers/pipelines.go
+++ b/internal/api/handlers/pipelines.go
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
+ "github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
@@ -19,6 +20,59 @@ func NewPipelineHandler(db *xorm.Engine) *PipelineHandler {
return &PipelineHandler{db: db}
}
+// recentRunResponse extends PipelineRun with repo context for the global feed.
+type recentRunResponse struct {
+ models.PipelineRun
+ RepoName string `json:"repoName"`
+ OwnerName string `json:"ownerName"`
+}
+
+// ListRecentRuns returns recent runs across all repos owned by the current user.
+// GET /api/v1/pipelines/runs
+func (h *PipelineHandler) ListRecentRuns(w http.ResponseWriter, r *http.Request) {
+ userID, _ := middleware.UserIDFromContext(r.Context())
+
+ limit := 30
+ if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 {
+ limit = l
+ }
+
+ // Repos owned by this user.
+ var repos []models.Repository
+ h.db.Where("owner_id = ?", userID).Cols("id", "name").Find(&repos)
+ if len(repos) == 0 {
+ jsonOK(w, []recentRunResponse{})
+ return
+ }
+
+ repoIDs := make([]int64, len(repos))
+ repoNameByID := make(map[int64]string, len(repos))
+ for i, rp := range repos {
+ repoIDs[i] = rp.ID
+ repoNameByID[rp.ID] = rp.Name
+ }
+
+ // Owner username.
+ var owner models.User
+ h.db.ID(userID).Cols("username").Get(&owner)
+
+ var runs []models.PipelineRun
+ h.db.In("repo_id", repoIDs).Desc("id").Limit(limit).Find(&runs)
+ if runs == nil {
+ runs = []models.PipelineRun{}
+ }
+
+ result := make([]recentRunResponse, len(runs))
+ for i, run := range runs {
+ result[i] = recentRunResponse{
+ PipelineRun: run,
+ RepoName: repoNameByID[run.RepoID],
+ OwnerName: owner.Username,
+ }
+ }
+ jsonOK(w, result)
+}
+
// ListPipelines returns all pipeline definitions for a repository.
func (h *PipelineHandler) ListPipelines(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoID(w, r)
diff --git a/internal/api/router.go b/internal/api/router.go
index fa56bee..cd17a8b 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -104,6 +104,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.Get("/me", userH.Me)
r.Get("/dashboard", dashH.Get)
r.Get("/audit", auditH.List)
+ r.Get("/pipelines/runs", pipeH.ListRecentRuns)
r.Route("/admin", func(r chi.Router) {
r.Get("/runners", runnerH.List)