implemented unified operational timeline. situational awareness 'what changed before it broke?'

This commit is contained in:
2026-05-11 23:02:40 +02:00
parent 24bf4706e1
commit 06e96ba16a
10 changed files with 652 additions and 6 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ Understand the phases before adding code — don't build Phase 3 infrastructure
| 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 | **Complete** |
| 3A | Environment model + deployment tracking | **Active** |
| 3B | Unified operational timeline | Planned |
| 3A | Environment model + deployment tracking | **Complete** |
| 3B | Unified operational timeline | **Active** |
| 3C | Secret management hierarchy | Planned |
| 3D | GitOps controller + drift detection | Planned |
| 3E | Observability (Prometheus, health sparklines) | Planned |
+7 -1
View File
@@ -9,7 +9,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### In Progress — Phase 3A (Environment model + deployment tracking)
### In Progress — Phase 3B (Unified Operational Timeline)
- `GET /api/v1/repos/:owner/:repo/timeline` — merges commits, pipeline runs, and deployments into a single chronological feed
- `RepoTimelinePage` at `/repos/:owner/:repo/timeline` — vertical event feed with type filter tabs
- Sidebar "Timeline" nav item between Environments and Settings
- Event types: commit (SHA, message, author), run (status, ref, duration), deployment (env, status, SHA)
### Completed — 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
+3 -2
View File
@@ -222,8 +222,9 @@ ForgeBucket has its own design language — intentionally distinct from GitHub a
| 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 | Done |
| Phase 3A | Environment model + deployment tracking | **In progress** |
| Phase 3BF | Unified timeline, secrets, drift detection, federation, observability | Planned |
| Phase 3A | Environment model + deployment tracking | Done |
| Phase 3B | Unified operational timeline | **In progress** |
| Phase 3CF | Secrets, drift detection, federation, observability | Planned |
| Phase 4 | AI diagnostics, signed artifacts, OCI registry, dep scanning | Planned |
---
+2
View File
@@ -38,6 +38,7 @@ 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 RepoTimelinePage = lazy(() => import('./pages/RepoTimelinePage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
@@ -87,6 +88,7 @@ export default function App() {
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="repos/:owner/:repo/pipelines" element={<S><RepoPipelinesPage /></S>} />
<Route path="repos/:owner/:repo/environments" element={<S><EnvironmentsPage /></S>} />
<Route path="repos/:owner/:repo/timeline" element={<S><RepoTimelinePage /></S>} />
<Route path="repos/:owner/:repo/runs/:runId" element={<S><PipelineRunPage /></S>} />
<Route path="starred" element={<S><StarredPage /></S>} />
+58
View File
@@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { TimelineEvent } from '../../types/api'
const commitEventSchema = z.object({
type: z.literal('commit'),
timestamp: z.string(),
sha: z.string(),
message: z.string(),
author: z.string(),
})
const runEventSchema = z.object({
type: z.literal('run'),
timestamp: z.string(),
runId: z.number(),
triggerRef: z.string(),
triggerSha: z.string(),
triggeredBy: z.string(),
runStatus: z.string(),
startedAt: z.string().nullable().optional(),
finishedAt: z.string().nullable().optional(),
})
const deploymentEventSchema = z.object({
type: z.literal('deployment'),
timestamp: z.string(),
deploymentId: z.number(),
envName: z.string(),
deployStatus: z.string(),
deployedSha: z.string(),
deployRef: z.string(),
triggeredBy: z.string(),
description: z.string(),
runLink: z.number().nullable().optional(),
})
const timelineEventSchema = z.discriminatedUnion('type', [
commitEventSchema,
runEventSchema,
deploymentEventSchema,
])
const timelineSchema = z.array(timelineEventSchema)
export function useTimeline(owner: string, repo: string, limit = 60) {
return useQuery({
queryKey: ['repos', owner, repo, 'timeline', limit],
queryFn: () =>
api.get<TimelineEvent[]>(
`/api/v1/repos/${owner}/${repo}/timeline?limit=${limit}`,
timelineSchema as z.ZodType<TimelineEvent[]>,
),
enabled: Boolean(owner && repo),
refetchInterval: 30_000,
})
}
+3 -1
View File
@@ -163,7 +163,8 @@ function RepoSubNav({ owner, repo }: { owner: string; repo: string }) {
{ label: 'Issues', to: `${base}/issues`, icon: <IssueIcon /> },
{ label: 'Pipelines', to: `${base}/pipelines`, icon: <PipelineIcon /> },
{ label: 'Environments', to: `${base}/environments`, icon: <EnvIcon /> },
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
{ label: 'Timeline', to: `${base}/timeline`, icon: <TimelineIcon /> },
{ label: 'Settings', to: `${base}/settings`, icon: <SettingsSmIcon /> },
]
return (
<div>
@@ -205,4 +206,5 @@ const BranchIcon = () => <I d="M3 13.5V6a2.25 2.25 0 0 1 2.25-2.25h.75a2.25 2.25
const IssueIcon = () => <I d={['M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z']} />
const PipelineIcon = () => <I d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
const EnvIcon = () => <I d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
const TimelineIcon = () => <I d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
const SettingsSmIcon = () => <I d={['M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z', 'M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z']} />
+362
View File
@@ -0,0 +1,362 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useTimeline } from '../api/queries/timeline'
import { Skeleton } from '../ui/Skeleton'
import { cn } from '../lib/utils'
import type { TimelineEvent, CommitEvent, RunEvent, DeploymentEvent } 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`
const d = Math.floor(hr / 24)
if (d < 30) return `${d}d ago`
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
})
}
function duration(start: string | null | undefined, end: string | null | undefined): 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 shortSHA(sha: string) { return sha.slice(0, 7) }
function shortRef(ref: string) { return ref.replace('refs/heads/', '').replace('refs/tags/', '') }
// ── Day grouping ──────────────────────────────────────────────────────────────
function dayKey(iso: string): string {
return new Date(iso).toISOString().slice(0, 10)
}
// ── Status helpers ─────────────────────────────────────────────────────────────
const RUN_LABEL: Record<string, string> = {
queued: 'Queued', running: 'Running', succeeded: 'Passed',
failed: 'Failed', cancelled: 'Cancelled',
}
const DEPLOY_LABEL: Record<string, string> = {
pending: 'Pending', in_progress: 'In progress', success: 'Active',
failure: 'Failed', cancelled: 'Cancelled',
}
// ── Event icons ───────────────────────────────────────────────────────────────
function CommitIcon() {
return (
<div className="w-7 h-7 rounded-full bg-[var(--c-surface)] border-2 border-[var(--c-border)] flex items-center justify-center shrink-0">
<svg width="12" height="12" fill="none" stroke="var(--c-muted)" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
</div>
)
}
function RunIcon({ status }: { status: string }) {
const isRunning = status === 'running'
const isFailed = status === 'failed'
const isPassed = status === 'succeeded'
return (
<div className={cn(
'w-7 h-7 rounded-full border-2 flex items-center justify-center shrink-0',
isPassed ? 'bg-[#E3FCEF] border-[#79F2C0]' :
isFailed ? 'bg-[var(--c-danger-tint)] border-[#FF8F73]' :
isRunning ? 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)]' :
'bg-[var(--c-surface)] border-[var(--c-border)]',
)}>
<svg width="12" height="12" fill="none"
stroke={isPassed ? 'var(--c-success)' : isFailed ? 'var(--c-danger)' : isRunning ? 'var(--c-brand)' : 'var(--c-muted)'}
strokeWidth="2" viewBox="0 0 24 24"
className={isRunning ? 'animate-spin' : ''}
>
{isPassed
? <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
: isFailed
? <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
: isRunning
? <path strokeLinecap="round" d="M12 3a9 9 0 1 0 9 9" />
: <path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
}
</svg>
</div>
)
}
function DeployIcon({ status }: { status: string }) {
const isSuccess = status === 'success'
const isFailure = status === 'failure'
const isActive = status === 'in_progress'
return (
<div className={cn(
'w-7 h-7 rounded-full border-2 flex items-center justify-center shrink-0',
isSuccess ? 'bg-[#E3FCEF] border-[#79F2C0]' :
isFailure ? 'bg-[var(--c-danger-tint)] border-[#FF8F73]' :
isActive ? 'bg-[var(--c-brand-tint)] border-[var(--c-brand-focus)]' :
'bg-[var(--c-surface)] border-[var(--c-border)]',
)}>
<svg width="12" height="12" fill="none"
stroke={isSuccess ? 'var(--c-success)' : isFailure ? 'var(--c-danger)' : isActive ? 'var(--c-brand)' : 'var(--c-muted)'}
strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 6 0m-6 0H3m16.5 0a3 3 0 0 0-3-3m3 3a3 3 0 1 1-6 0m6 0h1.5m-7.5 0h-3" />
</svg>
</div>
)
}
// ── Event rows ─────────────────────────────────────────────────────────────────
function CommitRow({ ev, owner, repo }: { ev: CommitEvent; owner: string; repo: string }) {
return (
<div className="flex items-start gap-3">
<CommitIcon />
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-sm text-[var(--c-text)] leading-snug line-clamp-2">{ev.message}</p>
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
<Link
to={`/repos/${owner}/${repo}/commits`}
className="font-mono text-[var(--c-brand)] hover:underline"
>
{shortSHA(ev.sha)}
</Link>
<span>by {ev.author}</span>
<span>{timeAgo(ev.timestamp)}</span>
</div>
</div>
</div>
)
}
function RunRow({ ev, owner, repo }: { ev: RunEvent; owner: string; repo: string }) {
const dur = duration(ev.startedAt, ev.finishedAt)
return (
<div className="flex items-start gap-3">
<RunIcon status={ev.runStatus} />
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/repos/${owner}/${repo}/runs/${ev.runId}`}
className="text-sm font-medium text-[var(--c-text)] hover:text-[var(--c-brand)] transition-colors"
>
Run #{ev.runId}
</Link>
<span className={cn(
'text-[10px] font-semibold',
ev.runStatus === 'succeeded' ? 'text-[var(--c-success)]' :
ev.runStatus === 'failed' ? 'text-[var(--c-danger)]' :
ev.runStatus === 'running' ? 'text-[var(--c-brand)]' :
'text-[var(--c-muted)]',
)}>
{RUN_LABEL[ev.runStatus] ?? ev.runStatus}
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
<span className="font-mono">{shortRef(ev.triggerRef)}</span>
<span className="font-mono">{shortSHA(ev.triggerSha)}</span>
{dur && <span>{dur}</span>}
<span>{timeAgo(ev.timestamp)}</span>
</div>
</div>
</div>
)
}
function DeployRow({ ev, owner, repo }: { ev: DeploymentEvent; owner: string; repo: string }) {
return (
<div className="flex items-start gap-3">
<DeployIcon status={ev.deployStatus} />
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-center gap-2 flex-wrap">
<Link
to={`/repos/${owner}/${repo}/environments`}
className="text-sm font-medium text-[var(--c-text)] hover:text-[var(--c-brand)] transition-colors"
>
{ev.envName}
</Link>
<span className={cn(
'text-[10px] font-semibold',
ev.deployStatus === 'success' ? 'text-[var(--c-success)]' :
ev.deployStatus === 'failure' ? 'text-[var(--c-danger)]' :
ev.deployStatus === 'in_progress' ? 'text-[var(--c-brand)]' :
'text-[var(--c-muted)]',
)}>
{DEPLOY_LABEL[ev.deployStatus] ?? ev.deployStatus}
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-[11px] text-[var(--c-muted)] flex-wrap">
<span className="font-mono">{shortSHA(ev.deployedSha)}</span>
{ev.deployRef && <span className="font-mono">{shortRef(ev.deployRef)}</span>}
<span>by {ev.triggeredBy}</span>
{ev.description && <span>· {ev.description}</span>}
{ev.runLink && (
<Link to={`/repos/${owner}/${repo}/runs/${ev.runLink}`} className="text-[var(--c-brand)] hover:underline">
run #{ev.runLink}
</Link>
)}
<span>{timeAgo(ev.timestamp)}</span>
</div>
</div>
</div>
)
}
// ── Filter bar ─────────────────────────────────────────────────────────────────
type Filter = 'all' | 'commit' | 'run' | 'deployment'
const FILTERS: { label: string; value: Filter }[] = [
{ label: 'All activity', value: 'all' },
{ label: 'Commits', value: 'commit' },
{ label: 'CI runs', value: 'run' },
{ label: 'Deployments', value: 'deployment' },
]
// ── Skeleton ──────────────────────────────────────────────────────────────────
function EventSkeleton() {
return (
<div className="flex items-start gap-3 py-3">
<Skeleton className="w-7 h-7 rounded-full shrink-0" />
<div className="flex-1 space-y-2 pt-0.5">
<Skeleton className="h-3.5 w-3/4 rounded" />
<Skeleton className="h-2.5 w-1/2 rounded" />
</div>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function RepoTimelinePage() {
const { owner = '', repo = '' } = useParams()
const { data: events, isLoading } = useTimeline(owner, repo)
const [filter, setFilter] = useState<Filter>('all')
const filtered = events?.filter(e => filter === 'all' || e.type === filter) ?? []
// Group by calendar day
const days: Array<{ key: string; label: string; events: TimelineEvent[] }> = []
const dayMap = new Map<string, TimelineEvent[]>()
for (const ev of filtered) {
const k = dayKey(ev.timestamp)
if (!dayMap.has(k)) dayMap.set(k, [])
dayMap.get(k)!.push(ev)
}
for (const [k, evs] of dayMap) {
days.push({ key: k, label: formatDate(evs[0].timestamp), events: evs })
}
const counts = {
commit: events?.filter(e => e.type === 'commit').length ?? 0,
run: events?.filter(e => e.type === 'run').length ?? 0,
deployment: events?.filter(e => e.type === 'deployment').length ?? 0,
}
return (
<div className="max-w-3xl mx-auto px-4 md:px-6 py-5 space-y-4">
{/* Header */}
<div>
<h1 className="text-lg font-semibold text-[var(--c-text)]">Timeline</h1>
<p className="text-xs text-[var(--c-muted)] mt-0.5">
Commits, CI runs, and deployments for{' '}
<span className="font-mono">{owner}/{repo}</span>
</p>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1 border-b border-[var(--c-border)]">
{FILTERS.map(f => (
<button
key={f.value}
onClick={() => 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 && (
<span className="ml-1.5 text-[10px] font-mono text-[var(--c-subtle)]">
{counts[f.value]}
</span>
)}
</button>
))}
</div>
{/* Timeline */}
{isLoading ? (
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => <EventSkeleton key={i} />)}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<div>
<p className="text-sm font-medium text-[var(--c-text)]">No activity yet</p>
<p className="text-xs text-[var(--c-muted)] mt-1">
{filter === 'all'
? 'Push commits, run pipelines, or create deployments to see activity here.'
: `No ${filter === 'commit' ? 'commits' : filter === 'run' ? 'CI runs' : 'deployments'} yet.`}
</p>
</div>
</div>
) : (
<div className="space-y-6">
{days.map(day => (
<div key={day.key}>
{/* Day separator */}
<div className="flex items-center gap-3 mb-3">
<div className="h-px flex-1 bg-[var(--c-border)]" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--c-muted)] shrink-0">
{day.label}
</span>
<div className="h-px flex-1 bg-[var(--c-border)]" />
</div>
{/* Events for this day */}
<div className="relative">
{/* Vertical line connecting events */}
<div className="absolute left-3.5 top-7 bottom-0 w-px bg-[var(--c-border)]" aria-hidden />
<div className="space-y-4">
{day.events.map((ev, i) => (
<div key={i} className="relative">
{ev.type === 'commit' && (
<CommitRow ev={ev as CommitEvent} owner={owner} repo={repo} />
)}
{ev.type === 'run' && (
<RunRow ev={ev as RunEvent} owner={owner} repo={repo} />
)}
{ev.type === 'deployment' && (
<DeployRow ev={ev as DeploymentEvent} owner={owner} repo={repo} />
)}
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
+37
View File
@@ -260,6 +260,43 @@ export interface EnvironmentWithLatest extends Environment {
latestDeployment: Deployment | null
}
// ── Unified Operational Timeline (Phase 3B) ──────────────────────────────────
export interface CommitEvent {
type: 'commit'
timestamp: string
sha: string
message: string
author: string
}
export interface RunEvent {
type: 'run'
timestamp: string
runId: number
triggerRef: string
triggerSha: string
triggeredBy: string
runStatus: string
startedAt: string | null
finishedAt: string | null
}
export interface DeploymentEvent {
type: 'deployment'
timestamp: string
deploymentId: number
envName: string
deployStatus: string
deployedSha: string
deployRef: string
triggeredBy: string
description: string
runLink: number | null
}
export type TimelineEvent = CommitEvent | RunEvent | DeploymentEvent
export interface ApiError {
error: string
status: number
+176
View File
@@ -0,0 +1,176 @@
package handlers
import (
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/models"
)
type TimelineHandler struct {
db *xorm.Engine
repoRoot string
}
func NewTimelineHandler(db *xorm.Engine, repoRoot string) *TimelineHandler {
return &TimelineHandler{db: db, repoRoot: repoRoot}
}
// ── Event types ───────────────────────────────────────────────────────────────
type timelineEventType string
const (
eventTypeCommit timelineEventType = "commit"
eventTypeRun timelineEventType = "run"
eventTypeDeployment timelineEventType = "deployment"
)
// TimelineEvent is a unified, sorted event across commits, CI runs, and deployments.
// The `type` field is used by the frontend to discriminate which fields are present.
type TimelineEvent struct {
Type timelineEventType `json:"type"`
Timestamp time.Time `json:"timestamp"`
// commit fields
SHA string `json:"sha,omitempty"`
Message string `json:"message,omitempty"`
Author string `json:"author,omitempty"`
// run fields
RunID int64 `json:"runId,omitempty"`
TriggerRef string `json:"triggerRef,omitempty"`
TriggerSHA string `json:"triggerSha,omitempty"`
TriggeredBy string `json:"triggeredBy,omitempty"`
RunStatus string `json:"runStatus,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,omitempty"`
// deployment fields
DeploymentID int64 `json:"deploymentId,omitempty"`
EnvName string `json:"envName,omitempty"`
DeployStatus string `json:"deployStatus,omitempty"`
DeployedSHA string `json:"deployedSha,omitempty"`
DeployRef string `json:"deployRef,omitempty"`
Description string `json:"description,omitempty"`
RunLink *int64 `json:"runLink,omitempty"` // links a deployment back to its pipeline run
}
// ── Handler ───────────────────────────────────────────────────────────────────
// GetTimeline returns a merged, time-sorted feed of commits, pipeline runs, and
// deployments for the given repository. Defaults to the last 60 events.
//
// GET /api/v1/repos/:owner/:repo/timeline?limit=60
func (h *TimelineHandler) GetTimeline(w http.ResponseWriter, r *http.Request) {
owner := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
limit := 60
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l
}
// ── Resolve repo ──────────────────────────────────────────────────────────
var u models.User
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
jsonError(w, "repository not found", http.StatusNotFound)
return
}
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
}
var events []TimelineEvent
// ── Commits ───────────────────────────────────────────────────────────────
gitdomain.SetRepoRoot(h.repoRoot)
if !gitdomain.IsEmpty(repo.DiskPath) {
commits, err := gitdomain.Log(repo.DiskPath, repo.DefaultBranch, limit)
if err == nil {
for _, c := range commits {
ts, _ := time.Parse("2006-01-02 15:04:05 -0700", c.Date)
events = append(events, TimelineEvent{
Type: eventTypeCommit,
Timestamp: ts,
SHA: c.Hash,
Message: c.Message,
Author: c.Author,
})
}
}
}
// ── Pipeline runs ─────────────────────────────────────────────────────────
var runs []models.PipelineRun
h.db.Where("repo_id = ?", repo.ID).Desc("id").Limit(limit).Find(&runs)
for _, run := range runs {
ev := TimelineEvent{
Type: eventTypeRun,
Timestamp: run.CreatedAt,
RunID: run.ID,
TriggerRef: run.TriggerRef,
TriggerSHA: run.TriggerSHA,
TriggeredBy: run.TriggeredBy,
RunStatus: run.Status,
}
if run.StartedAt != nil {
ev.StartedAt = run.StartedAt.Format(time.RFC3339)
}
if run.FinishedAt != nil {
ev.FinishedAt = run.FinishedAt.Format(time.RFC3339)
}
events = append(events, ev)
}
// ── Deployments (with environment name) ───────────────────────────────────
var deploys []models.Deployment
h.db.Where("repo_id = ?", repo.ID).Desc("id").Limit(limit).Find(&deploys)
// Cache env names to avoid N+1 queries.
envNameByID := map[int64]string{}
for _, d := range deploys {
if _, ok := envNameByID[d.EnvID]; !ok {
var env models.Environment
if found, _ := h.db.ID(d.EnvID).Cols("name").Get(&env); found {
envNameByID[d.EnvID] = env.Name
}
}
ev := TimelineEvent{
Type: eventTypeDeployment,
Timestamp: d.CreatedAt,
DeploymentID: d.ID,
EnvName: envNameByID[d.EnvID],
DeployStatus: string(d.Status),
DeployedSHA: d.SHA,
DeployRef: d.Ref,
TriggeredBy: d.TriggeredBy,
Description: d.Description,
RunLink: d.RunID,
}
events = append(events, ev)
}
// ── Merge-sort by timestamp descending ────────────────────────────────────
sort.Slice(events, func(i, j int) bool {
return events[i].Timestamp.After(events[j].Timestamp)
})
// Cap at limit.
if len(events) > limit {
events = events[:limit]
}
if events == nil {
events = []TimelineEvent{}
}
jsonOK(w, events)
}
+2
View File
@@ -59,6 +59,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
runnerH := handlers.NewRunnerHandler(engine)
envH := handlers.NewEnvironmentHandler(engine, bus)
timelineH := handlers.NewTimelineHandler(engine, cfg.RepoRoot)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -206,6 +207,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(csrf).Put("/default-description", prSettingsH.UpdateDefaultDescription)
r.Get("/excluded-files", prSettingsH.GetExcludedFiles)
r.With(csrf).Put("/excluded-files", prSettingsH.UpdateExcludedFiles)
r.Get("/timeline", timelineH.GetTimeline)
r.Get("/lfs-settings", lfsH.Get)
r.With(csrf).Put("/lfs-settings", lfsH.Update)
r.Route("/environments", func(r chi.Router) {