166 lines
5.4 KiB
Go
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)
|
|
}
|