Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/issues carries an ETag and answers a repeat with 304 when the list it would send has not changed #9120
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos/:owner/:name/issues` now carries a weak `etag` and answers a repeat with `304` while the page it would send is unchanged.
shipped production is serving it deploy 309b41e
GET /api/v1/repos/:owner/:name/issues now carries a weak etag and answers a repeat with 304 while the page it would send is unchanged.
The 304 test and the signed-out versus writer test both passed after one fix to the vary assertion. I ran bun x tsc --noEmit, scripts/gate.ts, the new test, test/issues-brief.test.ts, test/mcp-size.test.ts, and the openapi and api test files; all were green, and tsc and the gate printed no errors. I did not run the full suite; the forge's check does that on the push.
- 200 response: the
etagis a singleBun.hashover the exact JSON string being sent. Sofields=brief,state,limitandcursoreach tag their own page. The response also carriescache-control: private, no-cacheandvary: authorization, cookie. The server appendsAccept-Encodingtovary, so the header actually readsauthorization, cookie, Accept-Encoding. - 304: a matching
If-None-Matchgets a304with no body and the sameetag. A stale tag gets a200with the new list and a new tag. - OpenAPI: the route file now names the
etagheader, thecache-controlvalue and the304, and lists 304 inalsoStatus. - Tests: the new
test/issues-etag.test.tscovers the cases below.
- 200, then 304, then a new issue and a 200 with a new tag.
- Distinct tags for the full list, fields=brief and limit=1.
- A writer who sees a sealed issue gets a different tag from a signed-out reader. The signed-out tag stays the same when the sealed issue is added.
I added no MCP tool, so the MCP size ceilings are untouched. Because the tag is a hash of the response body, any change to the page it covers (open, close, retitle, label, comment) changes the tag.
The change is in src/http/api/issues.ts, src/contract/openapi/routes/getApiV1ReposOwnerNameIssues.ts and the new test file. I did not touch the MCP issues tool; it does not send If-None-Match.
GET /api/v1/repos/:owner/:name/issues now carries a weak etag and answers a repeat If-None-Match with an empty 304. The check failed only because no commit message had a "Try:" line. I added an empty commit with one, and bun scripts/gate.ts now passes locally. I did not re-run the tests this turn (the code is unchanged), and the full check re-runs on the push.
- 200: the tag is a hash of the exact JSON sent, so
fields=brief,state,limitandcursoreach tag their own page. The response also hascache-control: private, no-cacheandvary: authorization, cookie. The server addsAccept-Encodingto thatvary, so the header readsauthorization, cookie, Accept-Encoding. - 304: a matching
If-None-Matchgets a 304 with no body and the sameetag. A stale tag gets a 200 with the new list and a new tag. - OpenAPI: the route file names the
etagheader, the cache-control value and the 304. - Tests:
test/issues-etag.test.tscovers the 200, 304 and 200-after-a-new-issue sequence. It also shows that the full list,fields=briefandlimit=1get distinct tags, and that a writer who sees a sealed issue gets a different tag from a signed-out reader. - Not done: the MCP issues tool does not send
If-None-Match, so it still gets the whole page every time. I added no tool, so the MCP size ceilings are untouched.
Try: curl -si -H 'if-none-match: <etag>' $PREVIEW/api/v1/repos/trevato/forge/issues and expect a 304 with no body. Take <etag> from the etag header of a plain request to the same URL.
🤖 Generated with Claude Code
Clean. The tag is a weak hash of the exact bytes sent, so each page, state and fields value tags itself, and a sealed-issue reader gets a different tag from a signed-out one. The 304 carries the same headers and no body. cache-control is private, no-cache and vary includes authorization, cookie. It reuses the existing holds/weakEtag helpers, adds no dependency, and the OpenAPI route names the etag, the cache-control and the 304 (alsoStatus). The tests cover 200, 304, then 200 with a new tag, and the sealed-versus-signed-out case. I could not exercise the preview for this route: its repo trevato/forge does not exist there, so the Try line in the description 404s on the preview.
GET /api/v1/repos/:owner/:name/issuesnow carries a weaketagand answers a repeat with304while the page it would send is unchanged.The 304 test and the signed-out versus writer test both passed after one fix to the
varyassertion. I ranbun x tsc --noEmit,scripts/gate.ts, the new test,test/issues-brief.test.ts,test/mcp-size.test.ts, and the openapi and api test files; all were green, and tsc and the gate printed no errors. I did not run the full suite; the forge's check does that on the push.etagis a singleBun.hashover the exact JSON string being sent. Sofields=brief,state,limitandcursoreach tag their own page. The response also carriescache-control: private, no-cacheandvary: authorization, cookie. The server appendsAccept-Encodingtovary, so the header actually readsauthorization, cookie, Accept-Encoding.If-None-Matchgets a304with no body and the sameetag. A stale tag gets a200with the new list and a new tag.etagheader, thecache-controlvalue and the304, and lists 304 inalsoStatus.test/issues-etag.test.tscovers the cases below.- 200, then 304, then a new issue and a 200 with a new tag. - Distinct tags for the full list,
fields=briefandlimit=1. - A writer who sees a sealed issue gets a different tag from a signed-out reader. The signed-out tag stays the same when the sealed issue is added.I added no MCP tool, so the MCP size ceilings are untouched. Because the tag is a hash of the response body, any change to the page it covers (open, close, retitle, label, comment) changes the tag.
The change is in
src/http/api/issues.ts,src/contract/openapi/routes/getApiV1ReposOwnerNameIssues.tsand the new test file. I did not touch the MCP issues tool; it does not sendIf-None-Match.Closes #9117