Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/issues/:number/comments carries an ETag and answers a repeat with 304 until a comment is added or edited #9134
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9130-get-api-v1-repos-owner-name-issues-numbe → main 2 commits fork
3 files
changed +103
−13
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberComments.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import { REPO, type Route, limitTo } from "../shared.ts"; |
| 2 | 2 | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id; cursor (an issue's commentsNext) answers the page of older comments before it, next being the cursor for older still", auth: false, query: { after: "string", cursor: "string", limit: limitTo(100) } }; | |
| 3 | export const route: Route = { method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id; cursor (an issue's commentsNext) answers the page of older comments before it, next being the cursor for older still; the 200 carries a weak etag of the page and cache-control private, no-cache (it varies by reader), and a repeat sending If-None-Match gets 304 with no body once access is checked", auth: false, alsoStatus: [304], query: { after: "string", cursor: "string", limit: limitTo(100) } }; |
src/http/api/issues.ts +27 −12
| @@ -99,6 +99,18 @@ export async function labelsHandler(ctx: Ctx): Promise<Response> { | ||
| 99 | 99 | return json(set.value); |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | /** | |
| 103 | * A reader-scoped JSON answer tagged by its exact bytes: a repeat holding the | |
| 104 | * tag gets 304. Callers check access first, so a 304 confirms nothing to | |
| 105 | * someone who may not read it. | |
| 106 | */ | |
| 107 | function tagged(request: Request, text: string): Response { | |
| 108 | const etag = weakEtag(text); | |
| 109 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 110 | if (holds(request, etag)) return new Response(null, { status: 304, headers }); | |
| 111 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 112 | } | |
| 113 | ||
| 102 | 114 | export function issueRoutes(router: Router<Handler>): void { |
| 103 | 115 | router.add( |
| 104 | 116 | "PUT", |
| @@ -156,10 +168,7 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 156 | 168 | ); |
| 157 | 169 | // Scoped by who asks (sealed issues), so private to the reader; the tag |
| 158 | 170 | // is of the exact bytes sent, so each page, state and fields value has its own. |
| 159 | const etag = weakEtag(text); | |
| 160 | const headers = { etag, "cache-control": "private, no-cache", vary: "authorization, cookie" }; | |
| 161 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 162 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 171 | return tagged(ctx.request, text); | |
| 163 | 172 | }); |
| 164 | 173 | }); |
| 165 | 174 | |
| @@ -275,10 +284,13 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 275 | 284 | ); |
| 276 | 285 | const page = older.slice(older.length - Math.min(limit, older.length)); |
| 277 | 286 | return timePhase(ctx.request, "render", () => |
| 278 | json({ | |
| 279 | comments: readerMarked(ctx, found.repo.id, page), | |
| 280 | next: older.length > limit ? page[0]!.id : null, | |
| 281 | }), | |
| 287 | tagged( | |
| 288 | ctx.request, | |
| 289 | JSON.stringify({ | |
| 290 | comments: readerMarked(ctx, found.repo.id, page), | |
| 291 | next: older.length > limit ? page[0]!.id : null, | |
| 292 | }), | |
| 293 | ), | |
| 282 | 294 | ); |
| 283 | 295 | } |
| 284 | 296 | const { rows, page } = timePhase(ctx.request, "comments", () => { |
| @@ -289,10 +301,13 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 289 | 301 | return { rows: read, page: read.slice(0, limit) }; |
| 290 | 302 | }); |
| 291 | 303 | return timePhase(ctx.request, "render", () => |
| 292 | json({ | |
| 293 | comments: readerMarked(ctx, found.repo.id, page), | |
| 294 | next: rows.length > limit ? page[page.length - 1]!.id : null, | |
| 295 | }), | |
| 304 | tagged( | |
| 305 | ctx.request, | |
| 306 | JSON.stringify({ | |
| 307 | comments: readerMarked(ctx, found.repo.id, page), | |
| 308 | next: rows.length > limit ? page[page.length - 1]!.id : null, | |
| 309 | }), | |
| 310 | ), | |
| 296 | 311 | ); |
| 297 | 312 | }); |
| 298 | 313 |
test/issues-etag.test.ts +75
| @@ -84,3 +84,78 @@ describe("issues list ETag", () => { | ||
| 84 | 84 | expect(writer.headers.get("etag")).not.toBe(out.headers.get("etag")); |
| 85 | 85 | }); |
| 86 | 86 | }); |
| 87 | ||
| 88 | describe("issue comments ETag", () => { | |
| 89 | let dataDir: string; | |
| 90 | let app: App; | |
| 91 | let servers: Servers; | |
| 92 | let base: string; | |
| 93 | let token = ""; | |
| 94 | const send = (path: string, method = "GET", bodyObj?: unknown, headers: Record<string, string> = {}, auth = true) => | |
| 95 | fetch(`${base}${path}`, { | |
| 96 | method, | |
| 97 | headers: { | |
| 98 | ...(auth ? { authorization: `Bearer ${token}` } : {}), | |
| 99 | "content-type": "application/json", | |
| 100 | ...headers, | |
| 101 | }, | |
| 102 | ...(bodyObj === undefined ? {} : { body: JSON.stringify(bodyObj) }), | |
| 103 | }); | |
| 104 | ||
| 105 | beforeAll(async () => { | |
| 106 | dataDir = await mkdtemp(join(tmpdir(), "forge-comments-etag-")); | |
| 107 | app = createApp({ dataDir, port: 0 }); | |
| 108 | servers = await serve(app); | |
| 109 | base = servers.url; | |
| 110 | await seedAdmin(app, { username: "dev", email: "[email protected]", password: "etag-pass-1" }); | |
| 111 | token = await fetch(`${base}/api/v1/tokens`, { | |
| 112 | method: "POST", | |
| 113 | headers: { "content-type": "application/json" }, | |
| 114 | body: JSON.stringify({ adminConfirm: true, username: "dev", password: "etag-pass-1", name: "c", scope: "rw" }), | |
| 115 | }) | |
| 116 | .then((r) => r.json()) | |
| 117 | .then((j) => (j as { token: string }).token); | |
| 118 | await send("/api/v1/repos", "POST", { name: "lib", visibility: "public" }); | |
| 119 | await send("/api/v1/repos/dev/lib/issues", "POST", { title: "T", body: "b" }); | |
| 120 | await send("/api/v1/repos/dev/lib/issues", "POST", { title: "Hidden", body: "x", sealed: true }); | |
| 121 | await send("/api/v1/repos/dev/lib/issues/1/comments", "POST", { body: "one" }); | |
| 122 | await send("/api/v1/repos/dev/lib/issues/2/comments", "POST", { body: "secret" }); | |
| 123 | }); | |
| 124 | ||
| 125 | afterAll(async () => { | |
| 126 | await servers.stop(); | |
| 127 | await rm(dataDir, { recursive: true, force: true }); | |
| 128 | }); | |
| 129 | ||
| 130 | test("200, 304, then 200 with a new tag after a comment", async () => { | |
| 131 | const path = "/api/v1/repos/dev/lib/issues/1/comments"; | |
| 132 | const first = await send(path); | |
| 133 | expect(first.status).toBe(200); | |
| 134 | const tag = first.headers.get("etag")!; | |
| 135 | expect(tag).toMatch(/^W\//); | |
| 136 | expect(first.headers.get("cache-control")).toBe("private, no-cache"); | |
| 137 | expect(first.headers.get("vary")).toContain("authorization, cookie"); | |
| 138 | await first.text(); | |
| 139 | const again = await send(path, "GET", undefined, { "if-none-match": tag }); | |
| 140 | expect(again.status).toBe(304); | |
| 141 | expect(again.headers.get("etag")).toBe(tag); | |
| 142 | expect(await again.text()).toBe(""); | |
| 143 | await send(path, "POST", { body: "two" }); | |
| 144 | const changed = await send(path, "GET", undefined, { "if-none-match": tag }); | |
| 145 | expect(changed.status).toBe(200); | |
| 146 | expect(changed.headers.get("etag")).not.toBe(tag); | |
| 147 | const cursorTag = (await send(`${path}?limit=1`)).headers.get("etag"); | |
| 148 | expect(cursorTag).not.toBe(changed.headers.get("etag")); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a stranger is refused a sealed thread whatever tag they hold", async () => { | |
| 152 | const path = "/api/v1/repos/dev/lib/issues/2/comments"; | |
| 153 | const own = (await send(path)).headers.get("etag")!; | |
| 154 | const out = await send(path, "GET", undefined, { "if-none-match": own }, false); | |
| 155 | expect(out.status).not.toBe(304); | |
| 156 | expect(out.status).not.toBe(200); | |
| 157 | const star = await send(path, "GET", undefined, { "if-none-match": "*" }, false); | |
| 158 | expect(star.status).not.toBe(304); | |
| 159 | expect(star.status).not.toBe(200); | |
| 160 | }); | |
| 161 | }); |
new above
offline