diff --git a/api/stovepipe/proto/stovepipe.proto b/api/stovepipe/proto/stovepipe.proto index 24b657d79..eeccec8d1 100644 --- a/api/stovepipe/proto/stovepipe.proto +++ b/api/stovepipe/proto/stovepipe.proto @@ -112,7 +112,7 @@ message GetProjectStatusByURIRequest { string queue = 1; // Exact VCS-agnostic URI of the commit under validation. string change_uri = 2; - // Required consumer-defined project IDs whose recorded results are requested. + // Consumer-defined project IDs whose recorded results are requested. Empty returns request and repository status only. repeated string projects = 3; // Maximum requested projects to return in one page. Zero selects the server default. int32 page_size = 4; diff --git a/api/stovepipe/protopb/stovepipe.pb.go b/api/stovepipe/protopb/stovepipe.pb.go index 48dc97754..8ae4453e6 100644 --- a/api/stovepipe/protopb/stovepipe.pb.go +++ b/api/stovepipe/protopb/stovepipe.pb.go @@ -627,7 +627,7 @@ type GetProjectStatusByURIRequest struct { Queue string `protobuf:"bytes,1,opt,name=queue,proto3" json:"queue,omitempty"` // Exact VCS-agnostic URI of the commit under validation. ChangeUri string `protobuf:"bytes,2,opt,name=change_uri,json=changeUri,proto3" json:"change_uri,omitempty"` - // Required consumer-defined project IDs whose recorded results are requested. + // Consumer-defined project IDs whose recorded results are requested. Empty returns request and repository status only. Projects []string `protobuf:"bytes,3,rep,name=projects,proto3" json:"projects,omitempty"` // Maximum requested projects to return in one page. Zero selects the server default. PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` diff --git a/stovepipe/controller/get_project_status_by_uri.go b/stovepipe/controller/get_project_status_by_uri.go index b7cd09720..9dc2b402e 100644 --- a/stovepipe/controller/get_project_status_by_uri.go +++ b/stovepipe/controller/get_project_status_by_uri.go @@ -172,9 +172,6 @@ func validateProjectStatusRequest(req entity.GetProjectStatusByURIRequest) error if err := validateHistoryIdentifier("change URI", req.ChangeURI); err != nil { return fmt.Errorf("GetProjectStatusByURI invalid change_uri=%q: %w", req.ChangeURI, err) } - if len(req.Projects) == 0 { - return fmt.Errorf("GetProjectStatusByURI projects must be non-empty: %w", ErrInvalidRequest) - } seen := make(map[string]struct{}, len(req.Projects)) for _, project := range req.Projects { if err := validateHistoryIdentifier("project", project); err != nil { diff --git a/stovepipe/controller/get_project_status_by_uri_test.go b/stovepipe/controller/get_project_status_by_uri_test.go index dbe04e662..313a9647d 100644 --- a/stovepipe/controller/get_project_status_by_uri_test.go +++ b/stovepipe/controller/get_project_status_by_uri_test.go @@ -58,7 +58,9 @@ func TestGetProjectStatusByURI(t *testing.T) { {name: "missing uri", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, uriErr: storage.ErrNotFound, wantNotFound: true}, {name: "request summary visibility gap", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a"}}, summaryErr: storage.ErrNotFound, wantRetryable: true}, {name: "empty queue", request: entity.GetProjectStatusByURIRequest{ChangeURI: projectStatusURI}, wantInvalid: true}, - {name: "empty projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI}, wantInvalid: true}, + {name: "no projects without repository fact", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI}, factErr: storage.ErrNotFound}, + {name: "empty projects with repository fact", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{}}, fact: entity.ValidationFact{URI: projectStatusURI, RequestID: projectStatusID, Degree: entity.DegreeGreen}, wantFact: true}, + {name: "project page token without projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageToken: "token"}, factErr: storage.ErrNotFound, wantInvalid: true, noProjectLookup: true}, {name: "empty project", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{""}}, wantInvalid: true}, {name: "duplicate projects", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, Projects: []string{"project-a", "project-a"}}, wantInvalid: true}, {name: "invalid page size", request: entity.GetProjectStatusByURIRequest{Queue: projectStatusQueue, ChangeURI: projectStatusURI, PageSize: maxProjectStatusPageSize + 1}, wantInvalid: true}, @@ -110,6 +112,8 @@ func TestGetProjectStatusByURI(t *testing.T) { assert.Equal(t, requestSummary, got.RequestSummary) assert.Equal(t, tt.wantFact, got.HasRepositoryValidationFact) assert.False(t, got.ProjectResultsComplete) + assert.Empty(t, got.ProjectValidationFacts) + assert.Empty(t, got.NextPageToken) if tt.wantFact { assert.Equal(t, tt.fact, got.RepositoryValidationFact) } diff --git a/stovepipe/entity/project_status.go b/stovepipe/entity/project_status.go index 312dbcf63..fbe32fc74 100644 --- a/stovepipe/entity/project_status.go +++ b/stovepipe/entity/project_status.go @@ -20,7 +20,7 @@ type GetProjectStatusByURIRequest struct { Queue string // ChangeURI identifies the exact commit under validation. ChangeURI string - // Projects limits results to the supplied consumer-defined project IDs. + // Projects limits results to the supplied consumer-defined project IDs; empty returns request and repository status only. Projects []string // PageSize is the requested maximum number of requested projects to inspect. PageSize int32 diff --git a/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts index e34c58028..43f653661 100644 --- a/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts +++ b/web/api/src/gen/api/stovepipe/proto/stovepipe_pb.ts @@ -338,7 +338,7 @@ export type GetProjectStatusByURIRequest = Message<"uber.submitqueue.stovepipe.G changeUri: string; /** - * Required consumer-defined project IDs whose recorded results are requested. + * Consumer-defined project IDs whose recorded results are requested. Empty returns request and repository status only. * * @generated from field: repeated string projects = 3; */ diff --git a/web/service/stovepipe/README.md b/web/service/stovepipe/README.md index 3ab985d46..42d035841 100644 --- a/web/service/stovepipe/README.md +++ b/web/service/stovepipe/README.md @@ -13,7 +13,7 @@ For a separate backend or development server, configure the environment below an | `STOVEPIPE_URL` | Backend origin; the local overlay sets `http://stovepipe-service:8080`. | | `STOVEPIPE_ALLOW_PLAINTEXT` | Set `true` for local HTTP/2 without TLS. | | `STOVEPIPE_WEB_TOKEN` | Required Basic authentication password; the local overlay defaults to `test`. | -| `STOVEPIPE_WEB_QUEUES` | JSON array of queue objects with `name` and nonempty `projects` arrays. Names must match the backend; project IDs must be unique within each queue. The local overlay supplies the demo queues. | +| `STOVEPIPE_WEB_QUEUES` | JSON array of queue objects with `name` and `projects` arrays. Use an empty array to load request status, repository results, and history without project results. Names must match the backend; project IDs must be unique within each queue. The local overlay supplies the demo queues with empty project arrays. | | `LOG_LEVEL` | JSON logging level; defaults to `info`. | A queue such as `monorepo/main` appears at `/monorepo%2Fmain`; its change page appears at `/monorepo%2Fmain/change/`. diff --git a/web/service/stovepipe/docker-compose.yml b/web/service/stovepipe/docker-compose.yml index 38f260e97..a8e664a27 100644 --- a/web/service/stovepipe/docker-compose.yml +++ b/web/service/stovepipe/docker-compose.yml @@ -9,7 +9,7 @@ services: STOVEPIPE_URL: http://stovepipe-service:8080 STOVEPIPE_ALLOW_PLAINTEXT: "true" STOVEPIPE_WEB_TOKEN: ${STOVEPIPE_WEB_TOKEN:-test} - STOVEPIPE_WEB_QUEUES: ${STOVEPIPE_WEB_QUEUES:-[{"name":"monorepo/main","projects":["example"]},{"name":"monorepo/release","projects":["example"]},{"name":"monorepo/slow","projects":["example"]}]} + STOVEPIPE_WEB_QUEUES: ${STOVEPIPE_WEB_QUEUES:-[{"name":"monorepo/main","projects":[]},{"name":"monorepo/release","projects":[]},{"name":"monorepo/slow","projects":[]}]} depends_on: stovepipe-service: condition: service_started diff --git a/web/service/stovepipe/src/server/config.test.ts b/web/service/stovepipe/src/server/config.test.ts index 41a8b6c22..c89bebf3c 100644 --- a/web/service/stovepipe/src/server/config.test.ts +++ b/web/service/stovepipe/src/server/config.test.ts @@ -8,7 +8,7 @@ describe("Stovepipe host configuration", () => { it("preserves queue and project identities", () => { const queues = [ { name: "repo/main", projects: ["project/a"] }, - { name: "repo/empty", projects: ["project/b"] }, + { name: "repo/empty", projects: [] }, ]; expect( loadConfiguration({ @@ -20,7 +20,8 @@ describe("Stovepipe host configuration", () => { it("rejects missing or ambiguous queue/project configuration", () => { for (const queues of [ [], - [{ name: "q", projects: [] }], + [{ name: "q" }], + [{ name: "q", projects: [""] }], [{ name: "q", projects: ["a", "a"] }], [ { name: "q", projects: ["a"] }, diff --git a/web/service/stovepipe/src/server/config.ts b/web/service/stovepipe/src/server/config.ts index 120292036..3cd8467a7 100644 --- a/web/service/stovepipe/src/server/config.ts +++ b/web/service/stovepipe/src/server/config.ts @@ -19,12 +19,11 @@ export function loadConfiguration( typeof queue.name !== "string" || !queue.name || !Array.isArray(queue.projects) || - queue.projects.length === 0 || queue.projects.some( (project: unknown) => typeof project !== "string" || !project, ) ) - throw new Error("Each queue needs a name and at least one project ID"); + throw new Error("Each queue needs a name and an array of project IDs"); if ( queues.some((item) => item.name === queue.name) || new Set(queue.projects).size !== queue.projects.length diff --git a/web/stovepipe/README.md b/web/stovepipe/README.md index a26b92ffb..26f767f5c 100644 --- a/web/stovepipe/README.md +++ b/web/stovepipe/README.md @@ -12,7 +12,7 @@ Use the server entry's `createStovepipeWeb` to load a page and render its model Change pages use `//change/` and look up status with `GetProjectStatusByURI`. History uses the request ID returned by that lookup. The `/request/` path is reserved for a future ID-based view. Queue pagination uses the backend's opaque continuations, and the latest URL carries no time window. -Project results request ten configured project IDs per page beside the request status, with history below. Only entries returned by the backend are displayed; a page can have fewer than ten results or be empty. The raw JSON link collects the returned project entries across all backend pages, independent of display pagination. The reference host serializes only the projects array as protobuf JSON. Configured projects are not an inventory of every project in a repository. +Project results request ten configured project IDs per page beside the request status, with history below. With an empty project list, `GetProjectStatusByURI` still returns request and repository status, and history loads using the returned request ID. Only entries returned by the backend are displayed; a page can have fewer than ten results or be empty. The raw JSON link collects the returned project entries across all backend pages, independent of display pagination. The reference host serializes only the projects array as protobuf JSON. Configured projects are not an inventory of every project in a repository. ## Refresh and presentation diff --git a/web/stovepipe/entity/index.ts b/web/stovepipe/entity/index.ts index e638220e9..458ee0f59 100644 --- a/web/stovepipe/entity/index.ts +++ b/web/stovepipe/entity/index.ts @@ -1,7 +1,7 @@ export interface Queue { /** Queue name, preserved verbatim in the UI. */ name: string; - /** Nonempty consumer-selected project IDs. */ + /** Consumer-selected project IDs; empty requests only request and repository status. */ projects: readonly string[]; } export type ReadResult = diff --git a/web/stovepipe/module.test.ts b/web/stovepipe/module.test.ts index b3cda9117..ee8ab1268 100644 --- a/web/stovepipe/module.test.ts +++ b/web/stovepipe/module.test.ts @@ -195,6 +195,43 @@ describe("Stovepipe page loading", () => { }); expect(() => JSON.stringify(result)).not.toThrow(); }); + it("loads status and history without project selectors", async () => { + const { app, service, paths } = createPageTestContext([]); + vi.mocked(service.getProjectStatusByURI).mockResolvedValue({ + ...status, + projects: [], + }); + + const result = await app.handle({ + path: paths.change("repo/main", changeUri), + search: new URLSearchParams(), + }); + + expect(service.getProjectStatusByURI).toHaveBeenCalledWith( + { queue: "repo/main", changeUri, projects: [], pageSize: 10, pageToken: "" }, + {}, + ); + expect(result).toMatchObject({ + kind: "render", + model: { + requestId: "7", + status: { + ok: true, + data: { repositoryDegree: 0, projects: [], nextHref: null }, + }, + history: { ok: true }, + }, + }); + expect(service.getRequestHistoryByID).toHaveBeenCalledWith( + { queue: "repo/main", requestId: "7" }, + {}, + ); + + expect(await app.handle({ + path: paths.projectJSON("repo/main", changeUri), + search: new URLSearchParams(), + })).toEqual({ kind: "project-json", data: [] }); + }); it("requests ten configured projects per page without adding unreturned projects", async () => { const projects = Array.from( { length: 11 }, diff --git a/web/test/e2e/stovepipe/run_e2e.py b/web/test/e2e/stovepipe/run_e2e.py index 57943e88a..5495e7185 100644 --- a/web/test/e2e/stovepipe/run_e2e.py +++ b/web/test/e2e/stovepipe/run_e2e.py @@ -64,12 +64,12 @@ def main(): ) env["STOVEPIPE_WEB_QUEUES"] = json.dumps( [ - {"name": "monorepo/main", "projects": ["example"]}, + {"name": "monorepo/main", "projects": []}, { "name": "web/pagination", "projects": [f"project-{i}" for i in range(1, 52)], }, - {"name": "web/empty", "projects": ["example"]}, + {"name": "web/empty", "projects": []}, ] ) compose = [ diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts index 449360de4..95cbdc4ae 100644 --- a/web/test/e2e/stovepipe/stovepipe.spec.ts +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -56,6 +56,10 @@ test("real ingestion, queue selection, cursors, project results, history and ref await expect( page.getByText("No project results recorded on this page."), ).toBeVisible(); + const rawHref = await rawProjects.getAttribute("href"); + const response = await page.request.get(rawHref!); + expect(response.ok()).toBe(true); + expect(await response.json()).toEqual([]); await refresh.click(); await expect(refresh).toBeEnabled(); expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);