Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b344bf5
docs(SRVOCF-1038): add build status design spec
matejvasek Aug 26, 2026
a8a09a6
docs: add SRVOCF-1038 build status implementation plan
matejvasek Aug 26, 2026
3a3ba23
feat(fakegithub): add scripted workflow run state and admin control
matejvasek Aug 26, 2026
9cf94a3
feat(scm): add LatestWorkflowRun via GitHub Actions
matejvasek Aug 26, 2026
bb72459
feat(handler): add build status snapshot and SSE watch endpoints
matejvasek Aug 26, 2026
19894f6
feat(frontend): add useBuildStatus SSE hook
matejvasek Aug 26, 2026
40c3cdb
feat(frontend): show build status in the functions list
matejvasek Aug 26, 2026
3929c18
test(e2e): cover build status Building and BuildFailed over SSE
matejvasek Aug 26, 2026
a014603
fix(frontend): restart build-status stream on connection change
matejvasek Aug 26, 2026
2f80a8e
docs: move build status plan to completed
matejvasek Aug 26, 2026
22d4a8a
feat(scm): cache GitHub responses via ETags to cut build-status rate-…
matejvasek Sep 1, 2026
b9e34d8
fix(frontend): reconnect build-status stream after a body-less response
matejvasek Sep 1, 2026
926141b
fix(scm): scope build status to the func-deploy workflow
matejvasek Sep 1, 2026
32b391d
fix(frontend): disable consoleFetch timeout on the build-status SSE s…
matejvasek Sep 1, 2026
dd1f4a0
fix(SRVOCF-1038): show build activity non-destructively over availabl…
matejvasek Sep 1, 2026
1f1c239
test(SRVOCF-1038): scope fakegithub by-file-name runs endpoint to the…
matejvasek Sep 1, 2026
741e1f3
fix(SRVOCF-1038): end build-status stream when the token is revoked m…
matejvasek Sep 1, 2026
8618601
fix(build): stop leaking internal fetch errors to the browser
matejvasek Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions backend/fakegithub/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import (
"net/http"
"slices"
"sort"
"strconv"
"strings"
"sync"

"golang.org/x/crypto/nacl/box"

"github.com/openshift/faas-console-plugin/backend/functions"
)

// User configures the authenticated identity returned by the fake server.
Expand All @@ -33,6 +36,36 @@ type repo struct {
Commits map[string]*commit
Refs map[string]string // "refs/heads/main" -> commit sha
Secrets map[string]string // name -> encrypted value
Runs []workflowRun // scripted GitHub Actions runs, most recent last
}

type workflowRun struct {
ID int64 `json:"id"`
HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"`
Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | timed_out | ""
HTMLURL string `json:"html_url"`
Jobs []workflowJob `json:"-"` // returned by the jobs endpoint, not the runs listing
// WorkflowFile is the workflow file this run belongs to. It is used only to
// scope the by-file-name runs endpoint (mirroring real GitHub); it is not
// part of the runs listing the client parses.
WorkflowFile string `json:"-"`
}

type workflowJob struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Steps []workflowStep `json:"steps"`
}

type workflowStep struct {
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Number int `json:"number"`
}

type treeEntry struct {
Expand Down Expand Up @@ -66,6 +99,8 @@ type Server struct {
pubKeyB64 string
keyID string

runIDSeq int64 // monotonic id source for scripted workflow runs

mux *http.ServeMux
}

Expand Down Expand Up @@ -136,9 +171,17 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/secrets/public-key", s.handleGetPublicKey)
s.mux.HandleFunc("PUT /repos/{owner}/{repo}/actions/secrets/{name}", s.handlePutSecret)

// Actions runs (build status). The client scopes build status to a single
// workflow file via the by-file-name endpoint, which filters runs to that
// workflow (mirroring real GitHub). The repo-wide endpoint returns every run.
s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs", s.handleListWorkflowRuns)
s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/workflows/{workflow}/runs", s.handleListWorkflowRuns)
s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", s.handleListWorkflowJobs)

// Admin API (for test setup)
s.mux.HandleFunc("POST /_admin/seed", s.handleAdminSeed)
s.mux.HandleFunc("POST /_admin/reset", s.handleAdminReset)
s.mux.HandleFunc("POST /_admin/actions/runs", s.handleAdminSetRun)
}

// --- GitHub API handlers ---
Expand Down Expand Up @@ -628,6 +671,74 @@ func (s *Server) handlePutSecret(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}

func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()

rp := s.getRepo(r)
if rp == nil {
writeError(w, http.StatusNotFound, "Not Found")
return
}

branch := r.URL.Query().Get("branch")
// workflow is set only on the by-file-name route; when present, scope runs to
// that workflow file the way real GitHub does. The repo-wide route leaves it
// empty and returns every run.
workflow := r.PathValue("workflow")
// GitHub returns most-recent first; our slice keeps most-recent last, so reverse.
var runs []workflowRun
for i := len(rp.Runs) - 1; i >= 0; i-- {
run := rp.Runs[i]
if branch != "" && run.HeadBranch != branch {
continue
}
if workflow != "" && run.WorkflowFile != workflow {
continue
}
runs = append(runs, run)
}
if runs == nil {
runs = []workflowRun{}
}
writeJSON(w, http.StatusOK, map[string]any{
"total_count": len(runs),
"workflow_runs": runs,
})
}

func (s *Server) handleListWorkflowJobs(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()

rp := s.getRepo(r)
if rp == nil {
writeError(w, http.StatusNotFound, "Not Found")
return
}

runID, err := strconv.ParseInt(r.PathValue("run_id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid run id")
return
}

for _, run := range rp.Runs {
if run.ID == runID {
jobs := run.Jobs
if jobs == nil {
jobs = []workflowJob{}
}
writeJSON(w, http.StatusOK, map[string]any{
"total_count": len(jobs),
"jobs": jobs,
})
return
}
}
writeError(w, http.StatusNotFound, "Not Found")
}

// --- Admin API handlers ---

type seedRequest struct {
Expand Down Expand Up @@ -689,6 +800,67 @@ func (s *Server) handleAdminReset(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "reset"})
}

type adminRunRequest struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Branch string `json:"branch"`
HeadSHA string `json:"headSha"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Jobs []workflowJob `json:"jobs"`
// Workflow is the workflow file the run belongs to. Defaults to the func
// build workflow; set it to script a run under a different workflow (e.g. to
// verify build-status queries stay scoped to the func workflow).
Workflow string `json:"workflow"`
}

func (s *Server) handleAdminSetRun(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()

var req adminRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid run request: "+err.Error())
return
}
if req.Branch == "" {
req.Branch = "main"
}
workflowFile := req.Workflow
if workflowFile == "" {
workflowFile = functions.WorkflowFilename
}

key := req.Owner + "/" + req.Repo
rp, ok := s.repos[key]
if !ok {
writeError(w, http.StatusNotFound, "repo not seeded: "+key)
return
}

s.runIDSeq++
run := workflowRun{
ID: s.runIDSeq,
HeadBranch: req.Branch,
HeadSHA: req.HeadSHA,
Status: req.Status,
Conclusion: req.Conclusion,
HTMLURL: fmt.Sprintf("https://github.com/%s/actions/runs/%d", key, s.runIDSeq),
Jobs: req.Jobs,
WorkflowFile: workflowFile,
}
// Replace the latest run for this branch, keep others.
kept := rp.Runs[:0:0]
for _, existing := range rp.Runs {
if existing.HeadBranch != req.Branch {
kept = append(kept, existing)
}
}
rp.Runs = append(kept, run)

writeJSON(w, http.StatusOK, map[string]any{"status": "run set", "id": run.ID})
}

// --- Helpers ---

func (s *Server) getRepo(r *http.Request) *repo {
Expand Down
161 changes: 161 additions & 0 deletions backend/fakegithub/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,157 @@ var _ = Describe("FakeGitHub Server", func() {
Expect(err).NotTo(HaveOccurred())
Expect(repos).To(BeEmpty())
})

It("stores a scripted workflow run via /_admin/actions/runs", func() {
ts, cl := startServer()
seedRepo(ts)

setWorkflowRun(ts, `{
"owner": "testuser", "repo": "test-func", "branch": "main",
"headSha": "abc123", "status": "in_progress", "conclusion": ""
}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).NotTo(BeNil())
Expect(run.Status).To(Equal("in_progress"))
Expect(run.HeadSHA).To(Equal("abc123"))
})

It("clears workflow runs on reset", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","status":"completed","conclusion":"success"}`)

resetFakeGitHub(ts)
seedRepo(ts)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).To(BeNil())
})
})

Describe("LatestWorkflowRun", func() {
It("returns nil when the repo has no runs", func() {
ts, cl := startServer()
seedRepo(ts)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).To(BeNil())
})

It("returns the latest in-progress run", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"main","headSha":"sha1","status":"in_progress","conclusion":""}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).NotTo(BeNil())
Expect(run.Status).To(Equal("in_progress"))
Expect(run.Conclusion).To(BeEmpty())
Expect(run.HeadSHA).To(Equal("sha1"))
Expect(run.HTMLURL).To(ContainSubstring("/actions/runs/"))
Expect(run.FailureReason).To(BeEmpty())
})

It("filters by branch", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{"owner":"testuser","repo":"test-func","branch":"other","status":"completed","conclusion":"success"}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).To(BeNil())
})

It("scopes to the func workflow file, ignoring runs of other workflows", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{
"owner":"testuser","repo":"test-func","branch":"main",
"status":"completed","conclusion":"success","workflow":"other.yaml"
}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).To(BeNil())
})

It("composes a failure reason from the first failed step", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{
"owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha",
"status":"completed","conclusion":"failure",
"jobs":[{
"id":1,"name":"build","status":"completed","conclusion":"failure",
"steps":[
{"name":"checkout","status":"completed","conclusion":"success","number":1},
{"name":"go test","status":"completed","conclusion":"failure","number":2}
]
}]
}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).NotTo(BeNil())
Expect(run.Conclusion).To(Equal("failure"))
Expect(run.FailureReason).To(Equal("build / go test"))
})

It("falls back to the job name when no step failed", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{
"owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha",
"status":"completed","conclusion":"failure",
"jobs":[{
"id":1,"name":"build","status":"completed","conclusion":"failure",
"steps":[
{"name":"checkout","status":"completed","conclusion":"success","number":1}
]
}]
}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).NotTo(BeNil())
Expect(run.Conclusion).To(Equal("failure"))
Expect(run.FailureReason).To(Equal("build"))
})

It("skips successful jobs and uses a later failing job", func() {
ts, cl := startServer()
seedRepo(ts)
setWorkflowRun(ts, `{
"owner":"testuser","repo":"test-func","branch":"main","headSha":"badsha",
"status":"completed","conclusion":"failure",
"jobs":[
{
"id":1,"name":"lint","status":"completed","conclusion":"success",
"steps":[
{"name":"eslint","status":"completed","conclusion":"success","number":1}
]
},
{
"id":2,"name":"test","status":"completed","conclusion":"failure",
"steps":[
{"name":"setup","status":"completed","conclusion":"success","number":1},
{"name":"unit tests","status":"completed","conclusion":"failure","number":2}
]
}
]
}`)

run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml")
Expect(err).NotTo(HaveOccurred())
Expect(run).NotTo(BeNil())
Expect(run.Conclusion).To(Equal("failure"))
Expect(run.FailureReason).To(Equal("test / unit tests"))
})
})
})

Expand Down Expand Up @@ -264,6 +415,16 @@ func seedRepo(ts *httptest.Server) {
resp.Body.Close()
}

func setWorkflowRun(ts *httptest.Server, body string) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/_admin/actions/runs", strings.NewReader(body))
ExpectWithOffset(1, err).NotTo(HaveOccurred())
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
ExpectWithOffset(1, err).NotTo(HaveOccurred())
ExpectWithOffset(1, resp.StatusCode).To(Equal(200))
resp.Body.Close()
}

func resetFakeGitHub(ts *httptest.Server) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/_admin/reset", nil)
ExpectWithOffset(1, err).NotTo(HaveOccurred())
Expand Down
Loading