mirror of
https://github.com/hanzoai/ci.git
synced 2026-10-20 15:28:05 +00:00
Hanzo CI/CD / cicd (push) Waiting to run
CI/CD / gate (push) Waiting to run
These types are published through the composed cloud document, whose
schema namespace is FLAT and global: `Service` appears in fifty-three
other app documents, `Run` and `Head` in ten each, and two names that
differ only in case are worse still — every generator PascalCases, so an
SDK binds whichever it read last or refuses to compile.
Three of the five are better names besides:
Version -> Artifact a tag and a digest identifying built bytes is
not a version; nothing here is semver.
Head -> Tip its own comment already said "the tip of the
default branch".
Service -> Pipeline the row is not a workload, it is the chain
behind one: head, built, declared, running.
Run -> Execution } free names for the two that only collided
Build -> Check }
The wire does not move: JSON comes from the tags and those are
untouched, which the shape test asserts. What changes is the name a
generated client gives the class.
Co-authored-by: Hanzo Dev <[email protected]>
651 lines
21 KiB
Go
651 lines
21 KiB
Go
// ci — the dashboard behind ci.hanzo.ai.
|
|
//
|
|
// It owns no build state. Run truth lives in Hanzo Git (git.hanzo.ai), which
|
|
// schedules the jobs and holds every log; this reads that and presents it. The
|
|
// alternative — a CI service with its own run database — would put two answers
|
|
// to "did the build pass" in the fleet, and the one users look at would be the
|
|
// one that can drift. So: git.hanzo.ai is the store, ci.hanzo.ai is the view.
|
|
//
|
|
// Build and deploy are one event on this fleet, and the page says so. A push
|
|
// runs the pipeline this repo publishes; that pipeline's `image` job produces the
|
|
// artifact and its `rollout` job writes the tag and digest into hanzoai/universe,
|
|
// which cd.hanzo.ai reconciles onto the cluster. So the last jobs of a run ARE
|
|
// the deploy, and showing a run without showing whether its pin took effect is
|
|
// reporting on the first half of an event.
|
|
//
|
|
// The fleet view (fleet.go) closes it: for each service it reads what we wrote,
|
|
// what was proved, what is declared and what is running, from the four systems
|
|
// that own those answers. cd.hanzo.ai remains the place to drive a sync; this is
|
|
// the place to see whether one was needed.
|
|
//
|
|
// Tenancy is the same value everywhere: an org slug. Hanzo Git namespaces repos
|
|
// by org, IAM issues that slug in the `owner` claim, and Hanzo CD fences
|
|
// projects by it. Filtering here by `org` is therefore the same boundary those
|
|
// enforce, not a parallel notion of who-sees-what.
|
|
package ci
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Serve builds the dashboard's handler and starts the pollers that feed it.
|
|
//
|
|
// Exported so this surface can be MOUNTED as well as run: hanzoai/cloud carries
|
|
// /v1/deploy behind the gateway's IAM identity, and this is the CI half of that
|
|
// same delivery plane. A package main cannot be mounted by anything, which is
|
|
// why it was the one surface a valid hanzo.id token could not read.
|
|
//
|
|
// The caller owns the lifetime: cancelling ctx stops the pollers. The returned
|
|
// handler is safe to serve immediately — both caches answer "no snapshot yet"
|
|
// rather than blocking, so a mount never waits on an upstream fetch.
|
|
func Serve(ctx context.Context, logger *slog.Logger, cfg config) http.Handler {
|
|
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
|
|
cache := &runCache{}
|
|
board := &fleetCache{}
|
|
|
|
// The cluster is read through the pod's own ServiceAccount. Failing to dial
|
|
// is not fatal: the run view is unaffected, and the fleet view says it cannot
|
|
// read running state — which is true and visible, where exiting here would
|
|
// take the whole dashboard down over half of one page.
|
|
cl, err := dial(serviceAccount, apiHost)
|
|
if err != nil {
|
|
logger.Warn("cluster identity", "err", err)
|
|
}
|
|
|
|
// One poller, one cache. Every viewer reads the same snapshot, so N open
|
|
// dashboards cost Hanzo Git exactly as much as one — a dashboard that
|
|
// fanned each page load into upstream calls is how a status page takes the
|
|
// system it reports on down.
|
|
go poll(ctx, logger, src, cache, cfg)
|
|
go watch(ctx, logger, src, cl, board, cfg)
|
|
|
|
return routes(cfg, cache, board)
|
|
}
|
|
|
|
// Main is the standalone server: the same handler, with a listener and shutdown
|
|
// around it. cmd/ci is a thin wrapper over this. Named Main rather than Run
|
|
// because a Run in this package is a CI run — the domain object — and the
|
|
// binary's body is not one.
|
|
func Main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
logger.Error("config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.listen,
|
|
Handler: Serve(ctx, logger, cfg),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
sh, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_ = srv.Shutdown(sh)
|
|
}()
|
|
|
|
logger.Info("ci dashboard listening", "addr", cfg.listen, "source", cfg.gitBase, "refresh", cfg.refresh.String())
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
logger.Error("serve", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// routes is the whole surface, in one place, so that what the scope tests drive
|
|
// is what the binary serves. A test that rebuilt a handler of its own would be
|
|
// asserting a copy, and the copy is free to stop matching the route it stands in
|
|
// for — which for the org boundary is the one thing that must not happen.
|
|
//
|
|
// Every handler here begins by resolving the viewer, and every list it answers
|
|
// with passes through that viewer first. /healthz is the single exception, and
|
|
// only because it answers a constant.
|
|
// Runs and Fleet are what the two API operations answer.
|
|
//
|
|
// They were anonymous maps, which is fine for a page this repo also renders and
|
|
// wrong for an operation a client dispatches: a caller needs a shape it can
|
|
// generate against, and a mounted copy of this surface would otherwise have to
|
|
// declare a second one — the two-schema drift HIP-0106 §4.2 names.
|
|
//
|
|
// Both carry the SAME four facts about the fetch itself, because a board whose
|
|
// staleness a reader cannot see is a board that lies quietly.
|
|
type Executions struct {
|
|
Executions []Execution `json:"runs"`
|
|
Repos int `json:"repos"`
|
|
Orgs []string `json:"orgs"`
|
|
FetchedAt time.Time `json:"fetchedAt"`
|
|
Stale bool `json:"stale"`
|
|
SourceErr string `json:"sourceErr"`
|
|
}
|
|
|
|
// Fleet is one row per service: what was written against what is running.
|
|
type Pipelines struct {
|
|
Services []Pipeline `json:"services"`
|
|
Orgs []string `json:"orgs"`
|
|
FetchedAt time.Time `json:"fetchedAt"`
|
|
Stale bool `json:"stale"`
|
|
SourceErr string `json:"sourceErr"`
|
|
}
|
|
|
|
func routes(cfg config, cache *runCache, board *fleetCache) *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
// Liveness only: up means "serving". Readiness deliberately does NOT
|
|
// gate on having a snapshot — a Hanzo Git outage must show as a stale
|
|
// dashboard saying so, not as ci.hanzo.ai disappearing from the LB too.
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
|
})
|
|
// Namespaced under /v1/ci. An API path names the app that answers it: /v1/runs
|
|
// and /v1/fleet name neither, and two apps on one host cannot both have them.
|
|
// api.hanzo.ai already carries /v1/deploy for the CD half of this same plane,
|
|
// behind the gateway's IAM identity; this is the shape that lets the CI half
|
|
// join it rather than needing a second front door and a second way to prove
|
|
// who you are.
|
|
mux.HandleFunc("/v1/ci/runs", func(w http.ResponseWriter, r *http.Request) {
|
|
v, ok := requireViewer(w, r, cfg.adminOrg)
|
|
if !ok {
|
|
return
|
|
}
|
|
snap := cache.get()
|
|
writeJSON(w, http.StatusOK, Executions{
|
|
Executions: v.visible(snap.Executions, r.URL.Query().Get("org")),
|
|
Repos: snap.Repos,
|
|
Orgs: v.orgs(snap.Executions),
|
|
FetchedAt: snap.FetchedAt,
|
|
Stale: snap.stale(cfg.staleAfter),
|
|
SourceErr: snap.errString(),
|
|
})
|
|
})
|
|
mux.HandleFunc("/v1/ci/fleet", func(w http.ResponseWriter, r *http.Request) {
|
|
v, ok := requireViewer(w, r, cfg.adminOrg)
|
|
if !ok {
|
|
return
|
|
}
|
|
snap := board.get()
|
|
services := v.services(snap.Services, r.URL.Query().Get("org"))
|
|
writeJSON(w, http.StatusOK, Pipelines{
|
|
Services: services,
|
|
Orgs: orgsOfServices(services),
|
|
FetchedAt: snap.FetchedAt,
|
|
Stale: snap.stale(4 * cfg.fleetRefresh),
|
|
SourceErr: snap.errString(),
|
|
})
|
|
})
|
|
mux.HandleFunc("/runs", func(w http.ResponseWriter, r *http.Request) {
|
|
v, ok := requireViewer(w, r, cfg.adminOrg)
|
|
if !ok {
|
|
return
|
|
}
|
|
renderRuns(w, cache.get(), v, r.URL.Query().Get("org"), cfg)
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
v, ok := requireViewer(w, r, cfg.adminOrg)
|
|
if !ok {
|
|
return
|
|
}
|
|
renderFleet(w, board.get(), v, r.URL.Query().Get("org"), cfg)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
// ───────────────────────────── config ─────────────────────────────
|
|
|
|
type config struct {
|
|
listen string
|
|
gitBase string
|
|
// adminOrg is the ONE org whose members see across tenants. It must match
|
|
// admin-guard's IAM_ADMIN_ORG — the guard decides who gets in, this decides
|
|
// who sees everything, and a mismatch would silently demote the fleet view to
|
|
// a single-org view (or, if set too wide, promote a tenant to it).
|
|
adminOrg string
|
|
gitToken string
|
|
refresh time.Duration
|
|
staleAfter time.Duration
|
|
scanRepos int
|
|
runsPer int
|
|
|
|
// universe is the repo holding the declared state of the fleet, and
|
|
// fleetRefresh is how often the four values are re-read. The fleet interval
|
|
// is longer than the run interval because these values move on deploy
|
|
// cadence, not push cadence, and each cycle costs Hanzo Git a read per
|
|
// service.
|
|
universe string
|
|
fleetRefresh time.Duration
|
|
}
|
|
|
|
func loadConfig() (config, error) {
|
|
c := config{
|
|
listen: env("CI_LISTEN", ":8080"),
|
|
gitBase: strings.TrimRight(env("CI_GIT_BASE", "https://git.hanzo.ai"), "/"),
|
|
adminOrg: env("CI_ADMIN_ORG", "admin"),
|
|
gitToken: os.Getenv("CI_GIT_TOKEN"),
|
|
scanRepos: envInt("CI_SCAN_REPOS", 60),
|
|
runsPer: envInt("CI_RUNS_PER_REPO", 8),
|
|
universe: env("CI_UNIVERSE", "hanzo/universe"),
|
|
}
|
|
c.refresh = time.Duration(envInt("CI_REFRESH_SECONDS", 45)) * time.Second
|
|
c.fleetRefresh = time.Duration(envInt("CI_FLEET_SECONDS", 300)) * time.Second
|
|
// Stale is a multiple of refresh, not its own knob: the only meaningful
|
|
// definition of stale is "we have missed several refreshes", and deriving
|
|
// it means the two can never be configured into contradiction.
|
|
c.staleAfter = 4 * c.refresh
|
|
if c.gitToken == "" {
|
|
return c, errors.New("CI_GIT_TOKEN required (Hanzo Git API token)")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func env(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func envInt(k string, def int) int {
|
|
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// ───────────────────────────── model ─────────────────────────────
|
|
|
|
// Run is the projection of a Hanzo Git workflow run this dashboard shows. It is
|
|
// deliberately a SUBSET: the upstream object carries a dozen more fields, and
|
|
// copying them all would make this a second schema to maintain against theirs.
|
|
type Execution struct {
|
|
ID int64 `json:"id"`
|
|
Org string `json:"org"`
|
|
Repo string `json:"repo"`
|
|
Workflow string `json:"workflow"`
|
|
Title string `json:"title"`
|
|
|
|
// Status and Conclusion are BOTH required to know how a run went, and
|
|
// reading only one is wrong in a way that looks fine. Status answers
|
|
// "is it over" (queued | in_progress | completed); Conclusion answers
|
|
// "how did it end" and is empty until it is over. A view that buckets on
|
|
// Status alone sees `completed` and cannot tell a pass from a failure, so
|
|
// it draws every finished run — successes and cancellations included — the
|
|
// same way.
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
|
|
Event string `json:"event"`
|
|
Branch string `json:"branch"`
|
|
SHA string `json:"sha"`
|
|
Actor string `json:"actor"`
|
|
Number int `json:"number"`
|
|
URL string `json:"url"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
EndedAt time.Time `json:"endedAt"`
|
|
}
|
|
|
|
// Duration is zero-valued rather than negative when a run has not finished —
|
|
// callers render "running", and a negative duration would print as one.
|
|
func (r Execution) Duration() time.Duration {
|
|
if r.StartedAt.IsZero() || r.EndedAt.IsZero() || r.EndedAt.Before(r.StartedAt) {
|
|
return 0
|
|
}
|
|
return r.EndedAt.Sub(r.StartedAt)
|
|
}
|
|
|
|
type snapshot struct {
|
|
Executions []Execution `json:"runs"`
|
|
Repos int `json:"repos"`
|
|
FetchedAt time.Time `json:"fetchedAt"`
|
|
Err error `json:"-"`
|
|
}
|
|
|
|
func (s snapshot) stale(after time.Duration) bool {
|
|
return s.FetchedAt.IsZero() || time.Since(s.FetchedAt) > after
|
|
}
|
|
|
|
func (s snapshot) errString() string {
|
|
if s.Err == nil {
|
|
return ""
|
|
}
|
|
return s.Err.Error()
|
|
}
|
|
|
|
type runCache struct {
|
|
mu sync.RWMutex
|
|
snap snapshot
|
|
}
|
|
|
|
func (c *runCache) get() snapshot {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.snap
|
|
}
|
|
|
|
// put keeps the LAST GOOD run list when a refresh fails, recording the error
|
|
// alongside it. A failed poll must not blank the dashboard: "Hanzo Git is
|
|
// unreachable, here is what we last saw" is strictly more useful than an empty
|
|
// page, which reads as "nothing is building".
|
|
func (c *runCache) put(s snapshot) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if s.Err != nil && len(s.Executions) == 0 && len(c.snap.Executions) > 0 {
|
|
prev := c.snap
|
|
prev.Err = s.Err
|
|
c.snap = prev
|
|
return
|
|
}
|
|
c.snap = s
|
|
}
|
|
|
|
// ───────────────────────────── source ─────────────────────────────
|
|
|
|
type gitSource struct {
|
|
base string
|
|
token string
|
|
http *http.Client
|
|
|
|
// Answers that outlive a refresh. The declared fleet is re-read only when the
|
|
// universe head commit moves, and which repo publishes an image is settled
|
|
// once — neither changes on this service's clock, and asking again every
|
|
// cycle would spend the whole budget confirming that.
|
|
mu sync.Mutex
|
|
pinsAt string
|
|
pinned map[string]pin
|
|
located map[string]place
|
|
}
|
|
|
|
func (g *gitSource) getJSON(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+g.token)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := g.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("%s: %s", path, resp.Status)
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// getText fetches a path whose body is a file rather than a document.
|
|
func (g *gitSource) getText(ctx context.Context, path string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "token "+g.token)
|
|
resp, err := g.http.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("%s: %s", path, resp.Status)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
return string(body), err
|
|
}
|
|
|
|
type repoRef struct {
|
|
FullName string `json:"full_name"`
|
|
}
|
|
|
|
// repos returns the most recently ACTIVE repositories. Sorting by activity and
|
|
// taking a window is the whole scan strategy: the instance mirrors ~1400 repos
|
|
// and almost none of them built in the last hour, so walking all of them would
|
|
// spend the entire refresh budget confirming silence.
|
|
func (g *gitSource) repos(ctx context.Context, limit int) ([]string, error) {
|
|
var body struct {
|
|
Data []repoRef `json:"data"`
|
|
}
|
|
q := url.Values{}
|
|
q.Set("sort", "updated")
|
|
q.Set("order", "desc")
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
if err := g.getJSON(ctx, "/v1/repos/search?"+q.Encode(), &body); err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(body.Data))
|
|
for _, r := range body.Data {
|
|
if r.FullName != "" {
|
|
names = append(names, r.FullName)
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
type apiRun struct {
|
|
ID int64 `json:"id"`
|
|
DisplayTitle string `json:"display_title"`
|
|
Path string `json:"path"`
|
|
Event string `json:"event"`
|
|
Status string `json:"status"`
|
|
Conclusion string `json:"conclusion"`
|
|
HeadBranch string `json:"head_branch"`
|
|
HeadSHA string `json:"head_sha"`
|
|
RunNumber int `json:"run_number"`
|
|
HTMLURL string `json:"html_url"`
|
|
StartedAt string `json:"started_at"`
|
|
CompletedAt string `json:"completed_at"`
|
|
Actor struct {
|
|
Login string `json:"login"`
|
|
} `json:"actor"`
|
|
}
|
|
|
|
func (g *gitSource) runs(ctx context.Context, fullName string, limit int) ([]Execution, error) {
|
|
var body struct {
|
|
WorkflowRuns []apiRun `json:"workflow_runs"`
|
|
}
|
|
path := fmt.Sprintf("/v1/repos/%s/actions/runs?limit=%d", fullName, limit)
|
|
if err := g.getJSON(ctx, path, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
org, repo := splitFullName(fullName)
|
|
out := make([]Execution, 0, len(body.WorkflowRuns))
|
|
for _, r := range body.WorkflowRuns {
|
|
out = append(out, Execution{
|
|
ID: r.ID,
|
|
Org: org,
|
|
Repo: repo,
|
|
Workflow: workflowOf(r.Path),
|
|
Title: r.DisplayTitle,
|
|
Status: r.Status,
|
|
Conclusion: r.Conclusion,
|
|
Event: r.Event,
|
|
Branch: r.HeadBranch,
|
|
SHA: shortSHA(r.HeadSHA),
|
|
Actor: r.Actor.Login,
|
|
Number: r.RunNumber,
|
|
URL: r.HTMLURL,
|
|
StartedAt: parseTime(r.StartedAt),
|
|
EndedAt: parseTime(r.CompletedAt),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// poll refreshes the snapshot on an interval, forever.
|
|
func poll(ctx context.Context, logger *slog.Logger, src *gitSource, cache *runCache, cfg config) {
|
|
refresh := func() {
|
|
rctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
|
defer cancel()
|
|
|
|
names, err := src.repos(rctx, cfg.scanRepos)
|
|
if err != nil {
|
|
logger.Warn("repo scan failed", "err", err)
|
|
cache.put(snapshot{FetchedAt: time.Now().UTC(), Err: err})
|
|
return
|
|
}
|
|
|
|
// Fan out, bounded. The cap is small on purpose: this is a read against
|
|
// the server that schedules every build in the fleet, and a dashboard is
|
|
// never worth degrading it.
|
|
const workers = 6
|
|
var (
|
|
mu sync.Mutex
|
|
all []Execution
|
|
errs []string
|
|
wg sync.WaitGroup
|
|
)
|
|
jobs := make(chan string)
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for name := range jobs {
|
|
rs, err := src.runs(rctx, name, cfg.runsPer)
|
|
mu.Lock()
|
|
if err != nil {
|
|
// A repo with Actions disabled 404s. That is normal and
|
|
// not worth surfacing as a dashboard-level failure, so
|
|
// it is counted, not shown.
|
|
errs = append(errs, name)
|
|
} else {
|
|
all = append(all, rs...)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
for _, n := range names {
|
|
select {
|
|
case jobs <- n:
|
|
case <-rctx.Done():
|
|
}
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
|
|
sort.Slice(all, func(i, j int) bool { return all[i].StartedAt.After(all[j].StartedAt) })
|
|
cache.put(snapshot{Executions: all, Repos: len(names) - len(errs), FetchedAt: time.Now().UTC()})
|
|
logger.Info("refreshed", "repos", len(names), "withRuns", len(names)-len(errs), "runs", len(all))
|
|
}
|
|
|
|
refresh()
|
|
t := time.NewTicker(cfg.refresh)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
func splitFullName(s string) (org, repo string) {
|
|
if i := strings.IndexByte(s, '/'); i > 0 {
|
|
return s[:i], s[i+1:]
|
|
}
|
|
return "", s
|
|
}
|
|
|
|
// workflowOf reduces "e2e.yml@refs/heads/main" to "e2e.yml".
|
|
func workflowOf(path string) string {
|
|
if i := strings.IndexByte(path, '@'); i > 0 {
|
|
return path[:i]
|
|
}
|
|
return path
|
|
}
|
|
|
|
func shortSHA(s string) string {
|
|
if len(s) > 7 {
|
|
return s[:7]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func parseTime(s string) time.Time {
|
|
if s == "" {
|
|
return time.Time{}
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
// Hanzo Git reports an unset timestamp as the Unix epoch rather than null;
|
|
// treated as absent so the UI shows "—" instead of 1970.
|
|
if t.Year() < 2000 {
|
|
return time.Time{}
|
|
}
|
|
return t.UTC()
|
|
}
|
|
|
|
func filterByOrg(runs []Execution, org string) []Execution {
|
|
if org == "" {
|
|
return runs
|
|
}
|
|
out := make([]Execution, 0, len(runs))
|
|
for _, r := range runs {
|
|
if r.Org == org {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func orgsOf(runs []Execution) []string {
|
|
seen := map[string]bool{}
|
|
for _, r := range runs {
|
|
if r.Org != "" {
|
|
seen[r.Org] = true
|
|
}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for o := range seen {
|
|
out = append(out, o)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// ServeFromEnv is Serve with the configuration read the way the standalone
|
|
// binary reads it, so a host that mounts this surface configures it exactly as
|
|
// the deployment does — one set of names, one meaning, whichever way it runs.
|
|
//
|
|
// It exists because config is unexported and should stay so: a caller that had
|
|
// to construct one would be a second place that decides what the defaults are.
|
|
func ServeFromEnv(ctx context.Context, logger *slog.Logger) (http.Handler, error) {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return Serve(ctx, logger, cfg), nil
|
|
}
|