implemented unified operational timeline. situational awareness 'what changed before it broke?'
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user