Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -25897,6 +25897,12 @@
"responses": {
"200": {
"description": "Badge payload"
},
"404": {
"description": "The repo has no public badge (unknown, private, uninstalled, or badgeEnabled off)"
},
"503": {
"description": "The badge data could not be loaded (a transient loader failure, short-cached)"
}
},
"security": []
Expand Down Expand Up @@ -25930,6 +25936,12 @@
"responses": {
"200": {
"description": "SVG badge"
},
"404": {
"description": "The repo has no public badge (unknown, private, uninstalled, or badgeEnabled off)"
},
"503": {
"description": "The badge data could not be loaded (a transient loader failure, short-cached)"
}
},
"security": []
Expand Down
12 changes: 10 additions & 2 deletions src/openapi/internal-and-public-route-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,11 @@ const PUBLIC_ROUTES: SpecEntry[] = [
tags: ["Public"],
summary: "Return a shields.io-compatible badge payload for a repo",
auth: "public",
responses: { 200: { description: "Badge payload" } },
responses: {
200: { description: "Badge payload" },
404: { description: "The repo has no public badge (unknown, private, uninstalled, or badgeEnabled off)" },
503: { description: "The badge data could not be loaded (a transient loader failure, short-cached)" },
},
},
{
method: "get",
Expand All @@ -243,7 +247,11 @@ const PUBLIC_ROUTES: SpecEntry[] = [
tags: ["Public"],
summary: "Return a rendered SVG badge for a repo",
auth: "public",
responses: { 200: { description: "SVG badge" } },
responses: {
200: { description: "SVG badge" },
404: { description: "The repo has no public badge (unknown, private, uninstalled, or badgeEnabled off)" },
503: { description: "The badge data could not be loaded (a transient loader failure, short-cached)" },
},
},
{
method: "get",
Expand Down
12 changes: 10 additions & 2 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
upsertAgentRecommendationOutcome,
} from "../../src/db/repositories";
import { createApp } from "../../src/api/routes";
import { renderUnavailableBadgeSvg } from "../../src/api/badge";
import { clearPublicRepoStatsCacheForTests } from "../../src/github/public";
import { getRepositoryCollaboratorPermission } from "../../src/github/app";
import { BURDEN_FORECAST_MAX_AGE_MS } from "../../src/services/burden-forecast";
Expand Down Expand Up @@ -324,7 +325,13 @@ describe("api routes", () => {
await upsertRepositoryFromGitHub(env, { name: "not-opted-in", full_name: "acme/not-opted-in", private: false, owner: { login: "acme" }, default_branch: "main" }, 556);
const notOptedIn = await app.request("/v1/public/repos/acme/not-opted-in/badge.svg", {}, env);
expect(notOptedIn.status).toBe(404);
expect(await notOptedIn.text()).toContain("unavailable");
// #9710: the "no public badge" 404 carries the SHORT cache (not the long stale-while-revalidate window) and
// renders the exact unavailable badge -- the same shape a monitor distinguishes from the 503 loader-failure.
expect(notOptedIn.headers.get("cache-control")).toBe("public, max-age=300");
expect(await notOptedIn.text()).toBe(renderUnavailableBadgeSvg());
const notOptedInJson = await app.request("/v1/public/repos/acme/not-opted-in/badge.json", {}, env);
expect(notOptedInJson.status).toBe(404);
expect(notOptedInJson.headers.get("cache-control")).toBe("public, max-age=300");

// Private repos stay unavailable even when installed and explicitly opted in.
await upsertRepositoryFromGitHub(env, { name: "private", full_name: "acme/private", private: true, owner: { login: "acme" }, default_branch: "main" }, 558);
Expand Down Expand Up @@ -358,7 +365,8 @@ describe("api routes", () => {
expect(failedSvg.status).toBe(503);
expect(failedSvg.headers.get("content-type")).toContain("image/svg+xml");
expect(failedSvg.headers.get("cache-control")).toBe("public, max-age=300");
expect(await failedSvg.text()).toContain("unavailable");
// #9710: the 503 loader-failure branch renders the exact same unavailable badge as the 404 branch.
expect(await failedSvg.text()).toBe(renderUnavailableBadgeSvg());

const failedJson = await app.request("/v1/public/repos/acme/badged/badge.json", {}, brokenEnv);
expect(failedJson.status).toBe(503);
Expand Down
8 changes: 8 additions & 0 deletions test/unit/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ describe("OpenAPI contract", () => {
expect(spec.paths["/v1/auth/github/token"]?.post?.security).toEqual([{ LoopOverSessionCookie: [] }]);
});

it("#9710: both public badge routes declare 200, 404, and 503 -- the statuses a monitor distinguishes", () => {
const spec = buildOpenApiSpec();
for (const path of ["/v1/public/repos/{owner}/{repo}/badge.svg", "/v1/public/repos/{owner}/{repo}/badge.json"]) {
const responses = spec.paths[path]?.get?.responses ?? {};
expect(Object.keys(responses).sort()).toEqual(expect.arrayContaining(["200", "404", "503"]));
}
});

// #9303: selftune-override routes were live but undocumented; assert both paths, their HTTP methods, and the
// response schema keys matching each route's MCP tool output shape (selftuneOverrideAuditOutputSchema /
// clearSelftuneOverrideOutputSchema in src/mcp/server.ts).
Expand Down
Loading