package web import ( "context" "errors" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "github.com/go-chi/chi/v5" "hearthforge/internal/ci" "hearthforge/internal/db" "hearthforge/internal/util" "hearthforge/internal/web/views" ) const ciRunsPerPage = 20 // ciRoutes mounts the pipeline pages. The caller applies the session // middleware. func (s *Server) ciRoutes(r chi.Router) { r.Get("/{repo}/ci/badge.svg", s.ciBadge) r.Get("/{repo}/ci", s.ciHistory) r.Get("/{repo}/ci/{runID}", s.ciRunDetail) r.Get("/{repo}/ci/{runID}/artifacts/{artifactID}", s.ciArtifactDownload) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/{repo}/ci/run", s.ciTrigger) r.Post("/{repo}/ci/{runID}/retry", s.ciRetry) r.Post("/{repo}/ci/{runID}/cancel", s.ciCancel) r.Post("/{repo}/ci/purge-cache", s.ciPurgeCache) r.Post("/{repo}/settings/ci-secrets", s.ciSecretCreate) r.Post("/{repo}/settings/ci-secrets/delete", s.ciSecretDelete) }) } // ciRunParam reads the run id from the URL and loads the row for this repo. func (s *Server) ciRunParam(w http.ResponseWriter, r *http.Request, repoID int64) (*db.CiRun, bool) { runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } run, err := s.DB.CiRunInRepo(r.Context(), runID, repoID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return nil, false } if run == nil { http.Error(w, "Not found", http.StatusNotFound) return nil, false } return run, true } // ciHeadState is what the manual trigger found at the default branch. type ciHeadState struct { Cfg *ci.Config Branch string SHA string // Problem is "" when the config parsed, else one of the keys below. Problem string } // ciHeadState reads .hearthforge-ci.toml at the tip of branch. An empty branch // means the default branch. func (s *Server) ciHeadState(ctx context.Context, repo *db.Repo, branch string) ciHeadState { git := s.Git branches, err := git.Branches(ctx, repo.Name) if branch != "" && (err != nil || !slices.Contains(branches, branch)) { return ciHeadState{Problem: "unknown_branch"} } if branch == "" { branch = repo.DefaultBranch } if branch == "" && len(branches) > 0 { branch = branches[0] } if branch == "" { return ciHeadState{Problem: "no_branches"} } sha, err := git.ResolveRef(ctx, repo.Name, branch) if err != nil || sha == "" { return ciHeadState{Problem: "no_commits"} } cfg, err := s.CI.ConfigAt(ctx, repo.Name, sha) switch { case errors.Is(err, ci.ErrNoConfig): return ciHeadState{Branch: branch, SHA: sha, Problem: "no_toml"} case err != nil: return ciHeadState{Branch: branch, SHA: sha, Problem: "bad_toml"} } return ciHeadState{Cfg: cfg, Branch: branch, SHA: sha} } // ciHistoryReasons explains a disabled manual trigger button. var ciHistoryReasons = map[string]string{ "unknown_branch": "Unknown branch", "no_branches": "No branches — push a commit first", "no_commits": "No commits yet", "no_toml": "No .hearthforge-ci.toml found in repository", "bad_toml": "Failed to parse .hearthforge-ci.toml", } // ciTriggerErrors are the messages the manual trigger POST answers with. var ciTriggerErrors = map[string]string{ "unknown_branch": "Unknown branch", "no_branches": "No branches", "no_commits": "No commits", "no_toml": "No .hearthforge-ci.toml found at HEAD. Add one to your repository to " + "use CI pipelines.", "bad_toml": "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.", } // ciVariables lists the declared variables in file order. func ciVariables(cfg *ci.Config) []views.CiVariable { if cfg == nil { return nil } out := make([]views.CiVariable, 0, len(cfg.VariableOrder)) for _, name := range cfg.VariableOrder { def := cfg.Variables[name] out = append(out, views.CiVariable{ Name: name, Default: def.Default, Description: def.Description, }) } return out } func (s *Server) ciHistory(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } ctx := r.Context() total, err := s.DB.CountCiRuns(ctx, repo.ID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } page := util.Paginate(util.ParsePage(r.URL.Query().Get("page")), total, ciRunsPerPage) runs, err := s.DB.ListCiRuns(ctx, repo.ID, ciRunsPerPage, page.Offset) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } ids := make([]int64, 0, len(runs)) for _, run := range runs { ids = append(ids, run.ID) } counts, err := s.DB.CiArtifactCounts(ctx, ids) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } summaries := make([]views.CiRunSummary, 0, len(runs)) for _, run := range runs { sum := views.CiRunSummary{Run: run, ArtifactCount: counts[run.ID]} if run.Status == "queued" && s.CI != nil { sum.QueuePosition = s.CI.QueuePosition(run.ID) } summaries = append(summaries, sum) } // Only an admin sees the manual trigger, so only they need the config. var reason string var variables []views.CiVariable var branches []string q := r.URL.Query() branch := q.Get("branch") if u := User(r); u != nil && u.IsAdmin { state := s.ciHeadState(ctx, repo, branch) reason = ciHistoryReasons[state.Problem] variables = ciVariables(state.Cfg) if state.Branch != "" { branch = state.Branch } branches, _ = s.Git.Branches(ctx, repo.Name) } views.Render(w, http.StatusOK, views.CiHistory(s.Cfg, User(r), repo, summaries, views.PageInfo{ Page: page.Page, TotalPages: page.TotalPages, URLTemplate: "/" + repo.Name + "/ci?page={page}", }, views.CiTrigger{ DisabledReason: reason, Variables: variables, Branches: branches, Branch: branch, }, q.Get("success"), q.Get("error"))) } func (s *Server) ciRunDetail(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } run, ok := s.ciRunParam(w, r, repo.ID) if !ok { return } ctx := r.Context() steps, err := s.DB.ListCiSteps(ctx, run.ID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } artifacts, err := s.DB.ListCiArtifacts(ctx, run.ID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } queuePosition := 0 if run.Status == "queued" && s.CI != nil { queuePosition = s.CI.QueuePosition(run.ID) } autoRefresh := r.URL.Query().Get("refresh") != "off" views.Render(w, http.StatusOK, views.CiRunDetail(s.Cfg, User(r), repo, run, steps, artifacts, autoRefresh, queuePosition)) } func (s *Server) ciTrigger(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } state := s.ciHeadState(r.Context(), repo, r.Form.Get("branch")) if state.Problem != "" { http.Error(w, ciTriggerErrors[state.Problem], http.StatusBadRequest) return } runID, err := s.CI.TriggerRun(r.Context(), repo.Name, ci.TriggerOpts{ TriggerSource: "manual", CommitSha: state.SHA, CommitBranch: state.Branch, TriggeredBy: User(r).ID, VariableOverrides: ci.VariableOverrides(state.Cfg, r.Form), }) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(runID, 10), http.StatusFound) } func (s *Server) ciRetry(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } run, ok := s.ciRunParam(w, r, repo.ID) if !ok { return } if err := s.CI.RetryRun(r.Context(), run.ID, User(r).ID); err != nil { if errors.Is(err, ci.ErrRunNotFinished) { http.Error(w, "This run is not finished yet.", http.StatusConflict) return } http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10), http.StatusFound) } func (s *Server) ciCancel(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } run, ok := s.ciRunParam(w, r, repo.ID) if !ok { return } if err := s.CI.CancelRun(r.Context(), run.ID); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, "/"+repo.Name+"/ci/"+strconv.FormatInt(run.ID, 10), http.StatusFound) } func (s *Server) ciPurgeCache(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } query := url.Values{} removed, err := s.CI.PurgeRepoCaches(r.Context(), repo.Name) switch { case err != nil: query.Set("error", "Failed to purge caches. Is Docker reachable?") case removed == 0: query.Set("success", "No cache volumes to purge.") case removed == 1: query.Set("success", "Purged 1 cache volume.") default: query.Set("success", "Purged "+strconv.Itoa(removed)+" cache volumes.") } http.Redirect(w, r, "/"+repo.Name+"/ci?"+encodeQuery(query), http.StatusFound) } // validSecretName is the identifier rule for CI secret names. var validSecretName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) func (s *Server) ciSecretCreate(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } name := strings.TrimSpace(r.FormValue("name")) value := r.FormValue("value") description := strings.TrimSpace(r.FormValue("description")) settings := "/" + repo.Name + "/settings" if !validSecretName.MatchString(name) { http.Redirect(w, r, settings+"?error="+ queryEscape("Secret name must be a valid identifier."), http.StatusFound) return } if value == "" { http.Redirect(w, r, settings+"?error="+ queryEscape("Secret value cannot be empty."), http.StatusFound) return } var desc *string if description != "" { desc = &description } if err := s.DB.UpsertCiSecret(r.Context(), repo.ID, name, value, desc); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.Redirect(w, r, settings+"?success="+queryEscape("Secret saved."), http.StatusFound) } func (s *Server) ciSecretDelete(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) if err == nil { if err := s.DB.DeleteCiSecret(r.Context(), id, repo.ID); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } } http.Redirect(w, r, "/"+repo.Name+"/settings?success="+ queryEscape("Secret deleted."), http.StatusFound) } func (s *Server) ciArtifactDownload(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } runID, err1 := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64) artifactID, err2 := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64) if err1 != nil || err2 != nil { http.Error(w, "Not found", http.StatusNotFound) return } artifact, err := s.DB.CiArtifactInRun(r.Context(), artifactID, runID, repo.ID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } // Only a file listed for this run is served, and its name must be a plain // file name, so a stored path cannot escape the run directory. if artifact == nil || artifact.Filename != filepath.Base(artifact.Filename) { http.Error(w, "Not found", http.StatusNotFound) return } path := filepath.Join(s.Cfg.CIArtifactsDir(), strconv.FormatInt(runID, 10), artifact.Filename) f, err := os.Open(path) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } defer f.Close() w.Header().Set("Content-Disposition", util.ContentDisposition("attachment", artifact.Filename)) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(artifact.Size, 10)) io.Copy(w, f) } // ciBadgeColors maps a run status to its badge colour. var ciBadgeColors = map[string]string{ "success": "#4c1", "warning": "#dfb317", "failure": "#e05d44", "running": "#007ec6", "pending": "#9f9f9f", "cancelled": "#9f9f9f", } // ciBadge serves the README status badge. Private repositories have no badge. func (s *Server) ciBadge(w http.ResponseWriter, r *http.Request) { repo, err := s.DB.RepoByName(r.Context(), chi.URLParam(r, "repo")) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if repo == nil || repo.IsPrivate { http.Error(w, "Not found", http.StatusNotFound) return } status, err := s.DB.LatestCiRunStatus(r.Context(), repo.ID) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if status == "" { status = "no builds" } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "no-cache") w.Write([]byte(ciBadgeSVG(status))) } // ciBadgeSVG draws the two-part badge. Widths are estimated from the text // length. func ciBadgeSVG(status string) string { color := ciBadgeColors[status] if color == "" { color = "#9f9f9f" } const label = "pipeline" labelWidth := len(label)*6 + 10 valueWidth := len(status)*6 + 10 totalWidth := labelWidth + valueWidth n := func(i int) string { return strconv.Itoa(i) } half := func(i int) string { return strconv.FormatFloat(float64(i)/2, 'g', -1, 64) } return ` ` + label + ` ` + label + ` ` + status + ` ` + status + ` ` }