From b344bf5fe34593e82a82a4905f2ec722edfff74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:02:55 +0200 Subject: [PATCH 01/18] docs(SRVOCF-1038): add build status design spec Design for surfacing GitHub Actions build status and pipeline failures in the functions list, via an SSE stream from the backend (polling GH Actions) read with consoleFetch. Covers the status merge with the existing cluster watch, new Building/BuildFailed statuses, parameterless user-scoped endpoints, fakegithub Actions API with /_admin control, and the test strategy. Co-Authored-By: Claude Opus 4.8 --- ...6-08-26-SRVOCF-1038-build-status-design.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 docs/design/2026-08-26-SRVOCF-1038-build-status-design.md diff --git a/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md new file mode 100644 index 00000000..5e5cd3a4 --- /dev/null +++ b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md @@ -0,0 +1,261 @@ +# SRVOCF-1038: Show deployment status and pipeline failures in the UI + +Status: Design (prototype) +Date: 2026-08-26 +Jira: SRVOCF-1038 (Story, parent SRVOCF-953) + +## Problem + +After "Save & Deploy", a function is pushed to GitHub, a GitHub Actions workflow +builds the image and deploys a Knative Service, and only then does the cluster +show status. Today function status comes entirely from `useCluster.ts` (a K8s +watch of the Knative Service + Deployment). That means: + +- The build window (queued, building, deploying) is invisible in the UI. +- A pipeline failure (compile error, image push error) is completely invisible: + the ksvc simply never appears and the row shows `NotDeployed` forever. + +This work surfaces GitHub Actions build status, including failure reasons, in the +functions list, and keeps the list current as a deployment progresses. + +## Scope (prototype) + +- Surface only in the **functions list** (no separate post-save detail view). +- Simple statuses only: build is **Building**, **Succeeded**, or **Failed**. + More granular states can come later. +- Live updates via **SSE**; plus a non-streaming snapshot endpoint. +- Deterministic testing via a scripted fake GitHub Actions API. + +## Lifecycle and status merge + +``` +push -> GH Actions run: queued -> in_progress -> completed(success|failure) + | + success -----------+--> ksvc -> Deployment ready -> Running + failure -----------/ (tracked by useCluster today) + (never reaches cluster; currently invisible) +``` + +GitHub Actions is authoritative during the build; the cluster is authoritative +after a successful deploy. The frontend merges the two per function: + +| GH Actions latest run | Cluster (useCluster) | Shown status | +|------------------------------|---------------------------|-----------------------| +| queued / in_progress | any | `Building` | +| completed = failure | not Running | `BuildFailed` | +| completed = success, or none | (fall through) | existing cluster status (`Deploying`/`Running`/`ScaledToZero`/`Error`/`NotDeployed`) | + +Notes: +- `Building` overrides a stale cluster status so a re-deploy is visible. +- `BuildFailed` carries a failure reason and a link to the failing run. +- The backend returns a build-centric status (`Building`/`Succeeded`/`Failed`/`None`); + the merge to `FunctionStatus` happens in the frontend, which is the only place + that also has the cluster status. + +## Transport decision: SSE over consoleFetch stream + +- Server-to-client push only, so SSE fits better than WebSocket (full-duplex we + would never use). +- The GitHub PAT lives only in the browser (sessionStorage) and reaches the + backend as the `X-SCM-Token` header. Both native `EventSource` and native + `WebSocket` cannot set custom headers, so they would force the PAT into a URL + query param or WS subprotocol. Reading an SSE stream with `consoleFetch` + + `ReadableStream` lets us send the PAT as a header cleanly. This is the same + pattern the OpenShift Lightspeed console plugin uses for streaming chat. +- SSE is plain `net/http` + `Flusher`: zero new backend dependencies, matching + the stdlib-only backend. WebSocket would need a third-party library. + +## Backend + +Stateless, matching the current design: no shared in-memory store (unlike the +removed SSE spike). Each request creates its own SCM client from the caller's +PAT; each SSE connection runs its own poll loop. + +### SCM interface (`backend/scm/client.go`) + +Add one method to `scm.Client`: + +```go +LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) +``` + +```go +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} +``` + +Returns `nil, nil` when the repo has no runs on that branch (maps to `None`). + +### GitHub implementation (`backend/scm/github/client.go`) + +- `Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, &ListWorkflowRunsOptions{Branch: branch, ...})`, + take the most recent run. +- On `conclusion == "failure"`, call `Actions.ListWorkflowJobs` and compose + `FailureReason` from the first failed job and its first failed step. +- Errors mapped through the existing `mapErr` (401/403 -> `scm.ErrUnauthorized`). + +### Endpoints (`backend/handler/build.go`) + +Both are **parameterless and user-scoped**, mirroring `GET /api/v1/func/list`. +They require only the `X-SCM-Token` header and discover the caller's function +repos server-side the same way the list endpoint does (`ListRepos`, +`topic:serverless-function user:`), then fetch `LatestWorkflowRun` per repo +using each repo's default branch (already returned by `ListRepos`). No owner/ +name/branch params, no per-function URLs, one connection for the whole list. The +snapshot keys (`owner/repo`) match what `listFunctions` returns, so the frontend +merge is a direct key lookup. + +`GET /api/v1/func/build/status` (snapshot): + +```json +{ + "functions": [ + { + "key": "matejvasek/fn-testing-a", + "buildStatus": "Failed", + "conclusion": "failure", + "runURL": "https://github.com/.../actions/runs/123", + "failureReason": "build / go test", + "headSHA": "abc123" + } + ] +} +``` + +`buildStatus` is one of `Building | Succeeded | Failed | None`, derived from the run: +- `queued` / `in_progress` -> `Building` +- `completed` + `success` -> `Succeeded` +- `completed` + `failure|cancelled|timed_out` -> `Failed` +- no run -> `None` + +`GET /api/v1/func/build/watch` (SSE): +- Headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, + `Connection: keep-alive`, `X-Accel-Buffering: no`. +- First event is a full snapshot (same shape as above), then a full snapshot is + re-sent whenever any function's build status changes. +- Heartbeat comment (`:\n\n`) every 15s to survive proxy idle timeouts. +- Poll interval ~3s: each cycle fetches `LatestWorkflowRun` for every discovered + repo and re-emits the snapshot if anything changed. The user's function set is + discovered on connect and refreshed on a slower cadence (~30s) so newly created + or deleted functions appear/disappear without reconnecting. +- Exit on `r.Context().Done()` or on write error (detects client disconnect + without TCP close). Both learnings from the spike (c5a1455, f7059667). + +## Fake GitHub (`backend/fakegithub`) + +Add the Actions API surface plus an admin control to script runs deterministically. + +GitHub API: +- `GET /repos/{owner}/{repo}/actions/runs` -> `{ total_count, workflow_runs: [...] }`, + filtered by `?branch=`. Each run: `id, head_branch, head_sha, status, conclusion, + html_url, created_at`. +- `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs` -> `{ total_count, jobs: [...] }`, + each job: `id, name, status, conclusion, steps: [{ name, status, conclusion, number }]`. + +Admin control: +- `POST /_admin/actions/runs` with `{ owner, repo, branch, headSha, status, conclusion, jobs: [...] }` + creates/replaces the latest run for a repo. Lets tests drive queued -> + in_progress -> failed transitions with exact control. +- `POST /_admin/reset` also clears runs. + +State: add a `runs` slice (or latest-run field) to the in-memory `repo` struct. + +## Frontend + +### Client hook (`src/common/clients/useBuildStatus.ts`) + +`useBuildStatus()` (no arguments; the backend scopes to the user) opens the SSE +stream with `consoleFetch` (PAT in `X-SCM-Token`), reads the `ReadableStream` +body, parses `event: build-status` frames, and returns +`ReadonlyMap` where + +```ts +interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} +``` + +Handles reconnect on stream end/error with a small backoff (EventSource's +built-in reconnect is not available with fetch streaming). + +### List integration (`src/pages/function-list/FunctionsListPage.tsx`) + +`useFunctionListPage` calls `useBuildStatus()` alongside +`useCluster(functionNames)` and merges per the table above in `enrichItem`, +looking up each item's build status by its `owner/repo` key. + +### Types and rendering + +- `FunctionStatus` gains `Building` and `BuildFailed`. +- `FunctionTableItem` gains optional `buildRunURL` and `failureReason`. +- `StatusCell` in `FunctionTable.tsx`: + - `Building` -> `ProgressStatus`. + - `BuildFailed` -> error style with a tooltip showing `failureReason` and a link + to `buildRunURL`. + +## Testing + +Follow `docs/TESTING.md` (red/green/refactor, one test at a time). + +Backend (Ginkgo/Gomega): +- `scm/github` client: `LatestWorkflowRun` happy path (in_progress, success) and + failure path (composes `FailureReason`). Use the existing github client test + harness; also manually cross-check against real GitHub repo + `matejvasek/fn-testing-a` (token in `gh-token.txt`) during development. +- `handler` build endpoints: snapshot maps runs to `buildStatus`; SSE emits an + initial snapshot then a new snapshot on change; `X-SCM-Token` required; error + mapping. Extend `scmStub` with a `LatestWorkflowRun` function field. +- `fakegithub`: the new Actions endpoints and `/_admin/actions/runs` (directly or + via the github client test that points at fakegithub). + +Frontend (Vitest + RTL): stub the network boundary, do not `vi.mock` our own +hook. Following the SRVOCF-822 precedent (the list test stubs +`useK8sWatchResource` and runs the real `useCluster`), add a reusable +`consoleFetchStreamStub` in `src/common/testing/` that returns a `Response` whose +body is a `ReadableStream` fed SSE frames. +- `useBuildStatus.test`: real hook against the stream stub; asserts frame parsing, + the returned map, and reconnect. +- `FunctionsListPage.test` / `FunctionTable.test`: real `useBuildStatus` via the + same stub (alongside the existing K8s stub); asserts the `Building`/`BuildFailed` + merge and rendering. This catches SSE-payload vs consumer shape drift. + +Pragmatic fallback if streaming in jsdom proves fiddly: stub `consoleFetch` +directly (still the boundary), never mock `useBuildStatus` itself. + +E2e (Playwright): run against the **real backend connected to fakegithub**, no +`page.route` mocking (e2e no longer mocks GitHub; it seeds/resets fakegithub via +`/_admin` in `e2e/helpers/fakegithub.ts`). Add a `setWorkflowRun(owner, name, +branch, run)` helper that POSTs to `/_admin/actions/runs`. A test seeds a repo, +scripts an `in_progress` run, loads the list and asserts the status column shows +`Building`, then scripts a `completed`/`failure` run and asserts the column +updates to `BuildFailed` with the failure reason and a link to the run. Because +the list streams over SSE, the update should appear without a manual refresh +(use `expect.poll` / `toBeVisible` with a timeout). + +## Implementation order + +1. fakegithub Actions endpoints + `/_admin/actions/runs` (foundation for tests + and manual cross-check). +2. `scm.Client.LatestWorkflowRun` + github implementation + unit tests; cross-check + against real GitHub. +3. Backend snapshot + SSE endpoints + handler tests; wire routes in `main.go`. +4. Frontend `useBuildStatus` hook, list merge, new statuses, `StatusCell` + + component tests. +5. E2e test. +6. Revisit backend for anything the frontend surfaces. + +## Out of scope (prototype) + +- Faithful workflow execution via `act` (deferred; `/_admin` scripting instead). +- Live streaming on any surface other than the list. +- Granular per-step progress beyond Building/Succeeded/Failed. +- Persisting build history. From a8a09a6bc224fb0a072b7736c02a1e0dabfcbf08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:12:39 +0200 Subject: [PATCH 02/18] docs: add SRVOCF-1038 build status implementation plan Co-Authored-By: Claude Opus 4.8 --- .../2026-08-26-SRVOCF-1038-build-status.md | 1748 +++++++++++++++++ 1 file changed, 1748 insertions(+) create mode 100644 docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md diff --git a/docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md b/docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md new file mode 100644 index 00000000..e212a3ab --- /dev/null +++ b/docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md @@ -0,0 +1,1748 @@ +# SRVOCF-1038: Build Status and Pipeline Failures Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface GitHub Actions build status (Building / Succeeded / Failed, with failure reasons) in the functions list, live-updated over SSE. + +**Architecture:** A new `LatestWorkflowRun` method on `scm.Client` (implemented for GitHub, faked in `fakegithub`) feeds two stateless, user-scoped backend endpoints: a JSON snapshot (`GET /api/v1/func/build/status`) and an SSE stream (`GET /api/v1/func/build/watch`) that re-emits the snapshot on change. The frontend `useBuildStatus()` hook reads the SSE stream via `consoleFetch` and the list page merges the build status onto the existing cluster status. + +**Tech Stack:** Go stdlib `net/http` + go-github v72 + Ginkgo/Gomega (backend); React + TypeScript + PatternFly 6 + Vitest/RTL (frontend); Playwright (e2e). + +**Design spec:** `docs/design/2026-08-26-SRVOCF-1038-build-status-design.md` + +--- + +## File Structure + +**Backend (create/modify):** +- `backend/fakegithub/server.go` (modify): add `runs` state, Actions endpoints, `POST /_admin/actions/runs`. +- `backend/fakegithub/server_test.go` (modify): tests for the Actions endpoints via the github client. +- `backend/scm/client.go` (modify): add `WorkflowRun` type and `LatestWorkflowRun` to the `Client` interface. +- `backend/scm/github/client.go` (modify): implement `LatestWorkflowRun` + failure-reason composition. +- `backend/handler/build.go` (create): snapshot + SSE handlers and shared helpers. +- `backend/handler/build_test.go` (create): handler tests. +- `backend/handler/handler_test.go` (modify): add `latestWorkflowRun` field to `scmStub`. +- `backend/main.go` (modify): wire the two new routes. + +**Frontend (create/modify):** +- `src/common/types.ts` (modify): extend `FunctionStatus`, add `BuildStatus`. +- `src/common/clients/useBuildStatus.ts` (create): the SSE client hook. +- `src/common/clients/useBuildStatus.test.tsx` (create): hook test. +- `src/common/testing/consoleFetchStreamStub.ts` (create): SSE stream stub. +- `src/pages/function-list/FunctionsListPage.tsx` (modify): call `useBuildStatus()` and merge. +- `src/pages/function-list/FunctionsListPage.test.tsx` (modify): wire the stream stub. +- `src/pages/function-list/components/FunctionTable.tsx` (modify): `Building`/`BuildFailed` rendering. + +**E2e (create/modify):** +- `e2e/helpers/fakegithub.ts` (modify): add `setWorkflowRun` helper. +- `e2e/use-cases/build-status/build-status.test.ts` (create): end-to-end test. + +--- + +## Phase 1: Fake GitHub Actions API + +### Task 1: Fake GitHub run state + admin control + +**Files:** +- Modify: `backend/fakegithub/server.go` +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/fakegithub/server_test.go`, inside `Describe("Admin API", ...)` (after the existing `It`): + +```go + 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") + 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") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) +``` + +Add this helper next to `seedRepo` in the same file: + +```go +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() +} +``` + +> Note: this test also depends on `LatestWorkflowRun` (Task 4/5). It will not compile until those exist. If executing strictly one task at a time, temporarily assert via a raw HTTP GET to `/repos/testuser/test-func/actions/runs` instead, then switch to `cl.LatestWorkflowRun` after Task 5. The recommended path is to implement Tasks 1-5 as a group, running the fakegithub suite once `LatestWorkflowRun` lands. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: FAIL (compile error: `LatestWorkflowRun` undefined and/or 404 on `/_admin/actions/runs`). + +- [ ] **Step 3: Add run state to the `repo` struct and Server** + +In `backend/fakegithub/server.go`, add fields to `repo`: + +```go +type repo struct { + Owner string + Name string + DefaultBranch string + Topics []string + Files map[string]string // path -> content (source of truth) + Blobs map[string]string // sha -> content + Trees map[string][]treeEntry + 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 +} +``` + +Add the run/job/step types (near the `commit` type): + +```go +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 +} + +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"` +} +``` + +Add a monotonic run-ID counter to `Server`: + +```go +type Server struct { + mu sync.Mutex + user User + pat string // required PAT for API routes (empty = no auth check) + repos map[string]*repo // "owner/name" -> repo + + pubKey [32]byte + privKey [32]byte + pubKeyB64 string + keyID string + + runIDSeq int64 // monotonic id source for scripted workflow runs + + mux *http.ServeMux +} +``` + +- [ ] **Step 4: Register routes and add the admin handler** + +In `routes()`, add under the Actions secrets section: + +```go + // Actions runs (build status) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs", s.handleListWorkflowRuns) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", s.handleListWorkflowJobs) +``` + +And under the Admin API section: + +```go + s.mux.HandleFunc("POST /_admin/actions/runs", s.handleAdminSetRun) +``` + +Add the admin handler (near `handleAdminSeed`): + +```go +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"` +} + +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" + } + + 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, + } + // 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}) +} +``` + +Also clear runs in `handleAdminReset` (it already resets the whole `s.repos` map, so runs are cleared automatically; no change needed there since runs live on `repo`). + +- [ ] **Step 5: Run test to verify admin storage passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: the two new admin specs still fail to compile until Tasks 4-5 land `LatestWorkflowRun`; the Actions GET endpoints (Task 2/3) are exercised by Task 5's client. Proceed to Task 2. + +- [ ] **Step 6: Commit** (after Tasks 1-3 in this phase compile together) + +```bash +git add backend/fakegithub/server.go backend/fakegithub/server_test.go +git commit -m "feat(fakegithub): add scripted workflow run state and admin control" +``` + +### Task 2: Fake GitHub `GET .../actions/runs` + +**Files:** +- Modify: `backend/fakegithub/server.go` + +- [ ] **Step 1: Add the runs listing handler** + +```go +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") + // 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 { + runs = append(runs, run) + } + } + if runs == nil { + runs = []workflowRun{} + } + writeJSON(w, http.StatusOK, map[string]any{ + "total_count": len(runs), + "workflow_runs": runs, + }) +} +``` + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +### Task 3: Fake GitHub `GET .../actions/runs/{run_id}/jobs` + +**Files:** +- Modify: `backend/fakegithub/server.go` (add `strconv` import) + +- [ ] **Step 1: Add the jobs listing handler** + +```go +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") +} +``` + +Add `"strconv"` to the import block. + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +--- + +## Phase 2: SCM `LatestWorkflowRun` + +### Task 4: Extend the `scm.Client` interface + +**Files:** +- Modify: `backend/scm/client.go` +- Modify: `backend/handler/handler_test.go` (keep `scmStub` implementing the interface) + +- [ ] **Step 1: Add the type and interface method** + +In `backend/scm/client.go`, add to the `Client` interface (after `DeleteRepo`): + +```go + LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) +``` + +And add the type (after the `FileEntry` type): + +```go +// WorkflowRun is the latest GitHub Actions run for a repo branch. +// A nil *WorkflowRun means the branch has no runs. +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} +``` + +- [ ] **Step 2: Add the stub field so tests still compile** + +In `backend/handler/handler_test.go`, add to `scmStub`: + +```go + latestWorkflowRun func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) +``` + +And add the method (after `DeleteRepo`): + +```go +func (s *scmStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + if s.latestWorkflowRun != nil { + return s.latestWorkflowRun(ctx, owner, repo, branch) + } + return nil, nil +} +``` + +- [ ] **Step 3: Verify it fails to build** — `cd backend && go build ./...` +Expected: FAIL — `*ghClient` does not implement `scm.Client` (missing `LatestWorkflowRun`). Fixed in Task 5. + +### Task 5: GitHub `LatestWorkflowRun` (happy path) + +**Files:** +- Modify: `backend/scm/github/client.go` (add `log/slog` import) +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/fakegithub/server_test.go` a new top-level `Describe` (before the closing of the outer `Describe`): + +```go + 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") + 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") + 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") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: FAIL (compile error until the method exists). + +- [ ] **Step 3: Implement `LatestWorkflowRun`** + +In `backend/scm/github/client.go`, add `"log/slog"` to the imports, then add: + +```go +func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + opts := &ghlib.ListWorkflowRunsOptions{ + Branch: branch, + ListOptions: ghlib.ListOptions{PerPage: 1}, + } + runs, _, err := c.client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("list workflow runs for %s/%s: %w", owner, repo, mapErr(err)) + } + if len(runs.WorkflowRuns) == 0 { + return nil, nil + } + + run := runs.WorkflowRuns[0] + result := &scm.WorkflowRun{ + ID: run.GetID(), + Status: run.GetStatus(), + Conclusion: run.GetConclusion(), + HeadSHA: run.GetHeadSHA(), + HTMLURL: run.GetHTMLURL(), + } + if result.Conclusion == "failure" { + result.FailureReason = c.failureReason(ctx, owner, repo, result.ID) + } + return result, nil +} + +// failureReason returns a " / " summary of the first failed step, +// or the failing job name, or "" if it cannot be determined. Best-effort: never +// fails the caller. +func (c *ghClient) failureReason(ctx context.Context, owner, repo string, runID int64) string { + jobs, _, err := c.client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, nil) + if err != nil { + slog.Warn("failed to list workflow jobs", "repo", owner+"/"+repo, "run", runID, "err", err) + return "" + } + for _, job := range jobs.Jobs { + if job.GetConclusion() != "failure" { + continue + } + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + return job.GetName() + " / " + step.GetName() + } + } + return job.GetName() + } + return "" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: PASS (all `LatestWorkflowRun` and admin specs green). + +- [ ] **Step 5: Commit** + +```bash +git add backend/scm/client.go backend/scm/github/client.go backend/handler/handler_test.go backend/fakegithub/server_test.go +git commit -m "feat(scm): add LatestWorkflowRun with GitHub Actions implementation" +``` + +### Task 6: GitHub `LatestWorkflowRun` failure reason + +**Files:** +- Test: `backend/fakegithub/server_test.go` + +- [ ] **Step 1: Write the failing test** + +Add inside the `Describe("LatestWorkflowRun", ...)` block: + +```go + 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") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("build / go test")) + }) +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd backend && go test ./fakegithub/ -run TestFakeGitHub` +Expected: PASS (implementation from Task 5 already composes the reason). + +- [ ] **Step 3: Commit** + +```bash +git add backend/fakegithub/server_test.go +git commit -m "test(scm): cover workflow-run failure reason composition" +``` + +> Manual cross-check (not automated): with the token in `gh-token.txt`, point a scratch program or a temporary test at real GitHub repo `matejvasek/fn-testing-a` and confirm `LatestWorkflowRun` returns sane values against the live Actions API. Do not commit the token or a live test. + +--- + +## Phase 3: Backend build endpoints + +### Task 7: Snapshot endpoint `GET /api/v1/func/build/status` + +**Files:** +- Create: `backend/handler/build.go` +- Test: `backend/handler/build_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `backend/handler/build_test.go`: + +```go +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/faas-console-plugin/backend/scm" +) + +var _ = Describe("HandleBuildStatus", func() { + It("returns 401 without an SCM token", func() { + withSCMStub(&scmStub{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("maps a run to a build-status item keyed by owner/repo", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress", HeadSHA: "sha1", HTMLURL: "u"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(1)) + Expect(snap.Functions[0].Key).To(Equal("alice/fn")) + Expect(snap.Functions[0].BuildStatus).To(Equal("Building")) + }) + + It("reports None when a repo has no runs", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return nil, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions[0].BuildStatus).To(Equal("None")) + }) + + It("returns 401 when the SCM token is rejected", func() { + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return nil, scm.ErrUnauthorized + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./handler/ -run TestHandler` +Expected: FAIL (compile error: `HandleBuildStatus`, `buildSnapshot` undefined). + +> The handler suite entrypoint already exists (Ginkgo `RunSpecs`). If the run name differs, run `cd backend && go test ./handler/`. + +- [ ] **Step 3: Create `backend/handler/build.go`** + +```go +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/openshift/faas-console-plugin/backend/config" + "github.com/openshift/faas-console-plugin/backend/scm" +) + +// Tunable so tests can drive the SSE loop quickly. +var ( + buildPollInterval = 3 * time.Second + buildRediscoverInterval = 30 * time.Second + buildHeartbeatInterval = 15 * time.Second +) + +type buildStatusItem struct { + Key string `json:"key"` + BuildStatus string `json:"buildStatus"` // Building | Succeeded | Failed | None + Conclusion string `json:"conclusion,omitempty"` + RunURL string `json:"runURL,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + HeadSHA string `json:"headSHA,omitempty"` +} + +type buildSnapshot struct { + Functions []buildStatusItem `json:"functions"` +} + +func deriveBuildStatus(run *scm.WorkflowRun) string { + if run == nil { + return "None" + } + switch run.Status { + case "queued", "in_progress": + return "Building" + case "completed": + if run.Conclusion == "success" { + return "Succeeded" + } + return "Failed" + default: + return "None" + } +} + +func toBuildStatusItem(key string, run *scm.WorkflowRun) buildStatusItem { + item := buildStatusItem{Key: key, BuildStatus: deriveBuildStatus(run)} + if run != nil { + item.Conclusion = run.Conclusion + item.RunURL = run.HTMLURL + item.FailureReason = run.FailureReason + item.HeadSHA = run.HeadSHA + } + return item +} + +// buildStatusSnapshot fetches the latest run for each repo and builds a sorted snapshot. +func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Repo) buildSnapshot { + items := make([]buildStatusItem, len(repos)) + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(10) + for i, repo := range repos { + g.Go(func() error { + key := repo.Owner + "/" + repo.Name + run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch) + if err != nil { + slog.Warn("failed to get workflow run", "repo", key, "err", err) + items[i] = buildStatusItem{Key: key, BuildStatus: "None"} + return nil + } + items[i] = toBuildStatusItem(key, run) + return nil + }) + } + _ = g.Wait() + sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) + return buildSnapshot{Functions: items} +} + +func (h *Handlers) HandleBuildStatus(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + + repos, err := client.ListRepos(r.Context()) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build status: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + snap := buildStatusSnapshot(r.Context(), client, repos) + writeJSON(w, http.StatusOK, snap) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./handler/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/handler/build.go backend/handler/build_test.go +git commit -m "feat(handler): add build status snapshot endpoint" +``` + +### Task 8: SSE endpoint `GET /api/v1/func/build/watch` + +**Files:** +- Modify: `backend/handler/build.go` +- Test: `backend/handler/build_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `backend/handler/build_test.go` (add imports `"bufio"`, `"strings"`, `"sync"`, `"time"`): + +```go +var _ = Describe("HandleBuildWatch", func() { + It("emits an initial snapshot then a new snapshot on change", func() { + origPoll := buildPollInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildHeartbeatInterval = origHeartbeat + }) + + var mu sync.Mutex + calls := 0 + withSCMStub(&scmStub{ + listRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + latestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return &scm.WorkflowRun{Status: "completed", Conclusion: "failure", FailureReason: "build / test"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.Header.Get("Content-Type")).To(Equal("text/event-stream")) + + reader := bufio.NewReader(resp.Body) + first := readSSEData(reader) + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + second := readSSEData(reader) + Expect(second).To(ContainSubstring(`"buildStatus":"Failed"`)) + Expect(second).To(ContainSubstring(`"failureReason":"build / test"`)) + }) + + It("returns 401 without an SCM token", func() { + withSCMStub(&scmStub{}) + req := httptest.NewRequest(http.MethodGet, "/watch", nil) + w := httptest.NewRecorder() + (&Handlers{}).HandleBuildWatch(w, req) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) +}) + +// readSSEData reads frames until it finds one with a data: line and returns that payload. +func readSSEData(reader *bufio.Reader) string { + var data []string + for { + line, err := reader.ReadString('\n') + if err != nil { + return strings.Join(data, "\n") + } + line = strings.TrimRight(line, "\n") + if line == "" { + if len(data) > 0 { + return strings.Join(data, "\n") + } + continue // heartbeat or blank separator, keep reading + } + if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./handler/` +Expected: FAIL (compile error: `HandleBuildWatch` undefined). + +- [ ] **Step 3: Implement `HandleBuildWatch`** + +Append to `backend/handler/build.go`: + +```go +func (h *Handlers) HandleBuildWatch(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + ctx := r.Context() + + // Discover repos before switching to SSE so auth failures return a normal status. + repos, err := client.ListRepos(ctx) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build watch: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + snap := buildStatusSnapshot(ctx, client, repos) + if err := writeSnapshotEvent(w, snap); err != nil { + return + } + flusher.Flush() + prev := snapshotKey(snap) + + poll := time.NewTicker(buildPollInterval) + defer poll.Stop() + rediscover := time.NewTicker(buildRediscoverInterval) + defer rediscover.Stop() + heartbeat := time.NewTicker(buildHeartbeatInterval) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + if _, err := io.WriteString(w, ":\n\n"); err != nil { + return + } + flusher.Flush() + case <-rediscover.C: + if latest, err := client.ListRepos(ctx); err != nil { + slog.Warn("build watch: rediscover failed", "err", err) + } else { + repos = latest + } + case <-poll.C: + next := buildStatusSnapshot(ctx, client, repos) + key := snapshotKey(next) + if key == prev { + continue + } + prev = key + if err := writeSnapshotEvent(w, next); err != nil { + return + } + flusher.Flush() + } + } +} + +func writeSnapshotEvent(w io.Writer, snap buildSnapshot) error { + data, err := json.Marshal(snap) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "event: build-status\ndata: %s\n\n", data) + return err +} + +func snapshotKey(snap buildSnapshot) string { + data, _ := json.Marshal(snap) + return string(data) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && go test ./handler/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/handler/build.go backend/handler/build_test.go +git commit -m "feat(handler): add build status SSE watch endpoint" +``` + +### Task 9: Wire routes in `main.go` + +**Files:** +- Modify: `backend/main.go` + +- [ ] **Step 1: Add the routes** + +After the `mux.HandleFunc("POST /api/v1/func/create", ...)` line, add: + +```go + mux.HandleFunc("GET /api/v1/func/build/status", h.HandleBuildStatus) + mux.HandleFunc("GET /api/v1/func/build/watch", h.HandleBuildWatch) +``` + +- [ ] **Step 2: Verify build** — `cd backend && go build ./...` → PASS. + +- [ ] **Step 3: Commit** + +```bash +git add backend/main.go +git commit -m "feat(backend): wire build status routes" +``` + +> Ask the user to restart the dev environment (`hack/dev.sh`) before manual verification; you cannot restart it yourself. + +--- + +## Phase 4: Frontend + +### Task 10: Types + +**Files:** +- Modify: `src/common/types.ts` + +- [ ] **Step 1: Extend `FunctionStatus`** + +Replace the `FunctionStatus` union with: + +```ts +export type FunctionStatus = + | 'CreatingRepo' + | 'Pushing' + | 'PushedToGitHub' + | 'Building' + | 'Deploying' + | 'Running' + | 'ScaledToZero' + | 'Error' + | 'BuildFailed' + | 'Unknown' + | 'NotDeployed'; +``` + +- [ ] **Step 2: Add the `BuildStatus` type** + +Append to `src/common/types.ts`: + +```ts +export interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} +``` + +- [ ] **Step 3: Verify typecheck** — `npm run type-check` (or `npx tsc --noEmit`) → PASS. + +### Task 11: `consoleFetchStreamStub` testing helper + +**Files:** +- Create: `src/common/testing/consoleFetchStreamStub.ts` + +- [ ] **Step 1: Create the stub** + +```ts +// Test double for the SSE stream consumed by useBuildStatus. +// Mirrors the setFixtures pattern in useK8sWatchResourceStub.ts: +// a module-level fixture that tests set, and a stub function wired into +// the mocked consoleFetch. + +let frames: string[] = []; +let keepOpen = false; + +export function setStreamFrames(newFrames: string[], opts?: { keepOpen?: boolean }) { + frames = newFrames; + keepOpen = opts?.keepOpen ?? false; +} + +export function resetStreamFrames() { + frames = []; + keepOpen = false; +} + +// buildStatusFrame formats a single SSE build-status event. +export function buildStatusFrame(functions: unknown[]): string { + return `event: build-status\ndata: ${JSON.stringify({ functions })}\n\n`; +} + +// consoleFetchStub matches the consoleFetch signature used by useBuildStatus: +// consoleFetch(url, options) -> Promise. +export const consoleFetchStub = (_url: string, options?: RequestInit): Promise => { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) { + controller.enqueue(encoder.encode(frame)); + } + if (!keepOpen) { + controller.close(); + return; + } + const signal = options?.signal; + if (signal) { + signal.addEventListener('abort', () => { + try { + controller.close(); + } catch { + // already closed + } + }); + } + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); +}; +``` + +- [ ] **Step 2: Verify typecheck** — `npx tsc --noEmit` → PASS. + +### Task 12: `useBuildStatus` hook + +**Files:** +- Create: `src/common/clients/useBuildStatus.ts` +- Test: `src/common/clients/useBuildStatus.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/common/clients/useBuildStatus.test.tsx`: + +```tsx +import { renderHook, waitFor } from '@testing-library/react'; +import { PAT_KEY } from '../types'; + +const streamStub = await vi.hoisted( + async () => import('../testing/consoleFetchStreamStub'), +); + +vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + consoleFetch: streamStub.consoleFetchStub, +})); + +import { useBuildStatus } from './useBuildStatus'; + +describe('useBuildStatus', () => { + beforeEach(() => { + sessionStorage.setItem(PAT_KEY, 'test-pat'); + streamStub.resetStreamFrames(); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + it('parses a build-status frame into a keyed map', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { key: 'alice/fn', buildStatus: 'Building' }, + { key: 'alice/gn', buildStatus: 'Failed', failureReason: 'build / test', runURL: 'u' }, + ]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(2)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Building'); + expect(result.current.get('alice/gn')?.failureReason).toBe('build / test'); + }); + + it('ignores heartbeat comment frames', async () => { + streamStub.setStreamFrames([ + ':\n\n', + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Succeeded' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Succeeded'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/common/clients/useBuildStatus.test.tsx` +Expected: FAIL (cannot resolve `./useBuildStatus`). + +- [ ] **Step 3: Create `src/common/clients/useBuildStatus.ts`** + +```ts +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect, useState } from 'react'; +import { BuildStatus, PAT_KEY, PROXY_BASE } from '../types'; + +const RECONNECT_DELAY_MS = 3000; + +interface BuildStatusItem { + key: string; + buildStatus: BuildStatus['buildStatus']; + conclusion?: string; + runURL?: string; + failureReason?: string; + headSHA?: string; +} + +interface BuildSnapshot { + functions: BuildStatusItem[]; +} + +// useBuildStatus streams per-function GitHub Actions build status over SSE and +// returns it keyed by "owner/repo". The backend scopes the stream to the +// authenticated user, so no arguments are required. +export function useBuildStatus(): ReadonlyMap { + const [statuses, setStatuses] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function run() { + while (!cancelled) { + const pat = sessionStorage.getItem(PAT_KEY); + if (!pat) return; + try { + const res = await consoleFetch(`${PROXY_BASE}/api/v1/func/build/watch`, { + headers: { 'X-SCM-Token': pat }, + signal: controller.signal, + }); + if (!res.body) return; + await readStream(res.body, (snap) => { + if (!cancelled) setStatuses(toMap(snap)); + }); + } catch { + if (cancelled) return; + } + // Stream ended or errored; back off, then reconnect. + await delay(RECONNECT_DELAY_MS, controller.signal); + } + } + + run(); + return () => { + cancelled = true; + controller.abort(); + }; + }, []); + + return statuses; +} + +async function readStream( + body: ReadableStream, + onSnapshot: (snap: BuildSnapshot) => void, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const snap = parseFrame(frame); + if (snap) onSnapshot(snap); + } + } +} + +function parseFrame(frame: string): BuildSnapshot | null { + let event = ''; + const dataLines: string[] = []; + for (const line of frame.split('\n')) { + if (line.startsWith(':')) continue; // heartbeat / comment + if (line.startsWith('event:')) event = line.slice('event:'.length).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim()); + } + if (event && event !== 'build-status') return null; + if (dataLines.length === 0) return null; + try { + return JSON.parse(dataLines.join('\n')) as BuildSnapshot; + } catch { + return null; + } +} + +function toMap(snap: BuildSnapshot): ReadonlyMap { + return new Map( + (snap.functions ?? []).map((f) => [ + f.key, + { + buildStatus: f.buildStatus, + conclusion: f.conclusion, + runURL: f.runURL, + failureReason: f.failureReason, + }, + ]), + ); +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const id = setTimeout(resolve, ms); + signal.addEventListener('abort', () => { + clearTimeout(id); + resolve(); + }); + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/common/clients/useBuildStatus.test.tsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/common/types.ts src/common/testing/consoleFetchStreamStub.ts src/common/clients/useBuildStatus.ts src/common/clients/useBuildStatus.test.tsx +git commit -m "feat(frontend): add useBuildStatus SSE hook" +``` + +### Task 13: List page merge + +**Files:** +- Modify: `src/pages/function-list/components/FunctionTable.tsx` (add fields to `FunctionTableItem`) +- Modify: `src/pages/function-list/FunctionsListPage.tsx` + +- [ ] **Step 1: Extend `FunctionTableItem`** + +In `src/pages/function-list/components/FunctionTable.tsx`, extend the interface: + +```ts +export interface FunctionTableItem { + name: string; + repoName: string; + owner: string; + runtime: string; + status: FunctionStatus; + url: string; + replicas: number; + namespace: string; + mainResource?: K8sResourceCommon; + buildRunURL?: string; + failureReason?: string; +} +``` + +- [ ] **Step 2: Wire `useBuildStatus` into the list hook** + +In `src/pages/function-list/FunctionsListPage.tsx`: + +Add imports: + +```ts +import { useBuildStatus } from '../../common/clients/useBuildStatus'; +import { BuildStatus, ClusterFunction, FunctionListItem } from '../../common/types'; +``` + +(Replace the existing `ClusterFunction, FunctionListItem` import line with the one above.) + +In `newItem`, carry `owner`: + +```ts +function newItem(item: FunctionListItem): FunctionTableItem { + return { + name: item.name || item.repoName, + repoName: item.repoName, + owner: item.owner, + namespace: item.namespace, + runtime: item.runtime, + status: item.err ? 'Error' : 'NotDeployed', + url: '', + replicas: 0, + }; +} +``` + +Replace the `functions` memo (and add the `buildStatuses` call just above it): + +```ts + const { functions: clusterFunctions, loaded: clusterLoaded } = useCluster(functionNames); + const buildStatuses = useBuildStatus(); + + const functions = useMemo( + () => + functionItems.map((item) => { + const cf = clusterFunctions.get(item.name); + const enriched = cf ? enrichItem(item, cf) : item; + const build = buildStatuses.get(`${item.owner}/${item.repoName}`); + return build ? mergeBuild(enriched, build) : enriched; + }), + [functionItems, clusterFunctions, buildStatuses], + ); +``` + +Add the merge helper (next to `enrichItem`): + +```ts +function mergeBuild(item: FunctionTableItem, build: BuildStatus): FunctionTableItem { + if (build.buildStatus === 'Building') { + return { ...item, status: 'Building' }; + } + if (build.buildStatus === 'Failed' && item.status !== 'Running') { + return { + ...item, + status: 'BuildFailed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: fall through to the cluster-derived status. + return item; +} +``` + +- [ ] **Step 3: Verify typecheck** — `npx tsc --noEmit` → PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/function-list/FunctionsListPage.tsx src/pages/function-list/components/FunctionTable.tsx +git commit -m "feat(frontend): merge build status into the functions list" +``` + +### Task 14: `StatusCell` rendering + list test + +**Files:** +- Modify: `src/pages/function-list/components/FunctionTable.tsx` +- Modify: `src/pages/function-list/FunctionsListPage.test.tsx` + +- [ ] **Step 1: Write the failing list test** + +In `src/pages/function-list/FunctionsListPage.test.tsx`, add a hoisted stream stub import next to the existing `clusterStub` hoist: + +```tsx +const streamStub = await vi.hoisted( + async () => import('../../common/testing/consoleFetchStreamStub'), +); +``` + +Add `consoleFetch` to the mocked SDK (inside the `@openshift-console/dynamic-plugin-sdk` factory's returned object, alongside `consoleFetchJSON`): + +```tsx + consoleFetch: streamStub.consoleFetchStub, +``` + +Reset frames in `beforeEach` (after `authenticateGithubFake()`): + +```tsx + streamStub.resetStreamFrames(); +``` + +Add a test (inside `describe('FunctionsListPage', ...)`): + +```tsx + it('shows BuildFailed with the failure reason from the build stream', async () => { + listFunctionsStub({ response: repoListItem(funcName) }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Error: BuildFailed')).toBeInTheDocument(); + }); +``` + +> Confirm `repoListItem`'s owner is `twoGiants` (the fake auth login). If it differs, use the actual owner from `repoListItem` when building the key. Read the existing `repoListItem` helper in this test file before writing the key. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/pages/function-list/FunctionsListPage.test.tsx` +Expected: FAIL (no `Error: BuildFailed` text; `StatusCell` has no `BuildFailed` case). + +- [ ] **Step 3: Update `StatusCell`** + +In `src/pages/function-list/components/FunctionTable.tsx`: + +Add `Tooltip` to the PatternFly import: + +```ts +import { ActionList, ActionListItem, Button, Tooltip } from '@patternfly/react-core'; +``` + +Change the `StatusCell` invocation in the table body to pass the item: + +```tsx + + + +``` + +Replace `StatusCell`: + +```tsx +function StatusCell({ + status, + failureReason, + buildRunURL, +}: { + status: FunctionStatus; + failureReason?: string; + buildRunURL?: string; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + + switch (status) { + case 'Running': + return ; + case 'Building': + case 'Deploying': + case 'CreatingRepo': + case 'Pushing': + case 'PushedToGitHub': + return ; + case 'Error': + return ; + case 'BuildFailed': { + const badge = ; + const withLink = buildRunURL ? ( + + {badge} + + ) : ( + badge + ); + return {withLink}; + } + case 'ScaledToZero': + case 'NotDeployed': + return ; + case 'Unknown': + return } />; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/pages/function-list/` +Expected: PASS (new test and existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/function-list/components/FunctionTable.tsx src/pages/function-list/FunctionsListPage.test.tsx +git commit -m "feat(frontend): render Building and BuildFailed statuses" +``` + +--- + +## Phase 5: E2e + +### Task 15: `setWorkflowRun` helper + build-status e2e test + +**Files:** +- Modify: `e2e/helpers/fakegithub.ts` +- Create: `e2e/use-cases/build-status/build-status.test.ts` + +- [ ] **Step 1: Add the `setWorkflowRun` helper** + +Append to `e2e/helpers/fakegithub.ts`: + +```ts +interface WorkflowStep { + name: string; + status: string; + conclusion: string; + number: number; +} + +interface WorkflowJob { + id: number; + name: string; + status: string; + conclusion: string; + steps: WorkflowStep[]; +} + +interface WorkflowRunInput { + headSha?: string; + status: string; // queued | in_progress | completed + conclusion?: string; // success | failure | ... + jobs?: WorkflowJob[]; +} + +export async function setWorkflowRun( + owner: string, + name: string, + branch: string, + run: WorkflowRunInput, +): Promise { + const url = fakeGithubUrl(); + const resp = await fetch(`${url}/_admin/actions/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner, + repo: name, + branch, + headSha: run.headSha ?? '', + status: run.status, + conclusion: run.conclusion ?? '', + jobs: run.jobs ?? [], + }), + }); + if (!resp.ok) { + throw new Error( + `Failed to set workflow run for ${owner}/${name} in fake GitHub: ${resp.status} ${await resp.text()}`, + ); + } +} +``` + +- [ ] **Step 2: Write the e2e test** + +Create `e2e/use-cases/build-status/build-status.test.ts`: + +```ts +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToFunctionsList } from '../../helpers/navigation'; +import { seedRepo, setWorkflowRun } from '../../helpers/fakegithub'; +import { E2E_USER } from '../../helpers/constants'; + +const FUNC_NAME = 'build-status-func'; + +test.describe('Build status', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await seedRepo(E2E_USER, FUNC_NAME, 'main', ['serverless-function'], [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${FUNC_NAME}\nruntime: node\nnamespace: default\n`, + }, + ]); + }); + + test('reflects an in-progress run then a failure over SSE', async ({ page }) => { + await setWorkflowRun(E2E_USER, FUNC_NAME, 'main', { + headSha: 'sha-building', + status: 'in_progress', + }); + + await navigateToFunctionsList(page); + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect(grid).toBeVisible({ timeout: 30_000 }); + + const row = grid.locator('tbody tr').filter({ hasText: FUNC_NAME }); + await expect(row.getByText('Building')).toBeVisible({ timeout: 30_000 }); + + // Script a failure; the list streams over SSE, so it should update without a refresh. + await setWorkflowRun(E2E_USER, FUNC_NAME, 'main', { + headSha: 'sha-failed', + 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 }, + ], + }, + ], + }); + + await expect(row.getByText('BuildFailed')).toBeVisible({ timeout: 30_000 }); + }); +}); +``` + +- [ ] **Step 3: Run the e2e test** + +Run: `npm run test:e2e -- build-status` (confirm the exact e2e invocation in `package.json`; use `make dev-fake-gh` env as other e2e tests require). +Expected: PASS. The `Building` state appears on load, and `BuildFailed` appears after the second `setWorkflowRun` without navigating again. + +> The dev environment must be running with fakegithub. Ask the user to start/restart it (`make dev-fake-gh` / `hack/dev.sh`); you cannot start it yourself. + +- [ ] **Step 4: Commit** + +```bash +git add e2e/helpers/fakegithub.ts e2e/use-cases/build-status/build-status.test.ts +git commit -m "test(e2e): cover build status Building and BuildFailed over SSE" +``` + +--- + +## Phase 6: Revisit + +### Task 16: Reconcile backend with frontend findings + +- [ ] **Step 1:** Review whether the frontend needs any snapshot field not currently emitted (e.g. an explicit `branch` for key disambiguation). If so, add it to `buildStatusItem` and the `BuildStatusItem` TS interface together, with a test on each side. +- [ ] **Step 2:** Confirm the SSE reconnect behavior is acceptable in the running dev environment (watch the network panel: stream stays open, heartbeats arrive, status changes propagate). Note any tuning of `buildPollInterval` needed for GitHub rate limits. +- [ ] **Step 3:** Run the full suites: `cd backend && go test ./...` and `npx vitest run`. Both green. +- [ ] **Step 4:** Move this plan to `docs/plans/completed/` when the story is done. + +--- + +## Notes on conventions + +- No em dashes in code comments or docs (project style). +- Backend: stdlib + go-github only; SSE is `net/http` + `http.Flusher`, no new deps. +- Tests: red/green/refactor, one test at a time; stub the network boundary, never `vi.mock` our own hooks (SRVOCF-822 precedent). +- E2e runs against the real backend connected to fakegithub; no `page.route` GitHub mocking. From 3a3ba23c694bbf0f90f2612462a31d9dc9f301c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:20:37 +0200 Subject: [PATCH 03/18] feat(fakegithub): add scripted workflow run state and admin control Add admin endpoints so tests and dev can script a repo's GitHub Actions workflow run status, conclusion, and jobs, making build status deterministic to exercise end to end. Co-Authored-By: Claude Opus 4.8 --- backend/fakegithub/server.go | 146 +++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/backend/fakegithub/server.go b/backend/fakegithub/server.go index 5d556828..e92b08af 100644 --- a/backend/fakegithub/server.go +++ b/backend/fakegithub/server.go @@ -10,6 +10,7 @@ import ( "net/http" "slices" "sort" + "strconv" "strings" "sync" @@ -33,6 +34,32 @@ 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 +} + +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 { @@ -66,6 +93,8 @@ type Server struct { pubKeyB64 string keyID string + runIDSeq int64 // monotonic id source for scripted workflow runs + mux *http.ServeMux } @@ -136,9 +165,14 @@ 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) + s.mux.HandleFunc("GET /repos/{owner}/{repo}/actions/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 --- @@ -628,6 +662,66 @@ 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") + // 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 { + 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 { @@ -689,6 +783,58 @@ 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"` +} + +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" + } + + 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, + } + // 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 { From 9cf94a3c57b617fcb773d084dae8e6bb046f5339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:25:01 +0200 Subject: [PATCH 04/18] feat(scm): add LatestWorkflowRun via GitHub Actions Fetch the latest workflow run for a repo's default branch through the GitHub Actions REST API and, on failure, derive a " / " reason from the failed job. Includes fakegithub coverage and failureReason fallbacks. Co-Authored-By: Claude Opus 4.8 --- backend/fakegithub/server_test.go | 148 ++++++++++++++++++++++++++++++ backend/scm/client.go | 36 ++++++-- backend/scm/github/client.go | 53 +++++++++++ 3 files changed, 229 insertions(+), 8 deletions(-) diff --git a/backend/fakegithub/server_test.go b/backend/fakegithub/server_test.go index 5b4c3d6b..3af1fd38 100644 --- a/backend/fakegithub/server_test.go +++ b/backend/fakegithub/server_test.go @@ -233,6 +233,144 @@ 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") + 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") + 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") + 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") + 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") + 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") + 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") + 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") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + Expect(run.Conclusion).To(Equal("failure")) + Expect(run.FailureReason).To(Equal("test / unit tests")) + }) }) }) @@ -264,6 +402,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()) diff --git a/backend/scm/client.go b/backend/scm/client.go index 6f4e7cf4..ba2bb7c1 100644 --- a/backend/scm/client.go +++ b/backend/scm/client.go @@ -49,6 +49,7 @@ type Client interface { InitRepo(ctx context.Context, owner, name, branch string, topics []string) error StoreSecret(ctx context.Context, owner, repo, name, value string) error DeleteRepo(ctx context.Context, owner, repo string) error + LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) } type Repo struct { @@ -71,15 +72,27 @@ type FileEntry struct { Deleted bool `json:"deleted,omitempty"` } +// WorkflowRun is the latest GitHub Actions run for a repo branch. +// A nil *WorkflowRun means the branch has no runs. +type WorkflowRun struct { + ID int64 + Status string // queued | in_progress | completed + Conclusion string // success | failure | cancelled | timed_out | "" + HeadSHA string + HTMLURL string + FailureReason string // set for failures: " / " summary +} + type ClientStub struct { - OnGetUser func(ctx context.Context) (*User, error) - OnListRepos func(ctx context.Context) ([]Repo, error) - OnGetFileContent func(ctx context.Context, owner, repo, ref, path string) (string, error) - OnGetFiles func(ctx context.Context, owner, repo, ref string) ([]FileEntry, error) - OnPushFiles func(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error - OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error - OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error - OnDeleteRepo func(ctx context.Context, owner, repo string) error + OnGetUser func(ctx context.Context) (*User, error) + OnListRepos func(ctx context.Context) ([]Repo, error) + OnGetFileContent func(ctx context.Context, owner, repo, ref, path string) (string, error) + OnGetFiles func(ctx context.Context, owner, repo, ref string) ([]FileEntry, error) + OnPushFiles func(ctx context.Context, owner, repo, branch, message string, files []FileEntry) error + OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error + OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error + OnDeleteRepo func(ctx context.Context, owner, repo string) error + OnLatestWorkflowRun func(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) } func (s *ClientStub) GetUser(ctx context.Context) (*User, error) { @@ -137,3 +150,10 @@ func (s *ClientStub) DeleteRepo(ctx context.Context, owner, repo string) error { } return nil } + +func (s *ClientStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) { + if s.OnLatestWorkflowRun != nil { + return s.OnLatestWorkflowRun(ctx, owner, repo, branch) + } + return nil, nil +} diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index 5b2f575e..3b721a80 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "time" @@ -298,3 +299,55 @@ func (c *ghClient) DeleteRepo(ctx context.Context, owner, repo string) error { } return nil } + +func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + opts := &ghlib.ListWorkflowRunsOptions{ + Branch: branch, + ListOptions: ghlib.ListOptions{PerPage: 1}, + } + runs, _, err := c.client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("list workflow runs for %s/%s: %w", owner, repo, mapErr(err)) + } + if len(runs.WorkflowRuns) == 0 { + return nil, nil + } + + // GitHub returns runs in created_at descending order by default, so with + // PerPage 1 the single element WorkflowRuns[0] is the newest run. + run := runs.WorkflowRuns[0] + result := &scm.WorkflowRun{ + ID: run.GetID(), + Status: run.GetStatus(), + Conclusion: run.GetConclusion(), + HeadSHA: run.GetHeadSHA(), + HTMLURL: run.GetHTMLURL(), + } + if result.Conclusion == "failure" { + result.FailureReason = c.failureReason(ctx, owner, repo, result.ID) + } + return result, nil +} + +// failureReason returns a " / " summary of the first failed step, +// or the failing job name, or "" if it cannot be determined. Best-effort: never +// fails the caller. +func (c *ghClient) failureReason(ctx context.Context, owner, repo string, runID int64) string { + jobs, _, err := c.client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, nil) + if err != nil { + slog.Warn("failed to list workflow jobs", "repo", owner+"/"+repo, "run", runID, "err", err) + return "" + } + for _, job := range jobs.Jobs { + if job.GetConclusion() != "failure" { + continue + } + for _, step := range job.Steps { + if step.GetConclusion() == "failure" { + return job.GetName() + " / " + step.GetName() + } + } + return job.GetName() + } + return "" +} From bb72459cde53c0bbe484e2cd243a5c5e9cdd08d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:35:15 +0200 Subject: [PATCH 05/18] feat(handler): add build status snapshot and SSE watch endpoints Add a snapshot endpoint and an SSE watch endpoint that streams per-repo build status, polling GitHub on an interval with heartbeats and periodic repo rediscovery. Per-repo errors are surfaced in the snapshot, and each snapshot is marshalled once with the bytes reused as the change key so unchanged polls are skipped. Wire the routes into main. Co-Authored-By: Claude Opus 4.8 --- backend/handler/build.go | 243 +++++++++++++++++++++++++ backend/handler/build_test.go | 334 ++++++++++++++++++++++++++++++++++ backend/main.go | 2 + 3 files changed, 579 insertions(+) create mode 100644 backend/handler/build.go create mode 100644 backend/handler/build_test.go diff --git a/backend/handler/build.go b/backend/handler/build.go new file mode 100644 index 00000000..dde1b10b --- /dev/null +++ b/backend/handler/build.go @@ -0,0 +1,243 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "time" + + "golang.org/x/sync/errgroup" + + "github.com/openshift/faas-console-plugin/backend/config" + "github.com/openshift/faas-console-plugin/backend/scm" +) + +// Tunable so tests can drive the SSE loop quickly. +var ( + buildPollInterval = 3 * time.Second + buildRediscoverInterval = 30 * time.Second + buildHeartbeatInterval = 15 * time.Second +) + +type buildStatusItem struct { + Key string `json:"key"` + BuildStatus string `json:"buildStatus"` // Building | Succeeded | Failed | None + Conclusion string `json:"conclusion,omitempty"` + RunURL string `json:"runURL,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + HeadSHA string `json:"headSHA,omitempty"` + Err string `json:"err,omitempty"` +} + +type buildSnapshot struct { + Functions []buildStatusItem `json:"functions"` +} + +func deriveBuildStatus(run *scm.WorkflowRun) string { + if run == nil { + return "None" + } + switch run.Status { + // Every pre-completion status (including the gated "waiting"/"requested"/ + // "pending" states) means a run exists but has not finished, so the build is + // still in flight. + case "queued", "in_progress", "waiting", "requested", "pending": + return "Building" + case "completed": + switch run.Conclusion { + case "success": + return "Succeeded" + case "failure", "cancelled", "timed_out": + return "Failed" + default: + // Non-failure outcomes like "skipped", "neutral", "stale", or + // "action_required" are not build failures; report no build signal + // so the frontend falls back to the cluster-derived status rather + // than showing a red "Build failed" badge. + return "None" + } + default: + return "None" + } +} + +func toBuildStatusItem(key string, run *scm.WorkflowRun) buildStatusItem { + item := buildStatusItem{Key: key, BuildStatus: deriveBuildStatus(run)} + if run != nil { + item.Conclusion = run.Conclusion + item.RunURL = run.HTMLURL + item.FailureReason = run.FailureReason + item.HeadSHA = run.HeadSHA + } + return item +} + +// buildStatusSnapshot fetches the latest run for each repo and builds a sorted +// snapshot. When a per-repo fetch fails and prev holds a last-known item for +// that repo, the previous item is carried forward instead of resetting to +// "None": a transient GitHub error would otherwise flicker the badge back to +// the cluster status and, via a varying error string, defeat the watch loop's +// change-detection and force a re-send every poll. prev may be nil (the +// one-shot snapshot endpoint has no prior state), in which case a fresh error +// item is emitted. +func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Repo, prev map[string]buildStatusItem) buildSnapshot { + items := make([]buildStatusItem, len(repos)) + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(10) + for i, repo := range repos { + g.Go(func() error { + key := repo.Owner + "/" + repo.Name + run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch) + if err != nil { + slog.Warn("failed to get workflow run", "repo", key, "err", err) + if last, ok := prev[key]; ok { + items[i] = last + } else { + items[i] = buildStatusItem{Key: key, BuildStatus: "None", Err: err.Error()} + } + return nil + } + items[i] = toBuildStatusItem(key, run) + return nil + }) + } + _ = g.Wait() + sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) + return buildSnapshot{Functions: items} +} + +// snapshotIndex keys a snapshot's items by their repo key for carry-forward +// lookups on the next poll. +func snapshotIndex(snap buildSnapshot) map[string]buildStatusItem { + index := make(map[string]buildStatusItem, len(snap.Functions)) + for _, item := range snap.Functions { + index[item.Key] = item + } + return index +} + +func (h *Handlers) HandleBuildStatus(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + + repos, err := client.ListRepos(r.Context()) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build status: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + snap := buildStatusSnapshot(r.Context(), client, repos, nil) + writeJSON(w, http.StatusOK, snap) +} + +func (h *Handlers) HandleBuildWatch(w http.ResponseWriter, r *http.Request) { + pat, ok := extractSCMToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "X-SCM-Token header is required") + return + } + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + client := config.SCMRegistry.Client(scm.DefaultPlatform, pat) + ctx := r.Context() + + // Discover repos before switching to SSE so auth failures return a normal status. + repos, err := client.ListRepos(ctx) + if err != nil { + if errors.Is(err, scm.ErrUnauthorized) { + writeError(w, http.StatusUnauthorized, "invalid SCM token") + return + } + slog.Error("build watch: list repos failed", "err", err) + writeError(w, http.StatusBadGateway, "failed to list repositories") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + snap := buildStatusSnapshot(ctx, client, repos, nil) + data, err := json.Marshal(snap) + if err != nil { + slog.Error("build watch: marshal snapshot failed", "err", err) + return + } + if err := writeSnapshotEvent(w, data); err != nil { + return + } + flusher.Flush() + // The marshaled bytes double as the change-detection key. + prev := string(data) + // Last-known items, carried forward when a per-repo poll fails transiently. + prevItems := snapshotIndex(snap) + + poll := time.NewTicker(buildPollInterval) + defer poll.Stop() + rediscover := time.NewTicker(buildRediscoverInterval) + defer rediscover.Stop() + heartbeat := time.NewTicker(buildHeartbeatInterval) + defer heartbeat.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-heartbeat.C: + if _, err := io.WriteString(w, ":\n\n"); err != nil { + return + } + flusher.Flush() + case <-rediscover.C: + if latest, err := client.ListRepos(ctx); err != nil { + slog.Warn("build watch: rediscover failed", "err", err) + } else { + repos = latest + } + case <-poll.C: + next := buildStatusSnapshot(ctx, client, repos, prevItems) + data, err := json.Marshal(next) + if err != nil { + slog.Warn("build watch: marshal snapshot failed", "err", err) + continue + } + prevItems = snapshotIndex(next) + key := string(data) + if key == prev { + continue + } + prev = key + if err := writeSnapshotEvent(w, data); err != nil { + return + } + flusher.Flush() + } + } +} + +// writeSnapshotEvent writes the already-marshaled snapshot bytes as an SSE frame. +func writeSnapshotEvent(w io.Writer, data []byte) error { + if _, err := fmt.Fprintf(w, "event: build-status\ndata: %s\n\n", data); err != nil { + return fmt.Errorf("write build-status event: %w", err) + } + return nil +} diff --git a/backend/handler/build_test.go b/backend/handler/build_test.go new file mode 100644 index 00000000..6c2fe16e --- /dev/null +++ b/backend/handler/build_test.go @@ -0,0 +1,334 @@ +package handler + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openshift/faas-console-plugin/backend/scm" +) + +var _ = Describe("HandleBuildStatus", func() { + It("returns 401 without an SCM token", func() { + withSCMStub(&scm.ClientStub{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("maps a run to a build-status item keyed by owner/repo", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress", HeadSHA: "sha1", HTMLURL: "u"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(1)) + Expect(snap.Functions[0].Key).To(Equal("alice/fn")) + Expect(snap.Functions[0].BuildStatus).To(Equal("Building")) + }) + + It("reports None when a repo has no runs", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + return nil, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions[0].BuildStatus).To(Equal("None")) + }) + + It("returns 401 when the SCM token is rejected", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return nil, scm.ErrUnauthorized + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("surfaces a per-repo fetch error without affecting other repos", func() { + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{ + {Owner: "alice", Name: "bad", DefaultBranch: "main"}, + {Owner: "alice", Name: "good", DefaultBranch: "main"}, + }, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + if repo == "bad" { + return nil, errors.New("boom") + } + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/func/build/status", nil) + req.Header.Set("X-SCM-Token", "pat") + w := httptest.NewRecorder() + + (&Handlers{}).HandleBuildStatus(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + var snap buildSnapshot + Expect(json.Unmarshal(w.Body.Bytes(), &snap)).To(Succeed()) + Expect(snap.Functions).To(HaveLen(2)) + + byKey := map[string]buildStatusItem{} + for _, f := range snap.Functions { + byKey[f.Key] = f + } + Expect(byKey["alice/bad"].BuildStatus).To(Equal("None")) + Expect(byKey["alice/bad"].Err).To(ContainSubstring("boom")) + Expect(byKey["alice/good"].BuildStatus).To(Equal("Building")) + Expect(byKey["alice/good"].Err).To(BeEmpty()) + }) +}) + +var _ = Describe("HandleBuildWatch", func() { + // pinIntervals makes the SSE timing fully explicit: a fast poll, and + // rediscover/heartbeat pushed far out so they never fire during a test. + pinIntervals := func() { + origPoll := buildPollInterval + origRediscover := buildRediscoverInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildRediscoverInterval = time.Hour + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildRediscoverInterval = origRediscover + buildHeartbeatInterval = origHeartbeat + }) + } + + It("emits an initial snapshot then a new snapshot on change", func() { + pinIntervals() + + var mu sync.Mutex + calls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return &scm.WorkflowRun{Status: "completed", Conclusion: "failure", FailureReason: "build / test"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.Header.Get("Content-Type")).To(Equal("text/event-stream")) + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + second, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected a second snapshot frame on change") + Expect(second).To(ContainSubstring(`"buildStatus":"Failed"`)) + Expect(second).To(ContainSubstring(`"failureReason":"build / test"`)) + }) + + It("does not emit a second frame when the snapshot is unchanged", func() { + pinIntervals() + + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + // Same state on every poll, so the change-detection key never moves. + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Wait well beyond several poll cycles (poll is 10ms). No new frame should arrive. + _, ok = readSSEDataWithin(reader, 300*time.Millisecond) + Expect(ok).To(BeFalse(), "expected no second frame while the snapshot is unchanged") + }) + + It("returns 401 without an SCM token", func() { + withSCMStub(&scm.ClientStub{}) + req := httptest.NewRequest(http.MethodGet, "/watch", nil) + w := httptest.NewRecorder() + (&Handlers{}).HandleBuildWatch(w, req) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("carries forward the last-known status when a poll errors transiently", func() { + pinIntervals() + + var mu sync.Mutex + calls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return &scm.WorkflowRun{Status: "in_progress"}, nil + } + return nil, errors.New("transient boom") + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Subsequent polls error; the last-known "Building" is carried forward, so + // the change-detection key does not move and no new frame is emitted (no + // flicker back to "None", no error-string churn re-sends). + _, ok = readSSEDataWithin(reader, 300*time.Millisecond) + Expect(ok).To(BeFalse(), "expected no new frame while transient errors are carried forward") + }) +}) + +var _ = Describe("deriveBuildStatus", func() { + DescribeTable("maps run status and conclusion to a build status", + func(status, conclusion, expected string) { + Expect(deriveBuildStatus(&scm.WorkflowRun{Status: status, Conclusion: conclusion})).To(Equal(expected)) + }, + Entry("queued -> Building", "queued", "", "Building"), + Entry("in_progress -> Building", "in_progress", "", "Building"), + Entry("waiting -> Building", "waiting", "", "Building"), + Entry("requested -> Building", "requested", "", "Building"), + Entry("pending -> Building", "pending", "", "Building"), + Entry("completed+success -> Succeeded", "completed", "success", "Succeeded"), + Entry("completed+failure -> Failed", "completed", "failure", "Failed"), + Entry("completed+cancelled -> Failed", "completed", "cancelled", "Failed"), + Entry("completed+timed_out -> Failed", "completed", "timed_out", "Failed"), + Entry("completed+skipped -> None", "completed", "skipped", "None"), + Entry("completed+neutral -> None", "completed", "neutral", "None"), + Entry("completed+stale -> None", "completed", "stale", "None"), + Entry("completed+action_required -> None", "completed", "action_required", "None"), + Entry("unknown status -> None", "bogus", "", "None"), + ) + + It("maps a nil run to None", func() { + Expect(deriveBuildStatus(nil)).To(Equal("None")) + }) +}) + +// readSSEDataWithin runs readSSEData with a timeout so a handler that never +// emits fails fast instead of blocking until the spec timeout. It returns the +// payload and true on success, or "" and false if the timeout elapses first. +func readSSEDataWithin(reader *bufio.Reader, timeout time.Duration) (string, bool) { + ch := make(chan string, 1) + go func() { ch <- readSSEData(reader) }() + select { + case data := <-ch: + return data, true + case <-time.After(timeout): + return "", false + } +} + +// readSSEData reads frames until it finds one with a data: line and returns that payload. +func readSSEData(reader *bufio.Reader) string { + var data []string + for { + line, err := reader.ReadString('\n') + if err != nil { + return strings.Join(data, "\n") + } + line = strings.TrimRight(line, "\n") + if line == "" { + if len(data) > 0 { + return strings.Join(data, "\n") + } + continue // heartbeat or blank separator, keep reading + } + if strings.HasPrefix(line, "data:") { + data = append(data, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} diff --git a/backend/main.go b/backend/main.go index 4b954970..013c59b5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -65,6 +65,8 @@ func main() { mux.HandleFunc("GET /api/v1/func/{owner}/{name}/files", h.HandleGetFiles) mux.HandleFunc("PUT /api/v1/func/{owner}/{name}/files", h.HandlePutFiles) mux.HandleFunc("POST /api/v1/func/create", h.HandleFuncCreate) + mux.HandleFunc("GET /api/v1/func/build/status", h.HandleBuildStatus) + mux.HandleFunc("GET /api/v1/func/build/watch", h.HandleBuildWatch) mux.Handle("/", http.FileServer(http.FS(static))) muxHandler := loggingMiddleware(mux) From 19894f6a8dac67416658c87c78b54c5dd71fffd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 13:51:18 +0200 Subject: [PATCH 06/18] feat(frontend): add useBuildStatus SSE hook Stream build status over SSE via consoleFetch, sending the PAT in the X-SCM-Token header, with reconnect/backoff and stop-on-auth-error. Includes a consoleFetch stream test stub. Co-Authored-By: Claude Opus 4.8 --- src/common/clients/useBuildStatus.test.tsx | 105 +++++++++++++ src/common/clients/useBuildStatus.ts | 149 +++++++++++++++++++ src/common/testing/consoleFetchStreamStub.ts | 58 ++++++++ src/common/types.ts | 9 ++ 4 files changed, 321 insertions(+) create mode 100644 src/common/clients/useBuildStatus.test.tsx create mode 100644 src/common/clients/useBuildStatus.ts create mode 100644 src/common/testing/consoleFetchStreamStub.ts diff --git a/src/common/clients/useBuildStatus.test.tsx b/src/common/clients/useBuildStatus.test.tsx new file mode 100644 index 00000000..01fcc2b3 --- /dev/null +++ b/src/common/clients/useBuildStatus.test.tsx @@ -0,0 +1,105 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { PAT_KEY } from '../types'; + +const streamStub = await vi.hoisted(async () => import('../testing/consoleFetchStreamStub')); + +vi.mock('@openshift-console/dynamic-plugin-sdk', () => ({ + consoleFetch: streamStub.consoleFetchStub, +})); + +import { useBuildStatus } from './useBuildStatus'; + +describe('useBuildStatus', () => { + beforeEach(() => { + sessionStorage.setItem(PAT_KEY, 'test-pat'); + streamStub.resetStreamFrames(); + }); + + afterEach(() => { + sessionStorage.clear(); + vi.useRealTimers(); + }); + + it('parses a build-status frame into a keyed map', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { key: 'alice/fn', buildStatus: 'Building' }, + { key: 'alice/gn', buildStatus: 'Failed', failureReason: 'build / test', runURL: 'u' }, + ]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(2)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Building'); + expect(result.current.get('alice/gn')?.failureReason).toBe('build / test'); + }); + + it('ignores heartbeat comment frames', async () => { + streamStub.setStreamFrames([ + ':\n\n', + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Succeeded' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('alice/fn')?.buildStatus).toBe('Succeeded'); + }); + + it('reassembles a frame split across two stream chunks', async () => { + // A single build-status frame delivered as two separate reader.read() chunks; + // the split falls in the middle of the JSON payload ("func" | "tions"). + streamStub.setStreamFrames([ + 'event: build-status\ndata: {"func', + 'tions":[{"key":"a/b","buildStatus":"Building"}]}\n\n', + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('a/b')?.buildStatus).toBe('Building'); + }); + + it('applies the last snapshot when two frames arrive in one chunk', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'a/b', buildStatus: 'Building' }]) + + streamStub.buildStatusFrame([{ key: 'a/b', buildStatus: 'Failed' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.get('a/b')?.buildStatus).toBe('Failed'); + }); + + it('stops reconnecting after an auth failure', async () => { + vi.useFakeTimers(); + streamStub.setStreamError(Object.assign(new Error('unauthorized'), { code: 401 })); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = renderHook(() => useBuildStatus()); + // Advance well past the 3s backoff window; a stopped stream must not retry. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBe(1); + expect(errorSpy).toHaveBeenCalled(); + + unmount(); + }); + + it('reconnects with backoff after a transient stream error', async () => { + vi.useFakeTimers(); + streamStub.setStreamError(new Error('network blip')); // no status code -> transient + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = renderHook(() => useBuildStatus()); + // 0ms + retries at 3s/6s/9s within the window. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBeGreaterThan(1); + expect(errorSpy).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/src/common/clients/useBuildStatus.ts b/src/common/clients/useBuildStatus.ts new file mode 100644 index 00000000..9ac0055e --- /dev/null +++ b/src/common/clients/useBuildStatus.ts @@ -0,0 +1,149 @@ +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; +import { useEffect, useState } from 'react'; +import { BuildStatus, PAT_KEY, PROXY_BASE } from '../types'; + +const RECONNECT_DELAY_MS = 3000; + +interface BuildStatusItem { + key: string; + buildStatus: BuildStatus['buildStatus']; + conclusion?: string; + runURL?: string; + failureReason?: string; + headSHA?: string; +} + +interface BuildSnapshot { + functions: BuildStatusItem[]; +} + +// useBuildStatus streams per-function GitHub Actions build status over SSE and +// returns it keyed by "owner/repo". The backend scopes the stream to the +// authenticated user, so no arguments are required. +export function useBuildStatus(): ReadonlyMap { + const [statuses, setStatuses] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function run() { + while (!cancelled) { + const pat = sessionStorage.getItem(PAT_KEY); + if (!pat) return; + try { + const res = await consoleFetch(`${PROXY_BASE}/api/v1/func/build/watch`, { + headers: { 'X-SCM-Token': pat }, + signal: controller.signal, + }); + if (!res.body) return; + await readStream(res.body, (snap) => { + if (!cancelled) setStatuses(toMap(snap)); + }); + } catch (err) { + if (cancelled) return; + if (isAuthError(err)) { + // A bad or expired PAT will not recover on retry, so stop the stream + // instead of reconnecting in a tight loop. The console has no logger + // utility, so we surface the diagnostic via console.error. + console.error( + 'useBuildStatus: build status stream unauthorized, not reconnecting', + err, + ); + return; + } + // Transient stream/network error: log so it is not silent, then fall + // through to the backoff-and-reconnect below. + console.error('useBuildStatus: build status stream error, reconnecting', err); + } + // Stream ended or errored transiently; back off, then reconnect. + await delay(RECONNECT_DELAY_MS, controller.signal); + } + } + + run(); + return () => { + cancelled = true; + controller.abort(); + }; + }, []); + + return statuses; +} + +async function readStream( + body: ReadableStream, + onSnapshot: (snap: BuildSnapshot) => void, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const snap = parseFrame(frame); + if (snap) onSnapshot(snap); + } + } +} + +function parseFrame(frame: string): BuildSnapshot | null { + let event = ''; + const dataLines: string[] = []; + for (const line of frame.split('\n')) { + if (line.startsWith(':')) continue; // heartbeat / comment + if (line.startsWith('event:')) event = line.slice('event:'.length).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice('data:'.length).trim()); + } + if (event && event !== 'build-status') return null; + if (dataLines.length === 0) return null; + try { + return JSON.parse(dataLines.join('\n')) as BuildSnapshot; + } catch { + return null; + } +} + +function toMap(snap: BuildSnapshot): ReadonlyMap { + return new Map( + (snap.functions ?? []).map((f) => [ + f.key, + { + buildStatus: f.buildStatus, + conclusion: f.conclusion, + runURL: f.runURL, + failureReason: f.failureReason, + }, + ]), + ); +} + +// isAuthError reports whether a consoleFetch failure is a 401/403. consoleFetch +// throws an HttpError carrying the status on `code`; we also check `response.status` +// defensively without depending on the SDK error class at runtime. +function isAuthError(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const e = err as { code?: number; response?: { status?: number } }; + const status = e.code ?? e.response?.status; + return status === 401 || status === 403; +} + +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const onAbort = () => { + clearTimeout(id); + resolve(); + }; + const id = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/src/common/testing/consoleFetchStreamStub.ts b/src/common/testing/consoleFetchStreamStub.ts new file mode 100644 index 00000000..3bd9dabf --- /dev/null +++ b/src/common/testing/consoleFetchStreamStub.ts @@ -0,0 +1,58 @@ +// Test double for the SSE stream consumed by useBuildStatus. +// Mirrors the setFixtures pattern in useK8sWatchResourceStub.ts: +// a module-level fixture that tests set, and a stub function wired into +// the mocked consoleFetch. +// +// Each element of `frames` is enqueued as a separate ReadableStream chunk, so +// tests can split a single SSE frame across chunk boundaries to exercise the +// hook's cross-read buffering. + +let frames: string[] = []; +let error: unknown = null; +let calls = 0; + +export function setStreamFrames(newFrames: string[]) { + frames = newFrames; + error = null; +} + +// setStreamError makes the next consoleFetch reject, simulating an HTTP or +// network failure. Attach a `code` (HTTP status) to simulate an auth failure. +export function setStreamError(err: unknown) { + error = err; +} + +export function resetStreamFrames() { + frames = []; + error = null; + calls = 0; +} + +// streamFetchCalls reports how many times the stubbed consoleFetch was invoked, +// so tests can assert reconnect versus stop behaviour. +export function streamFetchCalls(): number { + return calls; +} + +// buildStatusFrame formats a single SSE build-status event. +export function buildStatusFrame(functions: unknown[]): string { + return `event: build-status\ndata: ${JSON.stringify({ functions })}\n\n`; +} + +// consoleFetchStub stands in for consoleFetch(url, options): it ignores its +// arguments and serves the configured frames (or error) as the response body. +export const consoleFetchStub = (): Promise => { + calls++; + if (error) return Promise.reject(error); + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const chunk of frames) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + return Promise.resolve(new Response(stream, { status: 200 })); +}; diff --git a/src/common/types.ts b/src/common/types.ts index c3a5bbdc..d2853386 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -77,10 +77,12 @@ export type FunctionStatus = | 'CreatingRepo' | 'Pushing' | 'PushedToGitHub' + | 'Building' | 'Deploying' | 'Running' | 'ScaledToZero' | 'Error' + | 'BuildFailed' | 'Unknown' | 'NotDeployed'; @@ -92,3 +94,10 @@ export interface ClusterFunction { readonly replicas: number; readonly mainResource: K8sResourceCommon; } + +export interface BuildStatus { + buildStatus: 'Building' | 'Succeeded' | 'Failed' | 'None'; + conclusion?: string; + runURL?: string; + failureReason?: string; +} From 40c3cdb08461e780ceea35dd469477bdf09a6904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 14:03:40 +0200 Subject: [PATCH 07/18] feat(frontend): show build status in the functions list Merge streamed build status into the functions list: render Building and BuildFailed (with a link to the run and a failure-reason tooltip), while letting a Running cluster status win over a stale Failed build. Also repairs the setup-guide test orphaned by a master helper rename. Co-Authored-By: Claude Opus 4.8 --- .../function-list/FunctionsListPage.test.tsx | 72 +++++++++++++++++++ src/pages/function-list/FunctionsListPage.tsx | 27 ++++++- .../components/FunctionTable.test.tsx | 5 ++ .../components/FunctionTable.tsx | 33 ++++++++- 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index 2a37be82..004955ac 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -11,6 +11,10 @@ import FunctionsListPage from './FunctionsListPage'; // https://vitest.dev/api/vi.html#vi-hoisted const sdkTestDoubles = await vi.hoisted(async () => import('../../common/testing/sdkTestDoubles')); +const streamStub = await vi.hoisted( + async () => import('../../common/testing/consoleFetchStreamStub'), +); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); @@ -33,6 +37,7 @@ vi.mock('@openshift-console/dynamic-plugin-sdk', async () => { ), consoleFetchJSON, + consoleFetch: streamStub.consoleFetchStub, SuccessStatus: ({ title }: { title: string }) => `Success: ${title}`, ProgressStatus: ({ title }: { title: string }) => `Progress: ${title}`, ErrorStatus: ({ title }: { title: string }) => `Error: ${title}`, @@ -51,6 +56,7 @@ describe('FunctionsListPage', () => { beforeEach(() => { logoutGithubFake(); authenticateGithubFake(); + streamStub.resetStreamFrames(); }); afterEach(() => { @@ -301,6 +307,72 @@ describe('FunctionsListPage', () => { expect(await screen.findByText('Error: Error')).toBeInTheDocument(); }); + it('shows Building from the build stream, overriding cluster status', async () => { + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: `twoGiants/${funcName}`, buildStatus: 'Building' }]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Progress: Building')).toBeInTheDocument(); + }); + + it('keeps Running when a build Failed but the cluster is Running', async () => { + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Success: Running')).toBeInTheDocument(); + expect(screen.queryByText('Error: BuildFailed')).not.toBeInTheDocument(); + }); + + it('shows BuildFailed with the failure reason and run link from the build stream', async () => { + listFunctionsStub({ responses: [repoListItem(funcName)] }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Error: BuildFailed')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Error: BuildFailed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + it('uses func.yaml name instead of repo name for cluster matching', async () => { listFunctionsStub({ responses: [repoListItem('my-repo', funcName, 'demo', 'node')] }); sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 5bbcf463..1d99f01b 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -25,8 +25,9 @@ import { FunctionTable, FunctionTableItem } from './components/FunctionTable'; import { SetupGuide } from './components/SetupGuide'; import { UserAvatar } from '../../common/components/UserAvatar'; import { AuthContext, AuthProvider } from '../../common/context/AuthProvider'; -import { ClusterFunction, FunctionListItem } from '../../common/types'; +import { BuildStatus, ClusterFunction, FunctionListItem } from '../../common/types'; import { useCluster } from '../../common/clients/useCluster'; +import { useBuildStatus } from '../../common/clients/useBuildStatus'; import { listFunctions } from '../../common/clients/functionsClient'; import { errorMessage } from '../../common/utils/utils'; @@ -210,15 +211,18 @@ function useFunctionListPage(): { // to 'get resources from all namespaces' isAllNamespacesKey(namespace) ? undefined : namespace, ); + const buildStatuses = useBuildStatus(); const functions = useMemo( () => functionItems.map((item) => { // keyed by namespace/name - the same function name can exist in multiple namespaces const cf = clusterFunctions.get(`${item.namespace}/${item.name}`); - return cf ? enrichItem(item, cf) : item; + const enriched = cf ? enrichItem(item, cf) : item; + const build = buildStatuses.get(`${item.owner}/${item.repoName}`); + return build ? mergeBuild(enriched, build) : enriched; }), - [functionItems, clusterFunctions], + [functionItems, clusterFunctions, buildStatuses], ); const reposLoaded = !isAuthenticated || (namespaceLoaded && prevNamespace === namespace); @@ -247,6 +251,7 @@ function newItem(item: FunctionListItem): FunctionTableItem { return { name: item.name || item.repoName, repoName: item.repoName, + owner: item.owner, namespace: item.namespace, runtime: item.runtime, status: item.err ? 'Error' : 'NotDeployed', @@ -265,3 +270,19 @@ function enrichItem(item: FunctionTableItem, cf: ClusterFunction): FunctionTable mainResource: cf.mainResource, }; } + +function mergeBuild(item: FunctionTableItem, build: BuildStatus): FunctionTableItem { + if (build.buildStatus === 'Building') { + return { ...item, status: 'Building' }; + } + if (build.buildStatus === 'Failed' && item.status !== 'Running') { + return { + ...item, + status: 'BuildFailed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: fall through to the cluster-derived status. + return item; +} diff --git a/src/pages/function-list/components/FunctionTable.test.tsx b/src/pages/function-list/components/FunctionTable.test.tsx index ee7b7a64..5e81e218 100644 --- a/src/pages/function-list/components/FunctionTable.test.tsx +++ b/src/pages/function-list/components/FunctionTable.test.tsx @@ -39,6 +39,7 @@ const mockFunctions: FunctionTableItem[] = [ { name: 'my-func', repoName: 'my-func', + owner: 'twoGiants', runtime: 'go', status: 'Running', url: 'http://my-func.demo.svc', @@ -50,6 +51,7 @@ const mockFunctions: FunctionTableItem[] = [ { name: 'idle-func', repoName: 'idle-func', + owner: 'twoGiants', runtime: 'node', status: 'NotDeployed', url: '', @@ -62,6 +64,7 @@ const mockFunctions: FunctionTableItem[] = [ const clusterOnlyFunction: FunctionTableItem = { name: 'cluster-only', repoName: '', + owner: '', runtime: 'node', status: 'Running', url: 'http://cluster-only.demo.svc', @@ -91,6 +94,7 @@ describe('FunctionTable', () => { const noRuntime: FunctionTableItem = { name: 'cluster-only', repoName: '', + owner: '', runtime: '', status: 'Running', url: 'http://cluster-only.demo.svc', @@ -185,6 +189,7 @@ describe('FunctionTable', () => { const fn: FunctionTableItem = { name: 'my-function', repoName: 'my-repo', + owner: 'twoGiants', runtime: 'node', status: 'Running', url: '', diff --git a/src/pages/function-list/components/FunctionTable.tsx b/src/pages/function-list/components/FunctionTable.tsx index 5edda32f..d7d2b6b8 100644 --- a/src/pages/function-list/components/FunctionTable.tsx +++ b/src/pages/function-list/components/FunctionTable.tsx @@ -16,6 +16,7 @@ import { FunctionSource, FunctionStatus } from '../../../common/types'; export interface FunctionTableItem { name: string; repoName: string; + owner: string; runtime: string; status: FunctionStatus; url: string; @@ -23,6 +24,8 @@ export interface FunctionTableItem { namespace: string; source: FunctionSource; mainResource?: K8sResourceCommon; + buildRunURL?: string; + failureReason?: string; } export function FunctionTable({ @@ -68,7 +71,11 @@ export function FunctionTable({ - + @@ -95,10 +102,21 @@ function TextOrDash({ value }: { value?: string }) { return <>{value || '—'}; } -function StatusCell({ status }: { status: FunctionStatus }) { +function StatusCell({ + status, + failureReason, + buildRunURL, +}: { + status: FunctionStatus; + failureReason?: string; + buildRunURL?: string; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + switch (status) { case 'Running': return ; + case 'Building': case 'Deploying': case 'CreatingRepo': case 'Pushing': @@ -106,6 +124,17 @@ function StatusCell({ status }: { status: FunctionStatus }) { return ; case 'Error': return ; + case 'BuildFailed': { + const badge = ; + const withLink = buildRunURL ? ( + + {badge} + + ) : ( + badge + ); + return {withLink}; + } case 'ScaledToZero': case 'NotDeployed': return ; From 3929c18e537127b26885d82c228406a600212750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 14:07:13 +0200 Subject: [PATCH 08/18] test(e2e): cover build status Building and BuildFailed over SSE Co-Authored-By: Claude Opus 4.8 --- e2e/helpers/fakegithub.ts | 49 ++++++++++++++ e2e/use-cases/list/build-status.test.ts | 87 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 e2e/use-cases/list/build-status.test.ts diff --git a/e2e/helpers/fakegithub.ts b/e2e/helpers/fakegithub.ts index bc520971..b5aa5db7 100644 --- a/e2e/helpers/fakegithub.ts +++ b/e2e/helpers/fakegithub.ts @@ -71,3 +71,52 @@ export async function deleteRepoOnFakeGithub(owner: string, name: string): Promi ); } } + +interface WorkflowStep { + name: string; + status: string; + conclusion: string; + number: number; +} + +interface WorkflowJob { + id: number; + name: string; + status: string; + conclusion: string; + steps: WorkflowStep[]; +} + +interface WorkflowRunInput { + headSha?: string; + status: string; // queued | in_progress | completed + conclusion?: string; // success | failure | ... + jobs?: WorkflowJob[]; +} + +export async function setWorkflowRun( + owner: string, + name: string, + branch: string, + run: WorkflowRunInput, +): Promise { + const url = fakeGithubUrl(); + const resp = await fetch(`${url}/_admin/actions/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + owner, + repo: name, + branch, + headSha: run.headSha ?? '', + status: run.status, + conclusion: run.conclusion ?? '', + jobs: run.jobs ?? [], + }), + }); + if (!resp.ok) { + throw new Error( + `Failed to set workflow run for ${owner}/${name} in fake GitHub: ${resp.status} ${await resp.text()}`, + ); + } +} diff --git a/e2e/use-cases/list/build-status.test.ts b/e2e/use-cases/list/build-status.test.ts new file mode 100644 index 00000000..02b3e17a --- /dev/null +++ b/e2e/use-cases/list/build-status.test.ts @@ -0,0 +1,87 @@ +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToFunctionsList } from '../../helpers/navigation'; +import { deleteRepoOnFakeGithub, seedRepo, setWorkflowRun } from '../../helpers/fakegithub'; +import { E2E_USER } from '../../helpers/constants'; + +const FUNC_NAME = 'build-status-func'; +const BRANCH = 'main'; +const FAILURE_REASON = 'build / go test'; + +test.describe('Build status', () => { + test.beforeEach(async () => { + await seedRepo( + E2E_USER, + FUNC_NAME, + BRANCH, + ['serverless-function'], + [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${FUNC_NAME}\nruntime: node\nnamespace: default\n`, + }, + ], + ); + }); + + test.afterEach(async () => { + await deleteRepoOnFakeGithub(E2E_USER, FUNC_NAME); + }); + + test('reflects an in-progress run then a failure over SSE', async ({ page }) => { + await test.step('script an in-progress run', async () => { + await setWorkflowRun(E2E_USER, FUNC_NAME, BRANCH, { + headSha: 'sha-building', + status: 'in_progress', + }); + }); + + await test.step('navigate to functions list and verify Building', async () => { + await navigateToFunctionsList(page); + const grid = page.getByRole('grid', { name: 'Functions' }); + await expect(grid).toBeVisible({ timeout: 30_000 }); + + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + await expect(row).toBeVisible(); + await expect(row.getByText('Building')).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('script a failing run and verify BuildFailed updates over SSE', async () => { + // The list streams over SSE (~3s poll cadence), so the status should update + // without a manual refresh. + await setWorkflowRun(E2E_USER, FUNC_NAME, BRANCH, { + headSha: 'sha-failed', + 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 }, + ], + }, + ], + }); + + const grid = page.getByRole('grid', { name: 'Functions' }); + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + await expect(row.getByText('BuildFailed')).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('verify a link to the run and the failure reason', async () => { + const grid = page.getByRole('grid', { name: 'Functions' }); + const row = grid.locator(`tbody tr:has(td:text-is("${FUNC_NAME}"))`); + + const runLink = row.locator('a[href*="/actions/runs/"]'); + await expect(runLink).toBeVisible(); + + // The failure reason is surfaced via a tooltip on the status badge. + await runLink.hover(); + await expect(page.getByRole('tooltip')).toContainText(FAILURE_REASON, { timeout: 20_000 }); + }); + }); +}); From a01460332a8c6ec3b197a10eb00e69e54df7b16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 15:47:49 +0200 Subject: [PATCH 09/18] fix(frontend): restart build-status stream on connection change Pass the auth connectionId into useBuildStatus so the stream tears down and reconnects with the current PAT on in-place login and account switch. Co-Authored-By: Claude Opus 4.8 --- src/common/clients/useBuildStatus.test.tsx | 18 ++++++++++++++++++ src/common/clients/useBuildStatus.ts | 8 ++++---- src/pages/function-list/FunctionsListPage.tsx | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/common/clients/useBuildStatus.test.tsx b/src/common/clients/useBuildStatus.test.tsx index 01fcc2b3..2c718447 100644 --- a/src/common/clients/useBuildStatus.test.tsx +++ b/src/common/clients/useBuildStatus.test.tsx @@ -88,6 +88,24 @@ describe('useBuildStatus', () => { unmount(); }); + it('restarts the stream when connectionId changes', async () => { + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Building' }]), + ]); + + const { rerender } = renderHook(({ connectionId }) => useBuildStatus(connectionId), { + initialProps: { connectionId: 1 }, + }); + + await waitFor(() => expect(streamStub.streamFetchCalls()).toBe(1)); + + // A new connection (initial login or account switch) must tear down the old + // stream and open a fresh one carrying the new user's PAT. + rerender({ connectionId: 2 }); + + await waitFor(() => expect(streamStub.streamFetchCalls()).toBe(2)); + }); + it('reconnects with backoff after a transient stream error', async () => { vi.useFakeTimers(); streamStub.setStreamError(new Error('network blip')); // no status code -> transient diff --git a/src/common/clients/useBuildStatus.ts b/src/common/clients/useBuildStatus.ts index 9ac0055e..b6b1a4e7 100644 --- a/src/common/clients/useBuildStatus.ts +++ b/src/common/clients/useBuildStatus.ts @@ -10,7 +10,6 @@ interface BuildStatusItem { conclusion?: string; runURL?: string; failureReason?: string; - headSHA?: string; } interface BuildSnapshot { @@ -19,8 +18,9 @@ interface BuildSnapshot { // useBuildStatus streams per-function GitHub Actions build status over SSE and // returns it keyed by "owner/repo". The backend scopes the stream to the -// authenticated user, so no arguments are required. -export function useBuildStatus(): ReadonlyMap { +// authenticated user. Pass the auth connectionId so the stream tears down and +// reconnects (with the current PAT) on in-place login and account switch. +export function useBuildStatus(connectionId = 0): ReadonlyMap { const [statuses, setStatuses] = useState>(() => new Map()); useEffect(() => { @@ -66,7 +66,7 @@ export function useBuildStatus(): ReadonlyMap { cancelled = true; controller.abort(); }; - }, []); + }, [connectionId]); return statuses; } diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 1d99f01b..990d365a 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -211,7 +211,7 @@ function useFunctionListPage(): { // to 'get resources from all namespaces' isAllNamespacesKey(namespace) ? undefined : namespace, ); - const buildStatuses = useBuildStatus(); + const buildStatuses = useBuildStatus(connectionId); const functions = useMemo( () => From 2f80a8e5bbd42afd893081350bceaa0b7e9f4ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 26 Aug 2026 17:38:31 +0200 Subject: [PATCH 10/18] docs: move build status plan to completed Co-Authored-By: Claude Opus 4.8 --- .../{active => completed}/2026-08-26-SRVOCF-1038-build-status.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/plans/{active => completed}/2026-08-26-SRVOCF-1038-build-status.md (100%) diff --git a/docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md b/docs/plans/completed/2026-08-26-SRVOCF-1038-build-status.md similarity index 100% rename from docs/plans/active/2026-08-26-SRVOCF-1038-build-status.md rename to docs/plans/completed/2026-08-26-SRVOCF-1038-build-status.md From 22d4a8ab6d66321412e479f07994213231272cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Tue, 1 Sep 2026 12:38:31 +0200 Subject: [PATCH 11/18] feat(scm): cache GitHub responses via ETags to cut build-status rate-limit usage The build-status poll loop hit GitHub every 3s per repo, exhausting the 5,000/hr core rate limit. Wire a per-client in-memory httpcache transport so unchanged responses come back as 304 Not Modified, which do not count against the primary rate limit. GitHub sends Cache-Control: max-age=60 on these responses, which would let the cache serve a stale build status for up to ~60s. A forceRevalidate transport sets Cache-Control: max-age=0 on every request so the cache always revalidates with a conditional request: unchanged status stays a free 304, but a real change is seen on the next poll. Co-Authored-By: Claude Opus 4.8 --- backend/go.mod | 2 +- backend/scm/github/client.go | 32 +++++++++++++++++- backend/scm/github/client_test.go | 55 +++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 36958be5..b1659301 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/google/go-github/v90 v90.0.0 + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/onsi/ginkgo/v2 v2.32.1 github.com/onsi/gomega v1.42.1 golang.org/x/crypto v0.55.0 @@ -95,7 +96,6 @@ require ( github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index 3b721a80..c4902b8a 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -9,6 +9,7 @@ import ( "time" ghlib "github.com/google/go-github/v90/github" + "github.com/gregjones/httpcache" "golang.org/x/sync/errgroup" "github.com/openshift/faas-console-plugin/backend/scm" @@ -19,7 +20,19 @@ func New(pat string) scm.Client { } func NewWithBaseURL(pat, baseURL string) scm.Client { - httpClient := &http.Client{Timeout: 30 * time.Second} + // A per-client in-memory HTTP cache issues conditional requests + // (If-None-Match) using the ETags GitHub returns. When build status is + // unchanged the server replies 304 Not Modified, which does NOT count + // against the primary rate limit, so the 3s poll loop stays nearly free. + // The cache is scoped per client (one per PAT), so one user's cached + // responses are never served to another. + // + // forceRevalidate wraps the cache so every request revalidates instead of + // being served from GitHub's max-age freshness window. Without it a newly + // triggered build would stay hidden for up to ~60s; with it an unchanged + // status is still just a (free) 304, but a real change is seen immediately. + cacheTransport := httpcache.NewMemoryCacheTransport() + httpClient := &http.Client{Transport: &forceRevalidate{next: cacheTransport}, Timeout: 30 * time.Second} opts := []ghlib.ClientOptionsFunc{ ghlib.WithHTTPClient(httpClient), ghlib.WithAuthToken(pat), @@ -34,6 +47,23 @@ func NewWithBaseURL(pat, baseURL string) scm.Client { return &ghClient{client: client} } +// forceRevalidate sets Cache-Control: max-age=0 on every request so the +// underlying cache always revalidates with a conditional request rather than +// serving a still-"fresh" response from GitHub's max-age window. +type forceRevalidate struct { + next http.RoundTripper +} + +func (t *forceRevalidate) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Cache-Control", "max-age=0") + resp, err := t.next.RoundTrip(req) + if err != nil { + return nil, fmt.Errorf("forced-revalidation round trip: %w", err) + } + return resp, nil +} + type ghClient struct { client *ghlib.Client } diff --git a/backend/scm/github/client_test.go b/backend/scm/github/client_test.go index 5a679e8a..a2c517ee 100644 --- a/backend/scm/github/client_test.go +++ b/backend/scm/github/client_test.go @@ -639,4 +639,59 @@ var _ = Describe("GitHub SCM client", func() { Expect(secretBody["encrypted_value"]).NotTo(BeEmpty()) }) }) + + Describe("LatestWorkflowRun conditional requests", func() { + It("revalidates with If-None-Match and serves cached data on a 304", func() { + const runsPath = "/repos/alice/my-func/actions/runs" + var requestCount int + var conditional []string + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, runsPath) { + return + } + requestCount++ + inm := r.Header.Get("If-None-Match") + conditional = append(conditional, inm) + + w.Header().Set("ETag", `"run-etag-v1"`) + // A "fresh" response (like GitHub's max-age=60). The client must + // still revalidate on every poll, otherwise a new build would be + // hidden behind this window. This guards the forceRevalidate wrap. + w.Header().Set("Cache-Control", "max-age=60") + + if inm == `"run-etag-v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "workflow_runs": []map[string]any{ + { + "id": 42, + "status": "completed", + "conclusion": "success", + "head_sha": "abc123", + "html_url": "https://example.com/runs/42", + }, + }, + }) + }) + + first, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(first).NotTo(BeNil()) + Expect(first.ID).To(Equal(int64(42))) + + second, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main") + Expect(err).NotTo(HaveOccurred()) + Expect(second).NotTo(BeNil()) + + // The first call was unconditional; the second sent If-None-Match and + // got a 304, yet still returned the same run data (served from cache). + Expect(requestCount).To(Equal(2)) + Expect(conditional[0]).To(BeEmpty()) + Expect(conditional[1]).To(Equal(`"run-etag-v1"`)) + Expect(*second).To(Equal(*first)) + }) + }) }) From b9e34d85e5bffd40f33fd7a645d57cc66c2ae487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Tue, 1 Sep 2026 20:55:21 +0200 Subject: [PATCH 12/18] fix(frontend): reconnect build-status stream after a body-less response A 2xx response with no body previously fell through the `if (!res.body) return;` guard and permanently stopped the SSE stream, so the build-status badges would silently freeze until the next connectionId change. Treat a body-less response like any other stream end: fall through to the backoff-and-reconnect path instead of giving up. Co-Authored-By: Claude Opus 4.8 --- src/common/clients/useBuildStatus.test.tsx | 14 ++++++++++++++ src/common/clients/useBuildStatus.ts | 11 +++++++---- src/common/testing/consoleFetchStreamStub.ts | 12 ++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/common/clients/useBuildStatus.test.tsx b/src/common/clients/useBuildStatus.test.tsx index 2c718447..4f6da02c 100644 --- a/src/common/clients/useBuildStatus.test.tsx +++ b/src/common/clients/useBuildStatus.test.tsx @@ -106,6 +106,20 @@ describe('useBuildStatus', () => { await waitFor(() => expect(streamStub.streamFetchCalls()).toBe(2)); }); + it('reconnects after a body-less response instead of stopping', async () => { + vi.useFakeTimers(); + streamStub.setNullBodyForNext(1); // first connect yields a 2xx with no body + + const { unmount } = renderHook(() => useBuildStatus()); + // A body-less response must not permanently stop the stream: after the 3s + // backoff the hook reconnects rather than giving up. + await vi.advanceTimersByTimeAsync(10_000); + + expect(streamStub.streamFetchCalls()).toBeGreaterThan(1); + + unmount(); + }); + it('reconnects with backoff after a transient stream error', async () => { vi.useFakeTimers(); streamStub.setStreamError(new Error('network blip')); // no status code -> transient diff --git a/src/common/clients/useBuildStatus.ts b/src/common/clients/useBuildStatus.ts index b6b1a4e7..4cbd6ec0 100644 --- a/src/common/clients/useBuildStatus.ts +++ b/src/common/clients/useBuildStatus.ts @@ -36,10 +36,13 @@ export function useBuildStatus(connectionId = 0): ReadonlyMap { - if (!cancelled) setStatuses(toMap(snap)); - }); + // A 2xx with no body is unexpected; fall through to backoff-and-reconnect + // below rather than permanently stopping the stream. + if (res.body) { + await readStream(res.body, (snap) => { + if (!cancelled) setStatuses(toMap(snap)); + }); + } } catch (err) { if (cancelled) return; if (isAuthError(err)) { diff --git a/src/common/testing/consoleFetchStreamStub.ts b/src/common/testing/consoleFetchStreamStub.ts index 3bd9dabf..6419b437 100644 --- a/src/common/testing/consoleFetchStreamStub.ts +++ b/src/common/testing/consoleFetchStreamStub.ts @@ -10,12 +10,19 @@ let frames: string[] = []; let error: unknown = null; let calls = 0; +let nullBodyCalls = 0; export function setStreamFrames(newFrames: string[]) { frames = newFrames; error = null; } +// setNullBodyForNext makes the next n consoleFetch calls resolve 2xx with a null +// body, simulating a body-less response the hook must recover from. +export function setNullBodyForNext(n: number) { + nullBodyCalls = n; +} + // setStreamError makes the next consoleFetch reject, simulating an HTTP or // network failure. Attach a `code` (HTTP status) to simulate an auth failure. export function setStreamError(err: unknown) { @@ -26,6 +33,7 @@ export function resetStreamFrames() { frames = []; error = null; calls = 0; + nullBodyCalls = 0; } // streamFetchCalls reports how many times the stubbed consoleFetch was invoked, @@ -44,6 +52,10 @@ export function buildStatusFrame(functions: unknown[]): string { export const consoleFetchStub = (): Promise => { calls++; if (error) return Promise.reject(error); + if (nullBodyCalls > 0) { + nullBodyCalls--; + return Promise.resolve(new Response(null, { status: 200 })); + } const stream = new ReadableStream({ start(controller) { From 926141bbaa7f5b0c71f73b0ec3e7f126c5260dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Tue, 1 Sep 2026 22:03:52 +0200 Subject: [PATCH 13/18] fix(scm): scope build status to the func-deploy workflow LatestWorkflowRun took the newest run across *all* workflows in a repo (ListRepositoryWorkflowRuns), so an unrelated workflow (lint, CodeQL, a cron) could mask or misrepresent the func build: a passing lint run could hide a failed build, or a failing unrelated workflow could paint a function red. Filter to the func build workflow by file name via ListWorkflowRunsByFileName. The identifier is func's own DefaultGitHubWorkflowFilename ("func-deploy.yaml"), re-exported from the scaffold package as scaffold.WorkflowFilename so it stays in sync with what we actually scaffold. The scm layer stays func-agnostic: the workflow file name is passed in as a parameter, supplied by the func-aware handler. A repo without that workflow file returns 404, which we map to a nil run (no build signal) so non-func repos and not-yet-pushed workflows fall back to the cluster-derived status instead of erroring. Co-Authored-By: Claude Opus 4.8 --- backend/fakegithub/server.go | 5 +++- backend/fakegithub/server_test.go | 16 ++++++------- backend/functions/ci.go | 7 ++++++ backend/handler/build.go | 3 ++- backend/handler/build_test.go | 12 +++++----- backend/scm/client.go | 13 ++++++----- backend/scm/github/client.go | 17 +++++++++++--- backend/scm/github/client_test.go | 39 ++++++++++++++++++++++++++++--- 8 files changed, 84 insertions(+), 28 deletions(-) diff --git a/backend/fakegithub/server.go b/backend/fakegithub/server.go index e92b08af..6fb50858 100644 --- a/backend/fakegithub/server.go +++ b/backend/fakegithub/server.go @@ -165,8 +165,11 @@ 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) + // Actions runs (build status). The client scopes build status to a single + // workflow file via the by-file-name endpoint; the repo-wide endpoint is kept + // for completeness. Both serve the same scripted runs. 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) diff --git a/backend/fakegithub/server_test.go b/backend/fakegithub/server_test.go index 3af1fd38..ba86b791 100644 --- a/backend/fakegithub/server_test.go +++ b/backend/fakegithub/server_test.go @@ -243,7 +243,7 @@ var _ = Describe("FakeGitHub Server", func() { "headSha": "abc123", "status": "in_progress", "conclusion": "" }`) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + 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")) @@ -258,7 +258,7 @@ var _ = Describe("FakeGitHub Server", func() { resetFakeGitHub(ts) seedRepo(ts) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") Expect(err).NotTo(HaveOccurred()) Expect(run).To(BeNil()) }) @@ -269,7 +269,7 @@ var _ = Describe("FakeGitHub Server", func() { ts, cl := startServer() seedRepo(ts) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") Expect(err).NotTo(HaveOccurred()) Expect(run).To(BeNil()) }) @@ -279,7 +279,7 @@ var _ = Describe("FakeGitHub Server", func() { 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") + 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")) @@ -294,7 +294,7 @@ var _ = Describe("FakeGitHub Server", func() { 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") + run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main", "func-deploy.yaml") Expect(err).NotTo(HaveOccurred()) Expect(run).To(BeNil()) }) @@ -314,7 +314,7 @@ var _ = Describe("FakeGitHub Server", func() { }] }`) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + 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")) @@ -335,7 +335,7 @@ var _ = Describe("FakeGitHub Server", func() { }] }`) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + 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")) @@ -365,7 +365,7 @@ var _ = Describe("FakeGitHub Server", func() { ] }`) - run, err := cl.LatestWorkflowRun(context.Background(), "testuser", "test-func", "main") + 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")) diff --git a/backend/functions/ci.go b/backend/functions/ci.go index 59655c68..2ca6ee32 100644 --- a/backend/functions/ci.go +++ b/backend/functions/ci.go @@ -11,6 +11,13 @@ import ( "github.com/openshift/faas-console-plugin/backend/scm" ) +// WorkflowFilename is the file name (under .github/workflows/) of the CI +// workflow generated for a function. It is the identifier used to scope build +// status queries to the func build workflow rather than to arbitrary other +// workflows in the repository. It aliases func's default so it stays in sync +// with what generateGithubCIFiles actually writes. +const WorkflowFilename = cigithub.DefaultGitHubWorkflowFilename + var ciGenerators = map[scm.Platform]func(string, ScaffoldConfig) error{ scm.GitHub: generateGithubCIFiles, } diff --git a/backend/handler/build.go b/backend/handler/build.go index dde1b10b..05e851bc 100644 --- a/backend/handler/build.go +++ b/backend/handler/build.go @@ -14,6 +14,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/openshift/faas-console-plugin/backend/config" + "github.com/openshift/faas-console-plugin/backend/functions" "github.com/openshift/faas-console-plugin/backend/scm" ) @@ -92,7 +93,7 @@ func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Rep for i, repo := range repos { g.Go(func() error { key := repo.Owner + "/" + repo.Name - run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch) + run, err := client.LatestWorkflowRun(ctx, repo.Owner, repo.Name, repo.DefaultBranch, functions.WorkflowFilename) if err != nil { slog.Warn("failed to get workflow run", "repo", key, "err", err) if last, ok := prev[key]; ok { diff --git a/backend/handler/build_test.go b/backend/handler/build_test.go index 6c2fe16e..79dad78b 100644 --- a/backend/handler/build_test.go +++ b/backend/handler/build_test.go @@ -33,7 +33,7 @@ var _ = Describe("HandleBuildStatus", func() { OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { return &scm.WorkflowRun{Status: "in_progress", HeadSHA: "sha1", HTMLURL: "u"}, nil }, }) @@ -56,7 +56,7 @@ var _ = Describe("HandleBuildStatus", func() { OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { return nil, nil }, }) @@ -94,7 +94,7 @@ var _ = Describe("HandleBuildStatus", func() { {Owner: "alice", Name: "good", DefaultBranch: "main"}, }, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { if repo == "bad" { return nil, errors.New("boom") } @@ -149,7 +149,7 @@ var _ = Describe("HandleBuildWatch", func() { OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { mu.Lock() defer mu.Unlock() calls++ @@ -191,7 +191,7 @@ var _ = Describe("HandleBuildWatch", func() { OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { // Same state on every poll, so the change-detection key never moves. return &scm.WorkflowRun{Status: "in_progress"}, nil }, @@ -236,7 +236,7 @@ var _ = Describe("HandleBuildWatch", func() { OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil }, - OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { mu.Lock() defer mu.Unlock() calls++ diff --git a/backend/scm/client.go b/backend/scm/client.go index ba2bb7c1..83391c52 100644 --- a/backend/scm/client.go +++ b/backend/scm/client.go @@ -49,7 +49,7 @@ type Client interface { InitRepo(ctx context.Context, owner, name, branch string, topics []string) error StoreSecret(ctx context.Context, owner, repo, name, value string) error DeleteRepo(ctx context.Context, owner, repo string) error - LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) + LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) } type Repo struct { @@ -72,8 +72,9 @@ type FileEntry struct { Deleted bool `json:"deleted,omitempty"` } -// WorkflowRun is the latest GitHub Actions run for a repo branch. -// A nil *WorkflowRun means the branch has no runs. +// WorkflowRun is the latest GitHub Actions run of a specific workflow file on a +// repo branch. A nil *WorkflowRun means the workflow has no runs on that branch +// (including when the workflow file does not exist in the repo). type WorkflowRun struct { ID int64 Status string // queued | in_progress | completed @@ -92,7 +93,7 @@ type ClientStub struct { OnInitRepo func(ctx context.Context, owner, name, branch string, topics []string) error OnStoreSecret func(ctx context.Context, owner, repo, name, value string) error OnDeleteRepo func(ctx context.Context, owner, repo string) error - OnLatestWorkflowRun func(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) + OnLatestWorkflowRun func(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) } func (s *ClientStub) GetUser(ctx context.Context) (*User, error) { @@ -151,9 +152,9 @@ func (s *ClientStub) DeleteRepo(ctx context.Context, owner, repo string) error { return nil } -func (s *ClientStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*WorkflowRun, error) { +func (s *ClientStub) LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*WorkflowRun, error) { if s.OnLatestWorkflowRun != nil { - return s.OnLatestWorkflowRun(ctx, owner, repo, branch) + return s.OnLatestWorkflowRun(ctx, owner, repo, branch, workflowFile) } return nil, nil } diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index c4902b8a..71d41b43 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -79,6 +79,11 @@ func mapErr(err error) error { return err } +func isNotFound(err error) bool { + var ghErr *ghlib.ErrorResponse + return errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound +} + func isRepoExists(err error) bool { var ghErr *ghlib.ErrorResponse if !errors.As(err, &ghErr) || ghErr.Response == nil || ghErr.Response.StatusCode != http.StatusUnprocessableEntity { @@ -330,14 +335,20 @@ func (c *ghClient) DeleteRepo(ctx context.Context, owner, repo string) error { return nil } -func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch string) (*scm.WorkflowRun, error) { +func (c *ghClient) LatestWorkflowRun(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { opts := &ghlib.ListWorkflowRunsOptions{ Branch: branch, ListOptions: ghlib.ListOptions{PerPage: 1}, } - runs, _, err := c.client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, opts) + runs, _, err := c.client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowFile, opts) if err != nil { - return nil, fmt.Errorf("list workflow runs for %s/%s: %w", owner, repo, mapErr(err)) + if isNotFound(err) { + // The workflow file does not exist in this repo (e.g. a non-func repo, + // or the func workflow has not been pushed yet). Treat it like a repo + // with no runs rather than surfacing an error. + return nil, nil + } + return nil, fmt.Errorf("list workflow runs for %s/%s (%s): %w", owner, repo, workflowFile, mapErr(err)) } if len(runs.WorkflowRuns) == 0 { return nil, nil diff --git a/backend/scm/github/client_test.go b/backend/scm/github/client_test.go index a2c517ee..ada0f221 100644 --- a/backend/scm/github/client_test.go +++ b/backend/scm/github/client_test.go @@ -642,7 +642,7 @@ var _ = Describe("GitHub SCM client", func() { Describe("LatestWorkflowRun conditional requests", func() { It("revalidates with If-None-Match and serves cached data on a 304", func() { - const runsPath = "/repos/alice/my-func/actions/runs" + const runsPath = "/repos/alice/my-func/actions/workflows/func-deploy.yaml/runs" var requestCount int var conditional []string cl := newClient(func(w http.ResponseWriter, r *http.Request) { @@ -677,12 +677,12 @@ var _ = Describe("GitHub SCM client", func() { }) }) - first, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main") + first, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") Expect(err).NotTo(HaveOccurred()) Expect(first).NotTo(BeNil()) Expect(first.ID).To(Equal(int64(42))) - second, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main") + second, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") Expect(err).NotTo(HaveOccurred()) Expect(second).NotTo(BeNil()) @@ -694,4 +694,37 @@ var _ = Describe("GitHub SCM client", func() { Expect(*second).To(Equal(*first)) }) }) + + Describe("LatestWorkflowRun workflow scoping", func() { + It("queries only the given workflow file, not the whole repo", func() { + var gotPath string + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "workflow_runs": []map[string]any{ + {"id": 7, "status": "completed", "conclusion": "success"}, + }, + }) + }) + + run, err := cl.LatestWorkflowRun(context.Background(), "alice", "my-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).NotTo(BeNil()) + // The by-file-name endpoint scopes results to the func workflow; the + // repo-wide /actions/runs endpoint would leak unrelated workflows. + Expect(gotPath).To(Equal("/repos/alice/my-func/actions/workflows/func-deploy.yaml/runs")) + }) + + It("returns a nil run when the workflow file does not exist (404)", func() { + cl := newClient(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"}) + }) + + run, err := cl.LatestWorkflowRun(context.Background(), "alice", "no-func", "main", "func-deploy.yaml") + Expect(err).NotTo(HaveOccurred()) + Expect(run).To(BeNil()) + }) + }) }) From 32b391d9ca3909e6895e20acb1e7a8dc8d922f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Tue, 1 Sep 2026 23:22:20 +0200 Subject: [PATCH 14/18] fix(frontend): disable consoleFetch timeout on the build-status SSE stream consoleFetch applies a default ~60s request timeout that aborts the request when it fires. On the long-lived build-status SSE stream that tore the connection down every minute regardless of the backend's 15s heartbeats, forcing a reconnect and a full initial snapshot re-fetch from GitHub each time. Pass timeout 0 to disable it so the stream is ended only by the hook's own AbortController (on unmount or connectionId change). Co-Authored-By: Claude Opus 4.8 --- src/common/clients/useBuildStatus.test.tsx | 14 ++++++++++++++ src/common/clients/useBuildStatus.ts | 17 +++++++++++++---- src/common/testing/consoleFetchStreamStub.ts | 15 ++++++++++++--- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/common/clients/useBuildStatus.test.tsx b/src/common/clients/useBuildStatus.test.tsx index 4f6da02c..2354e9c2 100644 --- a/src/common/clients/useBuildStatus.test.tsx +++ b/src/common/clients/useBuildStatus.test.tsx @@ -35,6 +35,20 @@ describe('useBuildStatus', () => { expect(result.current.get('alice/gn')?.failureReason).toBe('build / test'); }); + it('opens the stream with the request timeout disabled', async () => { + // consoleFetch applies a default ~60s timeout that aborts the request. For a + // long-lived SSE stream that would tear the connection down every minute + // regardless of heartbeats, so the hook must pass timeout 0 to disable it. + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: 'alice/fn', buildStatus: 'Building' }]), + ]); + + const { result } = renderHook(() => useBuildStatus()); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(streamStub.streamFetchLastArgs()[2]).toBe(0); + }); + it('ignores heartbeat comment frames', async () => { streamStub.setStreamFrames([ ':\n\n', diff --git a/src/common/clients/useBuildStatus.ts b/src/common/clients/useBuildStatus.ts index 4cbd6ec0..cdfee373 100644 --- a/src/common/clients/useBuildStatus.ts +++ b/src/common/clients/useBuildStatus.ts @@ -32,10 +32,19 @@ export function useBuildStatus(connectionId = 0): ReadonlyMap => { +// consoleFetchStub stands in for consoleFetch(url, options, timeout): it records +// its arguments and serves the configured frames (or error) as the response body. +export const consoleFetchStub = (...args: unknown[]): Promise => { calls++; + lastArgs = args; if (error) return Promise.reject(error); if (nullBodyCalls > 0) { nullBodyCalls--; From dd1f4a01ce4ca4e34bacd21a2cb85ead3cc863d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 2 Sep 2026 00:59:08 +0200 Subject: [PATCH 15/18] fix(SRVOCF-1038): show build activity non-destructively over available functions A function that is deployed and available (serving `Running` or idle `ScaledToZero`) now keeps its cluster status while a rebuild runs or fails, instead of being overwritten by `Building`/`BuildFailed`. The build activity is surfaced only as a small secondary indicator next to the status: a spinner (tooltip "Build in progress") while building, or a red danger-colored warning icon (tooltip "Latest build failed: ", link to the run) when the latest build failed. This stops an available function from flip-flopping to a build-centric status on every redeploy and keeps availability accurate. Deferred: giving a cluster `Error` (broken deployed revision) the same non-destructive treatment. `Error` is overloaded (it also covers a repo/list error with no cluster resource), so doing it right means gating on cluster presence rather than the status string. Noted in the design doc. Co-Authored-By: Claude Opus 4.8 --- ...6-08-26-SRVOCF-1038-build-status-design.md | 47 ++++++- .../en/plugin__console-functions-plugin.json | 4 + .../function-list/FunctionsListPage.test.tsx | 65 ++++++++- src/pages/function-list/FunctionsListPage.tsx | 33 ++++- .../components/FunctionTable.test.tsx | 75 +++++++++++ .../components/FunctionTable.tsx | 123 ++++++++++++++++-- 6 files changed, 328 insertions(+), 19 deletions(-) diff --git a/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md index 5e5cd3a4..d4521905 100644 --- a/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md +++ b/docs/design/2026-08-26-SRVOCF-1038-build-status-design.md @@ -39,18 +39,47 @@ push -> GH Actions run: queued -> in_progress -> completed(success|failure) GitHub Actions is authoritative during the build; the cluster is authoritative after a successful deploy. The frontend merges the two per function: +A function is treated as **available** when its cluster status is `Running` +(actively serving) or `ScaledToZero` (deployed and idle, cold-starts on demand). +Both have deployed successfully at least once, so a subsequent build is a rebuild. + | GH Actions latest run | Cluster (useCluster) | Shown status | |------------------------------|---------------------------|-----------------------| -| queued / in_progress | any | `Building` | -| completed = failure | not Running | `BuildFailed` | +| queued / in_progress | available (Running/ScaledToZero) | cluster status kept + secondary "build in progress" indicator | +| queued / in_progress | not available | `Building` | +| completed = failure | available (Running/ScaledToZero) | cluster status kept + secondary "build failed" indicator | +| completed = failure | not available | `BuildFailed` | | completed = success, or none | (fall through) | existing cluster status (`Deploying`/`Running`/`ScaledToZero`/`Error`/`NotDeployed`) | Notes: -- `Building` overrides a stale cluster status so a re-deploy is visible. -- `BuildFailed` carries a failure reason and a link to the failing run. +- The build status is **non-destructive** over an available function: a function + that is deployed and available (serving `Running`, or idle `ScaledToZero` that + cold-starts on demand) keeps its cluster status even while a new revision builds + or a rebuild fails, so availability is never misrepresented. The build activity + is surfaced as a small secondary indicator next to the status: a spinner + (tooltip "Build in progress") while building, or a red (danger-colored) warning + icon (tooltip "Latest build failed: ", link to the run) when the latest + build failed. The failed tooltip is phrased to make clear the function is still + available and only the latest rebuild failed, not the function itself. This + avoids flip-flopping an available function between its cluster status and + `Building`/`BuildFailed` on every redeploy. +- For a function that is **not** currently available, the build status is the most + useful thing to show, so `Building` (first deploy / redeploy of a stopped + function) and `BuildFailed` become the primary status. +- `BuildFailed` (and the secondary "build failed" indicator) carries a failure + reason and a link to the failing run. - The backend returns a build-centric status (`Building`/`Succeeded`/`Failed`/`None`); the merge to `FunctionStatus` happens in the frontend, which is the only place that also has the cluster status. +- **Deferred: non-destructive treatment for a cluster `Error`.** A cluster `Error` + (ksvc `Ready=False`) means a deployed revision is broken, so a failed rebuild + currently overwrites it with `BuildFailed`, losing the runtime signal. Ideally it + would be handled like the available states (keep `Error` as primary, show the + build as a secondary indicator). The catch is that `Error` is overloaded: it also + covers a repo/list-level error (`FunctionListItem.err`, no cluster resource), + which should keep falling through to `BuildFailed` like `NotDeployed`. Doing this + correctly means gating the non-destructive branch on **cluster presence** (whether + a `ClusterFunction` exists), not on the status string. Deferred to a later change. ## Transport decision: SSE over consoleFetch stream @@ -196,11 +225,19 @@ looking up each item's build status by its `owner/repo` key. ### Types and rendering - `FunctionStatus` gains `Building` and `BuildFailed`. -- `FunctionTableItem` gains optional `buildRunURL` and `failureReason`. +- `FunctionTableItem` gains optional `buildRunURL`, `failureReason`, and + `buildActivity` (`'Building' | 'Failed'`, set only when the primary status is an + available cluster status (`Running`/`ScaledToZero`) that the build status must + not overwrite). - `StatusCell` in `FunctionTable.tsx`: - `Building` -> `ProgressStatus`. - `BuildFailed` -> error style with a tooltip showing `failureReason` and a link to `buildRunURL`. + - An available status (`Running` -> `SuccessStatus`, `ScaledToZero` -> + `InfoStatus`) with `buildActivity` renders the cluster badge plus a secondary + indicator: a spinner (tooltip "Build in progress") for `'Building'`, or a red + (danger-colored) warning icon (tooltip "Latest build failed: `failureReason`", + link to `buildRunURL`) for `'Failed'`. ## Testing diff --git a/locales/en/plugin__console-functions-plugin.json b/locales/en/plugin__console-functions-plugin.json index b608da89..d0144d72 100644 --- a/locales/en/plugin__console-functions-plugin.json +++ b/locales/en/plugin__console-functions-plugin.json @@ -6,6 +6,8 @@ "Add key/value": "Add key/value", "Back to Functions": "Back to Functions", "Branch": "Branch", + "Build failed": "Build failed", + "Build in progress": "Build in progress", "Cancel": "Cancel", "Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".": "Click \"Create new function\", choose a runtime, and add any environment variables (plain values or from a secret). Submitting creates a GitHub repository, pushes the function scaffold, and starts a GitHub Actions workflow that deploys the function to your cluster. It appears here as \"NotDeployed\" until the workflow finishes, then the status changes to \"Running\".", "Close": "Close", @@ -37,6 +39,8 @@ "GitHub Settings": "GitHub Settings", "Key": "Key", "Language": "Language", + "Latest build failed": "Latest build failed", + "Latest build failed: {{reason}}": "Latest build failed: {{reason}}", "Leave": "Leave", "Loading": "Loading", "Name": "Name", diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index 004955ac..e74abd09 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -307,9 +307,10 @@ describe('FunctionsListPage', () => { expect(await screen.findByText('Error: Error')).toBeInTheDocument(); }); - it('shows Building from the build stream, overriding cluster status', async () => { + it('shows Building as the primary status when the function is not running', async () => { + // No cluster fixture, so the function is NotDeployed: the build status is the + // most useful thing to show, so Building becomes the primary status. listFunctionsStub({ responses: [repoListItem(funcName)] }); - sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); streamStub.setStreamFrames([ streamStub.buildStatusFrame([{ key: `twoGiants/${funcName}`, buildStatus: 'Building' }]), ]); @@ -323,7 +324,27 @@ describe('FunctionsListPage', () => { expect(await screen.findByText('Progress: Building')).toBeInTheDocument(); }); - it('keeps Running when a build Failed but the cluster is Running', async () => { + it('keeps Running with a build-in-progress indicator when the cluster is Running', async () => { + // Non-destructive: a serving function keeps its green Running status while a + // new revision builds; the build is surfaced only as a secondary spinner. + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([{ key: `twoGiants/${funcName}`, buildStatus: 'Building' }]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + expect(screen.queryByText('Progress: Building')).not.toBeInTheDocument(); + }); + + it('keeps Running with a build-failed indicator when the cluster is Running', async () => { listFunctionsStub({ responses: [repoListItem(funcName)] }); sdkTestDoubles.setWatchFixtures(sdkTestDoubles.funcFixture(funcName)); streamStub.setStreamFrames([ @@ -345,6 +366,44 @@ describe('FunctionsListPage', () => { expect(await screen.findByText('Success: Running')).toBeInTheDocument(); expect(screen.queryByText('Error: BuildFailed')).not.toBeInTheDocument(); + // The failed rebuild is surfaced as a secondary indicator linking to the run. + expect(await screen.findByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + + it('keeps ScaledToZero with a build-failed indicator when the cluster is scaled to zero', async () => { + // A scaled-to-zero function is deployed and available (idle, cold-starts on + // demand), so a failed rebuild must not overwrite it with BuildFailed. + listFunctionsStub({ responses: [repoListItem(funcName)] }); + sdkTestDoubles.setWatchFixtures({ + knSvcs: [sdkTestDoubles.ksvcFixture(funcName, 'True')], + deps: [sdkTestDoubles.deploymentFixture(funcName, 0, 0)], + }); + streamStub.setStreamFrames([ + streamStub.buildStatusFrame([ + { + key: `twoGiants/${funcName}`, + buildStatus: 'Failed', + failureReason: 'build / go test', + runURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }, + ]), + ]); + + render( + + + , + ); + + expect(await screen.findByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.queryByText('Error: BuildFailed')).not.toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); }); it('shows BuildFailed with the failure reason and run link from the build stream', async () => { diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 990d365a..9312516a 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -271,11 +271,42 @@ function enrichItem(item: FunctionTableItem, cf: ClusterFunction): FunctionTable }; } +// isAvailable reports whether a function is currently deployed and available: +// actively serving (`Running`) or idle but ready to cold-start (`ScaledToZero`). +// Both have deployed successfully at least once, so a subsequent build is a +// rebuild whose status must not misrepresent the function's availability. +function isAvailable(status: FunctionTableItem['status']): boolean { + return status === 'Running' || status === 'ScaledToZero'; +} + function mergeBuild(item: FunctionTableItem, build: BuildStatus): FunctionTableItem { + if (isAvailable(item.status)) { + // Non-destructive over an available function: a function that is deployed + // and available keeps its cluster status even while a new revision builds + // or a rebuild fails, so availability is never misrepresented. The build + // activity is surfaced only as a secondary indicator (see BuildActivityIndicator). + if (build.buildStatus === 'Building') { + // The in-progress indicator is a non-clickable spinner, so no run URL is + // carried here (only the failed indicator links to the run). + return { ...item, buildActivity: 'Building' }; + } + if (build.buildStatus === 'Failed') { + return { + ...item, + buildActivity: 'Failed', + buildRunURL: build.runURL, + failureReason: build.failureReason, + }; + } + // Succeeded / None: nothing to overlay on an available function. + return item; + } + // Not currently deployed/available: the build status is the most useful thing + // to show, so it becomes the primary status. if (build.buildStatus === 'Building') { return { ...item, status: 'Building' }; } - if (build.buildStatus === 'Failed' && item.status !== 'Running') { + if (build.buildStatus === 'Failed') { return { ...item, status: 'BuildFailed', diff --git a/src/pages/function-list/components/FunctionTable.test.tsx b/src/pages/function-list/components/FunctionTable.test.tsx index 5e81e218..17f5b93a 100644 --- a/src/pages/function-list/components/FunctionTable.test.tsx +++ b/src/pages/function-list/components/FunctionTable.test.tsx @@ -147,6 +147,81 @@ describe('FunctionTable', () => { expect(screen.getByText('Success: Running')).toBeInTheDocument(); }); + it('keeps Running and shows a build-in-progress spinner when buildActivity is Building', () => { + const rebuilding: FunctionTableItem = { ...mockFunctions[0], buildActivity: 'Building' }; + + render( + + + , + ); + + expect(screen.getByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + }); + + it('keeps Running and shows a warning icon linking to the run when buildActivity is Failed', () => { + const failedRebuild: FunctionTableItem = { + ...mockFunctions[0], + buildActivity: 'Failed', + failureReason: 'build / go test', + buildRunURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }; + + render( + + + , + ); + + expect(screen.getByText('Success: Running')).toBeInTheDocument(); + expect(screen.getByText('WarningIcon')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + + it('keeps ScaledToZero and shows a build-in-progress spinner when buildActivity is Building', () => { + const idleRebuilding: FunctionTableItem = { + ...mockFunctions[0], + status: 'ScaledToZero', + buildActivity: 'Building', + }; + + render( + + + , + ); + + expect(screen.getByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.getByLabelText('Build in progress')).toBeInTheDocument(); + }); + + it('keeps ScaledToZero and shows a warning icon linking to the run when buildActivity is Failed', () => { + const idleFailedRebuild: FunctionTableItem = { + ...mockFunctions[0], + status: 'ScaledToZero', + buildActivity: 'Failed', + failureReason: 'build / go test', + buildRunURL: 'https://github.com/twoGiants/my-func/actions/runs/1', + }; + + render( + + + , + ); + + expect(screen.getByText('Info: ScaledToZero')).toBeInTheDocument(); + expect(screen.getByText('WarningIcon')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Latest build failed' })).toHaveAttribute( + 'href', + 'https://github.com/twoGiants/my-func/actions/runs/1', + ); + }); + it('renders InfoStatus for NotDeployed functions', () => { render( diff --git a/src/pages/function-list/components/FunctionTable.tsx b/src/pages/function-list/components/FunctionTable.tsx index d7d2b6b8..2405a8c9 100644 --- a/src/pages/function-list/components/FunctionTable.tsx +++ b/src/pages/function-list/components/FunctionTable.tsx @@ -7,7 +7,7 @@ import { SuccessStatus, useDeleteModal, } from '@openshift-console/dynamic-plugin-sdk'; -import { ActionList, ActionListItem, Button, Tooltip } from '@patternfly/react-core'; +import { ActionList, ActionListItem, Button, Icon, Spinner, Tooltip } from '@patternfly/react-core'; import { ExclamationTriangleIcon, PencilAltIcon, TrashIcon } from '@patternfly/react-icons'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { useTranslation } from 'react-i18next'; @@ -26,6 +26,9 @@ export interface FunctionTableItem { mainResource?: K8sResourceCommon; buildRunURL?: string; failureReason?: string; + // buildActivity is set only when the primary status is a serving `Running` + // that the build status must not overwrite (non-destructive build indicator). + buildActivity?: 'Building' | 'Failed'; } export function FunctionTable({ @@ -75,6 +78,7 @@ export function FunctionTable({ status={fn.status} failureReason={fn.failureReason} buildRunURL={fn.buildRunURL} + buildActivity={fn.buildActivity} /> @@ -106,16 +110,34 @@ function StatusCell({ status, failureReason, buildRunURL, + buildActivity, }: { status: FunctionStatus; failureReason?: string; buildRunURL?: string; + buildActivity?: 'Building' | 'Failed'; }) { const { t } = useTranslation('plugin__console-functions-plugin'); switch (status) { + // Non-destructive build indicator: a function that is deployed and available + // (serving `Running` or idle `ScaledToZero`) keeps its cluster status; any + // in-progress or failed rebuild is shown only as a small secondary indicator + // so availability is never misrepresented. case 'Running': - return ; + return withBuildActivity( + , + buildActivity, + failureReason, + buildRunURL, + ); + case 'ScaledToZero': + return withBuildActivity( + , + buildActivity, + failureReason, + buildRunURL, + ); case 'Building': case 'Deploying': case 'CreatingRepo': @@ -126,16 +148,9 @@ function StatusCell({ return ; case 'BuildFailed': { const badge = ; - const withLink = buildRunURL ? ( - - {badge} - - ) : ( - badge - ); + const withLink = buildRunURL ? {badge} : badge; return {withLink}; } - case 'ScaledToZero': case 'NotDeployed': return ; case 'Unknown': @@ -143,6 +158,94 @@ function StatusCell({ } } +// RunLink wraps content in an external link to a GitHub Actions run. Pass +// ariaLabel when the content has no visible text of its own (e.g. an icon) so +// the link still has an accessible name. +function RunLink({ + url, + ariaLabel, + children, +}: { + url: string; + ariaLabel?: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +// withBuildActivity renders a primary status badge and, when a rebuild is in +// progress or failed for an available function, appends the secondary build +// indicator next to it. +function withBuildActivity( + badge: React.ReactNode, + buildActivity?: 'Building' | 'Failed', + failureReason?: string, + buildRunURL?: string, +) { + if (!buildActivity) return <>{badge}; + return ( + + {badge} + + + ); +} + +// BuildActivityIndicator is the small secondary indicator shown next to an +// available status (`Running` or `ScaledToZero`) while a new revision builds or a rebuild fails: a +// spinner (tooltip "Build in progress") for an in-progress build, or a warning +// icon (tooltip "Latest build failed: ", link to the run) for a failed +// one. On a serving function the tooltip is phrased to make clear the function +// is still running and only the latest rebuild failed, not the function itself. +function BuildActivityIndicator({ + buildActivity, + failureReason, + buildRunURL, +}: { + buildActivity?: 'Building' | 'Failed'; + failureReason?: string; + buildRunURL?: string; +}) { + const { t } = useTranslation('plugin__console-functions-plugin'); + + if (buildActivity === 'Building') { + return ( + + + + ); + } + if (buildActivity === 'Failed') { + // status="danger" colors the icon red even inside the run link, which would + // otherwise tint it link-blue via inherited anchor color. + const icon = ( + + + + ); + const withLink = buildRunURL ? ( + + {icon} + + ) : ( + icon + ); + const tooltip = failureReason + ? t('Latest build failed: {{reason}}', { reason: failureReason }) + : t('Latest build failed'); + return {withLink}; + } + return null; +} + function UrlCell({ url }: { url?: string }) { if (!url) return ; From 1f1c2395118ef709f6a5e58a044a7e197ae04a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 2 Sep 2026 01:13:37 +0200 Subject: [PATCH 16/18] test(SRVOCF-1038): scope fakegithub by-file-name runs endpoint to the workflow The fake's by-file-name runs endpoint (/repos/{owner}/{repo}/actions/workflows/{workflow}/runs) shared a handler with the repo-wide endpoint and ignored the {workflow} path segment, so both returned every scripted run. That gave the fakegithub and e2e suites no fidelity for workflow-file scoping: a regression where build status stopped querying only func-deploy.yaml would go uncaught. Give each scripted run a workflow-file identity (defaulting to functions.WorkflowFilename so it stays in sync with what the client requests, and overridable via the admin /_admin/actions/runs "workflow" field) and filter by the {workflow} path segment on the by-file-name route. The repo-wide route still returns all runs. Add a test asserting a run under a different workflow is not returned when querying the func workflow. Co-Authored-By: Claude Opus 4.8 --- backend/fakegithub/server.go | 45 +++++++++++++++++++++++-------- backend/fakegithub/server_test.go | 13 +++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/backend/fakegithub/server.go b/backend/fakegithub/server.go index 6fb50858..3a6a8d5c 100644 --- a/backend/fakegithub/server.go +++ b/backend/fakegithub/server.go @@ -15,6 +15,8 @@ import ( "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. @@ -45,6 +47,10 @@ type workflowRun struct { 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 { @@ -166,8 +172,8 @@ func (s *Server) routes() { 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; the repo-wide endpoint is kept - // for completeness. Both serve the same scripted runs. + // 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) @@ -676,13 +682,21 @@ func (s *Server) handleListWorkflowRuns(w http.ResponseWriter, r *http.Request) } 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 { - runs = append(runs, run) + if branch != "" && run.HeadBranch != branch { + continue } + if workflow != "" && run.WorkflowFile != workflow { + continue + } + runs = append(runs, run) } if runs == nil { runs = []workflowRun{} @@ -794,6 +808,10 @@ type adminRunRequest struct { 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) { @@ -808,6 +826,10 @@ func (s *Server) handleAdminSetRun(w http.ResponseWriter, r *http.Request) { if req.Branch == "" { req.Branch = "main" } + workflowFile := req.Workflow + if workflowFile == "" { + workflowFile = functions.WorkflowFilename + } key := req.Owner + "/" + req.Repo rp, ok := s.repos[key] @@ -818,13 +840,14 @@ func (s *Server) handleAdminSetRun(w http.ResponseWriter, r *http.Request) { 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, + 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] diff --git a/backend/fakegithub/server_test.go b/backend/fakegithub/server_test.go index ba86b791..e8e25dc3 100644 --- a/backend/fakegithub/server_test.go +++ b/backend/fakegithub/server_test.go @@ -299,6 +299,19 @@ var _ = Describe("FakeGitHub Server", func() { 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) From 741e1f39441f8341d9e68dc946854513e1c24ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 2 Sep 2026 01:23:26 +0200 Subject: [PATCH 17/18] fix(SRVOCF-1038): end build-status stream when the token is revoked mid-stream Once the SSE stream is established, a per-repo LatestWorkflowRun error (including ErrUnauthorized) is logged and the last-known status is carried forward, and the 30s rediscover ListRepos error was only logged. So if the caller's PAT was revoked after connecting, every poll failed, the change-detection key never moved, no new frame was sent, and the client showed stale build status indefinitely without ever seeing an auth error to trigger re-auth. ListRepos is a single global call, so its ErrUnauthorized unambiguously means the token is no longer valid. End the stream in that case; the client's reconnect then hits the initial ListRepos, gets a 401 before the SSE upgrade, and its existing isAuthError path stops the loop / prompts re-auth. Detection latency is bounded by the rediscover interval. Non-auth rediscover errors still just log. Co-Authored-By: Claude Opus 4.8 --- backend/handler/build.go | 9 +++++ backend/handler/build_test.go | 70 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/backend/handler/build.go b/backend/handler/build.go index 05e851bc..add612ae 100644 --- a/backend/handler/build.go +++ b/backend/handler/build.go @@ -210,6 +210,15 @@ func (h *Handlers) HandleBuildWatch(w http.ResponseWriter, r *http.Request) { flusher.Flush() case <-rediscover.C: if latest, err := client.ListRepos(ctx); err != nil { + // A revoked/expired token makes this global call fail + // unambiguously. End the stream so the client's reconnect hits the + // initial ListRepos, gets a 401, and triggers its re-auth path; + // otherwise per-repo poll errors are carried forward and the client + // would show stale status indefinitely. + if errors.Is(err, scm.ErrUnauthorized) { + slog.Info("build watch: token no longer authorized, ending stream") + return + } slog.Warn("build watch: rediscover failed", "err", err) } else { repos = latest diff --git a/backend/handler/build_test.go b/backend/handler/build_test.go index 79dad78b..a955ed79 100644 --- a/backend/handler/build_test.go +++ b/backend/handler/build_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -270,6 +271,75 @@ var _ = Describe("HandleBuildWatch", func() { _, ok = readSSEDataWithin(reader, 300*time.Millisecond) Expect(ok).To(BeFalse(), "expected no new frame while transient errors are carried forward") }) + + It("ends the stream when the token is revoked mid-stream", func() { + // Fire rediscover quickly so the test does not wait a real interval. + origPoll := buildPollInterval + origRediscover := buildRediscoverInterval + origHeartbeat := buildHeartbeatInterval + buildPollInterval = 10 * time.Millisecond + buildRediscoverInterval = 10 * time.Millisecond + buildHeartbeatInterval = time.Hour + DeferCleanup(func() { + buildPollInterval = origPoll + buildRediscoverInterval = origRediscover + buildHeartbeatInterval = origHeartbeat + }) + + var mu sync.Mutex + listCalls := 0 + withSCMStub(&scm.ClientStub{ + OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { + mu.Lock() + defer mu.Unlock() + listCalls++ + // The initial discovery succeeds; the token is then revoked, so + // every rediscover call is unauthorized. + if listCalls == 1 { + return []scm.Repo{{Owner: "alice", Name: "fn", DefaultBranch: "main"}}, nil + } + return nil, scm.ErrUnauthorized + }, + OnLatestWorkflowRun: func(ctx context.Context, owner, repo, branch, workflowFile string) (*scm.WorkflowRun, error) { + return &scm.WorkflowRun{Status: "in_progress"}, nil + }, + }) + + mux := http.NewServeMux() + mux.HandleFunc("GET /watch", (&Handlers{}).HandleBuildWatch) + ts := httptest.NewServer(mux) + DeferCleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/watch", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("X-SCM-Token", "pat") + resp, err := ts.Client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + first, ok := readSSEDataWithin(reader, 2*time.Second) + Expect(ok).To(BeTrue(), "expected an initial snapshot frame") + Expect(first).To(ContainSubstring(`"buildStatus":"Building"`)) + + // Once the token is revoked, the rediscover tick ends the stream, so the + // response body reaches EOF. + errCh := make(chan error, 1) + go func() { + for { + if _, err := reader.ReadString('\n'); err != nil { + errCh <- err + return + } + } + }() + select { + case err := <-errCh: + Expect(err).To(MatchError(io.EOF)) + case <-time.After(2 * time.Second): + Fail("expected the stream to close after the token was revoked") + } + }) }) var _ = Describe("deriveBuildStatus", func() { From 8618601ba8ebf98f3cfd09ff36f0b7351b3e67be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Va=C5=A1ek?= Date: Wed, 2 Sep 2026 01:32:15 +0200 Subject: [PATCH 18/18] fix(build): stop leaking internal fetch errors to the browser The per-repo build-status item carried an "Err" field populated with the raw error string from a failed workflow-run fetch. That string was never consumed by the frontend but was serialized onto the wire, exposing internal error detail to the browser. Drop the field: a failed fetch with no prior state now reports a plain "None" item and the cause is logged server-side instead. Co-Authored-By: Claude Opus 4.8 --- backend/handler/build.go | 10 ++++++---- backend/handler/build_test.go | 4 +--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/handler/build.go b/backend/handler/build.go index add612ae..b36b5c63 100644 --- a/backend/handler/build.go +++ b/backend/handler/build.go @@ -32,7 +32,6 @@ type buildStatusItem struct { RunURL string `json:"runURL,omitempty"` FailureReason string `json:"failureReason,omitempty"` HeadSHA string `json:"headSHA,omitempty"` - Err string `json:"err,omitempty"` } type buildSnapshot struct { @@ -84,8 +83,8 @@ func toBuildStatusItem(key string, run *scm.WorkflowRun) buildStatusItem { // "None": a transient GitHub error would otherwise flicker the badge back to // the cluster status and, via a varying error string, defeat the watch loop's // change-detection and force a re-send every poll. prev may be nil (the -// one-shot snapshot endpoint has no prior state), in which case a fresh error -// item is emitted. +// one-shot snapshot endpoint has no prior state), in which case a "None" item is +// emitted for the failed repo (the cause is logged, not sent to the client). func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Repo, prev map[string]buildStatusItem) buildSnapshot { items := make([]buildStatusItem, len(repos)) g, ctx := errgroup.WithContext(ctx) @@ -99,7 +98,10 @@ func buildStatusSnapshot(ctx context.Context, client scm.Client, repos []scm.Rep if last, ok := prev[key]; ok { items[i] = last } else { - items[i] = buildStatusItem{Key: key, BuildStatus: "None", Err: err.Error()} + // No prior status to carry forward: report "None" and rely on + // the server log above for the cause. The error is deliberately + // not put on the wire (the frontend does not consume it). + items[i] = buildStatusItem{Key: key, BuildStatus: "None"} } return nil } diff --git a/backend/handler/build_test.go b/backend/handler/build_test.go index a955ed79..b735f60f 100644 --- a/backend/handler/build_test.go +++ b/backend/handler/build_test.go @@ -87,7 +87,7 @@ var _ = Describe("HandleBuildStatus", func() { Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) - It("surfaces a per-repo fetch error without affecting other repos", func() { + It("isolates a per-repo fetch error to that repo without leaking it", func() { withSCMStub(&scm.ClientStub{ OnListRepos: func(ctx context.Context) ([]scm.Repo, error) { return []scm.Repo{ @@ -118,9 +118,7 @@ var _ = Describe("HandleBuildStatus", func() { byKey[f.Key] = f } Expect(byKey["alice/bad"].BuildStatus).To(Equal("None")) - Expect(byKey["alice/bad"].Err).To(ContainSubstring("boom")) Expect(byKey["alice/good"].BuildStatus).To(Equal("Building")) - Expect(byKey["alice/good"].Err).To(BeEmpty()) }) })