Files
ForgeBucket/internal/api/handlers/timeline.go
T
2026-05-11 23:56:45 +02:00

166 lines
5.4 KiB
Go

package handlers
import (
"net/http"
"sort"
"strconv"
"time"
"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) {
limit := 60
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 {
limit = l
}
repo, ok := resolveRepo(h.db, w, r)
if !ok {
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)
}