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) }