diff --git a/.changeset/robust-github-rest.md b/.changeset/robust-github-rest.md new file mode 100644 index 0000000..77de2eb --- /dev/null +++ b/.changeset/robust-github-rest.md @@ -0,0 +1,5 @@ +--- +"@agentskit/code-review": patch +--- + +Route GitHub REST traffic through one internal client with bounded JSON reads, API version and auth headers, same-origin redirect and pagination handling, and rate-limit-aware retries for GET requests. Keep all review and merge writes single-attempt. diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index ed3045d..cbf9411 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit Code Review", "root": "." }, - "contentHash": "f474b0aef326aaa3e8e78c64ddc686b6a3917854b5dd96bfebfa683aae8d138b", + "contentHash": "6b9948644e1789a1b182827c615ebb1c2ae373b9f21e7260af19351279afbe05", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": "llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index d3e5575..4d5a275 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "f474b0aef326aaa3e8e78c64ddc686b6a3917854b5dd96bfebfa683aae8d138b", + "contentHash": "6b9948644e1789a1b182827c615ebb1c2ae373b9f21e7260af19351279afbe05", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-03T01:38:03.468Z", + "generatedAt": "2026-10-03T12:54:49.400Z", "project": { "name": "AgentsKit Code Review", "root": "." @@ -106,6 +106,18 @@ ], "contentHash": "fb5a1367dec8beb0b95d995967969f0c4b87e16c21761a6a57581026c528c56c" }, + { + "id": "document:.changeset/robust-github-rest.md", + "type": "document", + "title": "robust-github-rest.md", + "path": ".changeset/robust-github-rest.md", + "description": "Route GitHub REST traffic through one internal client with bounded JSON reads, API version and auth headers, same-origin redirect and pagination handling, and rate-limit-aware retries for GET requests. Keep all review and merge writes single-attempt.", + "tags": [ + "document", + "unclassified" + ], + "contentHash": "55af8ad84bddbcdcaf39b536c364d251ef4cac836315d0ce1a06477dc7b925c1" + }, { "id": "document:.github/PULL_REQUEST_TEMPLATE.md", "type": "document", @@ -404,7 +416,7 @@ "document", "human" ], - "contentHash": "da720320098ab91a41dc11d235f8f15524e21f3f0e2d3a0876d99210faa0251a" + "contentHash": "d4034a9be5153573cbf77accda2fa34377787079e13348add34cd3f5420fb5fa" }, { "id": "document:docs/plans/ecosystem-doc-quality-code-review.md", @@ -464,7 +476,7 @@ "document", "project" ], - "contentHash": "ad54555d773a6ac456975bd899befd048c46b50b7dd3ef0dcec808f5dd2bcf1b" + "contentHash": "09d3367b44aa6e3163767d61bf9d771a5f5d6fe186067331f6717b4c9a75e878" }, { "id": "document:ROADMAP.md", @@ -1457,6 +1469,25 @@ ], "contentHash": "36fc90dc4b0cf1baff309170ecfc61e7030e381d1581230685fadb431c73c7b8" }, + { + "id": "module:src/github-api-client.ts", + "type": "module", + "title": "github-api-client.ts", + "path": "src/github-api-client.ts", + "symbols": [ + "GITHUB_API_BASE_URL", + "GITHUB_API_VERSION", + "GITHUB_REQUEST_TIMEOUT_MS", + "MAX_GITHUB_RESPONSE_BYTES", + "createGithubApiClient" + ], + "tags": [ + "module", + "ts", + "src" + ], + "contentHash": "334e57a47338564782b3ed2819081092b6aee181cfe5785fa05f7267819c8009" + }, { "id": "module:src/github-review-state.ts", "type": "module", @@ -1489,7 +1520,7 @@ "ts", "src" ], - "contentHash": "a062ff0e72cf44ff0e0f7881a4b937f144240179555af245866b19fd8d4cfa2d" + "contentHash": "0006bd6eef0307eb25c2e0a925f630a598a750f0adaa560448e0c52b85e6dc97" }, { "id": "module:src/github-scm-adapter.ts", @@ -1505,7 +1536,7 @@ "ts", "src" ], - "contentHash": "12172896656312674619d565abc99a587b7af6a58f2aa57483da6cfee31baad7" + "contentHash": "aafa98bfa0241a9125986657f9a1fab2ccfe7e92444e0f1bde976b2f78cada89" }, { "id": "module:src/grok-cli-adapter.ts", @@ -2580,7 +2611,7 @@ "mjs", "test" ], - "contentHash": "ce67040d74e22313da72ca56b1b1fcf8410cd3211adddef68c1187509a3a9aec" + "contentHash": "885d5f8d96b2499b74e79e370f6f16a08e1d37867877bbe186b3853447b00e71" }, { "id": "module:test/github-scm-adapter.test.mjs", @@ -2592,7 +2623,7 @@ "mjs", "test" ], - "contentHash": "042c68eb090d680c6d8af2e3b63b8a38b7f095d0a1ac25b25ba3885c1da52a86" + "contentHash": "d69366c503ed73e9feb37e8361333704966166af1d94d2ae70230c17aa79650e" }, { "id": "module:test/grok-cli-adapter.test.mjs", @@ -2988,7 +3019,7 @@ "mjs", "test" ], - "contentHash": "63d4444ab2eb9d0505d5a24b000e1b21c640f2970acecf318c5d5bddbc02e9a8" + "contentHash": "a732a2362eb26a89bb78f1c6107893b0e7369707014d0e4e2f61b4960f91aff6" }, { "id": "module:test/telemetry.test.mjs", @@ -3086,8 +3117,8 @@ } }, "inputs": { - "hash": "a610b9557b557af52ad2f8cdb393284bfb802595e8bccbee4cb476b729cd7482", - "fileCount": 192, + "hash": "9584d525f2267ab57f3b9a2544f1a7582f9c6993cda9ec06a2ca2e429c443481", + "fileCount": 194, "projectionVersion": 2 }, "retrieval": { @@ -3109,9 +3140,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "033331b284505f580ec5942053c6aca8814dbf45cce54cd83235e4f739f08198", + "contentHash": "18482af1ba2e0ffeb8a10da6d66394e934b89d8c16f033780b893a4e87a6383f", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "737d40210fef3c60f01f7190f71c2fdde66d1b9c2127c2a2ec75f805795ab89c", + "snapshotHash": "1c0a9281f1465e48f59f3847e089254514fdc38a3cb65c4eb7659971586736e8", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "ced84278fbcb351ab6b18cbfbc1a8e974f7ec31dc52eba9e9fc6d9d842657143", "lexiconVersion": 1, @@ -3131,7 +3162,7 @@ }, "lexical": { "version": 1, - "documentCount": 191, + "documentCount": 193, "fieldNames": [ "aliases", "body", @@ -3142,13 +3173,13 @@ "title" ], "averageFieldLength": { - "aliases": 2.884816753926702, - "body": 48.80628272251309, - "headings": 3.680628272251309, - "path": 5.638743455497382, - "summary": 3.712041884816754, - "symbols": 13.492146596858639, - "title": 4.303664921465969 + "aliases": 2.8808290155440415, + "body": 48.48186528497409, + "headings": 3.6424870466321244, + "path": 5.642487046632124, + "summary": 3.854922279792746, + "symbols": 13.476683937823834, + "title": 4.310880829015544 } }, "entries": [ @@ -3600,6 +3631,38 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "document:.changeset/robust-github-rest.md", + "kind": "document", + "path": ".changeset/robust-github-rest.md", + "title": "robust-github-rest.md", + "summary": "Route GitHub REST traffic through one internal client with bounded JSON reads, API version and auth headers, same-origin redirect and pagination handling, and rate-limit-aware retries for GET requests. Keep all review and merge writes single-attempt.", + "aliases": [], + "tags": [ + "document", + "unclassified" + ], + "fields": { + "title": "robust-github-rest.md", + "headings": "", + "path": ".changeset/robust-github-rest.md", + "symbols": "", + "summary": "Route GitHub REST traffic through one internal client with bounded JSON reads, API version and auth headers, same-origin redirect and pagination handling, and rate-limit-aware retries for GET requests. Keep all review and merge writes single-attempt.", + "body": "Route GitHub REST traffic through one internal client with bounded JSON reads, API version and auth headers, same-origin redirect and pagination handling, and rate-limit-aware retries for GET requests. Keep all review and merge writes single-attempt.", + "aliases": "document unclassified" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "inbound": [], + "outbound": [] + }, + "contentHash": "55af8ad84bddbcdcaf39b536c364d251ef4cac836315d0ce1a06477dc7b925c1", + "provenance": "observed", + "confidence": "observed" + }, { "id": "document:.github/PULL_REQUEST_TEMPLATE.md", "kind": "document", @@ -5490,7 +5553,7 @@ } ] }, - "contentHash": "da720320098ab91a41dc11d235f8f15524e21f3f0e2d3a0876d99210faa0251a", + "contentHash": "d4034a9be5153573cbf77accda2fa34377787079e13348add34cd3f5420fb5fa", "provenance": "observed", "confidence": "observed" }, @@ -5887,7 +5950,7 @@ } ] }, - "contentHash": "ad54555d773a6ac456975bd899befd048c46b50b7dd3ef0dcec808f5dd2bcf1b", + "contentHash": "09d3367b44aa6e3163767d61bf9d771a5f5d6fe186067331f6717b4c9a75e878", "provenance": "observed", "confidence": "observed" }, @@ -9258,6 +9321,57 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "module:src/github-api-client.ts", + "kind": "module", + "path": "src/github-api-client.ts", + "title": "github-api-client.ts", + "aliases": [], + "symbols": [ + "GITHUB_API_BASE_URL", + "GITHUB_API_VERSION", + "GITHUB_REQUEST_TIMEOUT_MS", + "MAX_GITHUB_RESPONSE_BYTES", + "createGithubApiClient" + ], + "tags": [ + "module", + "ts", + "src" + ], + "fields": { + "title": "github-api-client.ts", + "headings": "", + "path": "src/github-api-client.ts", + "symbols": "GITHUB_API_BASE_URL GITHUB_API_VERSION GITHUB_REQUEST_TIMEOUT_MS MAX_GITHUB_RESPONSE_BYTES createGithubApiClient", + "summary": "", + "body": "", + "aliases": "module ts src" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/code-review", + "inbound": [ + { + "kind": "imports", + "id": "module:src/github-review-state.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:src/github-scm-adapter.ts", + "confidence": "observed" + } + ], + "outbound": [] + }, + "contentHash": "334e57a47338564782b3ed2819081092b6aee181cfe5785fa05f7267819c8009", + "provenance": "observed", + "confidence": "observed" + }, { "id": "module:src/github-review-state.ts", "kind": "module", @@ -9323,9 +9437,15 @@ "confidence": "observed" } ], - "outbound": [] + "outbound": [ + { + "kind": "imports", + "id": "module:src/github-api-client.ts", + "confidence": "observed" + } + ] }, - "contentHash": "a062ff0e72cf44ff0e0f7881a4b937f144240179555af245866b19fd8d4cfa2d", + "contentHash": "0006bd6eef0307eb25c2e0a925f630a598a750f0adaa560448e0c52b85e6dc97", "provenance": "observed", "confidence": "observed" }, @@ -9389,6 +9509,11 @@ } ], "outbound": [ + { + "kind": "imports", + "id": "module:src/github-api-client.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:src/github-review-state.ts", @@ -9401,7 +9526,7 @@ } ] }, - "contentHash": "12172896656312674619d565abc99a587b7af6a58f2aa57483da6cfee31baad7", + "contentHash": "aafa98bfa0241a9125986657f9a1fab2ccfe7e92444e0f1bde976b2f78cada89", "provenance": "observed", "confidence": "observed" }, @@ -12416,7 +12541,7 @@ "inbound": [], "outbound": [] }, - "contentHash": "ce67040d74e22313da72ca56b1b1fcf8410cd3211adddef68c1187509a3a9aec", + "contentHash": "885d5f8d96b2499b74e79e370f6f16a08e1d37867877bbe186b3853447b00e71", "provenance": "observed", "confidence": "observed" }, @@ -12450,7 +12575,7 @@ "inbound": [], "outbound": [] }, - "contentHash": "042c68eb090d680c6d8af2e3b63b8a38b7f095d0a1ac25b25ba3885c1da52a86", + "contentHash": "d69366c503ed73e9feb37e8361333704966166af1d94d2ae70230c17aa79650e", "provenance": "observed", "confidence": "observed" }, @@ -13594,7 +13719,7 @@ "inbound": [], "outbound": [] }, - "contentHash": "63d4444ab2eb9d0505d5a24b000e1b21c640f2970acecf318c5d5bddbc02e9a8", + "contentHash": "a732a2362eb26a89bb78f1c6107893b0e7369707014d0e4e2f61b4960f91aff6", "provenance": "observed", "confidence": "observed" }, @@ -13746,7 +13871,7 @@ ], "outbound": [] }, - "contentHash": "a5f54f05d636a075e352ae84aac453e7a1c3ce4bc985b763e6f9190556972689", + "contentHash": "3cec18a9527595958567d19db74e8f8d3319ab7f618095b7eff15c5375bfea17", "provenance": "observed", "confidence": "observed", "ownershipId": "code-review" diff --git a/README.md b/README.md index 575b4df..33a23af 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ reviewdog -f=sarif -name=agentskit-review \ < "${REPORT_FILE}" ``` -GitHub GETs use a 30-second per-attempt timeout and at most two attempts. Network failures, `429`, and `5xx` responses retry with full jitter over a 250 ms base delay capped at 10 seconds; `Retry-After` is honored up to 60 seconds. Other statuses, including `408` and `425`, are returned without retry. GitHub response bodies default to a 25 MiB cap. Review and merge writes time out after 30 seconds and are not retried after transport or server failures; the existing review `422` fallback remains. If a review acknowledgement is lost, Code Review checks for the exact posted body before reporting failure. +GitHub GETs use a 30-second per-attempt timeout and at most two attempts. Network failures, `429`, and `5xx` responses retry with full jitter over a 250 ms base delay capped at 10 seconds; `Retry-After` is honored up to 60 seconds. Other statuses, including `408` and `425`, are returned without retry. GET and HEAD requests follow up to three redirects on the configured origin; cross-origin redirects are rejected, and writes fail instead of following redirects. Raw-content downloads follow same-origin redirects without sending the GitHub API token to the raw-content host. GitHub response bodies default to a 25 MiB cap. Review and merge writes time out after 30 seconds and are not retried after transport or server failures; the existing review `422` fallback remains. If a review acknowledgement is lost, Code Review checks for the exact posted body before reporting failure. The reviewdog recipe needs no custom converter: Code Review emits SARIF 2.1.0 and reviewdog consumes SARIF natively. See the [complete GitHub Actions job](docs/OPERATIONS.md#route-findings-through-reviewdog) for pinned installation, base-branch checkout, permissions, severity mapping, and CI ownership of the failure threshold. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 4880b06..7ad7832 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -69,6 +69,14 @@ inline and summary publication, conservative merge readiness, and revision-locked normal/admin merge. GitLab is future work and is not supported until an adapter passes the same contract suite. +GitHub REST GET and HEAD requests follow up to three redirects when each +`Location` stays on the configured origin, within the original request timeout +and retry attempt. Cross-origin redirects are rejected before forwarding +credentials. POST, PATCH, PUT, and DELETE requests fail on redirects rather than +replaying a write. Raw-content downloads use the same redirect policy with API +authentication disabled, so a same-origin redirect is followed without sending +the GitHub API token to the raw-content host. + Merge readiness also reads bounded review history. An outstanding request for changes blocks merging even when checks pass or its reviewed SHA predates the current commit. Comments do not clear a request; a later approval by that reviewer or dismissal of that request does. Missing decision identity or truncated history fails closed. ## First local setup diff --git a/llms-full.txt b/llms-full.txt index dbf3fb7..1eed9ba 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -299,7 +299,7 @@ reviewdog -f=sarif -name=agentskit-review \ < "${REPORT_FILE}" ``` -GitHub GETs use a 30-second per-attempt timeout and at most two attempts. Network failures, `429`, and `5xx` responses retry with full jitter over a 250 ms base delay capped at 10 seconds; `Retry-After` is honored up to 60 seconds. Other statuses, including `408` and `425`, are returned without retry. GitHub response bodies default to a 25 MiB cap. Review and merge writes time out after 30 seconds and are not retried after transport or server failures; the existing review `422` fallback remains. If a review acknowledgement is lost, Code Review checks for the exact posted body before reporting failure. +GitHub GETs use a 30-second per-attempt timeout and at most two attempts. Network failures, `429`, and `5xx` responses retry with full jitter over a 250 ms base delay capped at 10 seconds; `Retry-After` is honored up to 60 seconds. Other statuses, including `408` and `425`, are returned without retry. GET and HEAD requests follow up to three redirects on the configured origin; cross-origin redirects are rejected, and writes fail instead of following redirects. Raw-content downloads follow same-origin redirects without sending the GitHub API token to the raw-content host. GitHub response bodies default to a 25 MiB cap. Review and merge writes time out after 30 seconds and are not retried after transport or server failures; the existing review `422` fallback remains. If a review acknowledgement is lost, Code Review checks for the exact posted body before reporting failure. The reviewdog recipe needs no custom converter: Code Review emits SARIF 2.1.0 and reviewdog consumes SARIF natively. See the [complete GitHub Actions job](https://github.com/AgentsKit-io/code-review/blob/main/docs/OPERATIONS.md#route-findings-through-reviewdog) for pinned installation, base-branch checkout, permissions, severity mapping, and CI ownership of the failure threshold. @@ -1183,6 +1183,14 @@ inline and summary publication, conservative merge readiness, and revision-locked normal/admin merge. GitLab is future work and is not supported until an adapter passes the same contract suite. +GitHub REST GET and HEAD requests follow up to three redirects when each +`Location` stays on the configured origin, within the original request timeout +and retry attempt. Cross-origin redirects are rejected before forwarding +credentials. POST, PATCH, PUT, and DELETE requests fail on redirects rather than +replaying a write. Raw-content downloads use the same redirect policy with API +authentication disabled, so a same-origin redirect is followed without sending +the GitHub API token to the raw-content host. + Merge readiness also reads bounded review history. An outstanding request for changes blocks merging even when checks pass or its reviewed SHA predates the current commit. Comments do not clear a request; a later approval by that reviewer or dismissal of that request does. Missing decision identity or truncated history fails closed. ## First local setup diff --git a/package-lock.json b/package-lock.json index 08c2f18..12ac454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@agentskit/core": "^1.14.1", "@agentskit/cross-platform": "^0.1.0", "@agentskit/ink": "0.10.11", - "@agentskit/net": "^0.1.0", + "@agentskit/net": "^0.2.2", "@agentskit/runtime": "^0.10.17", "@agentskit/tools": "^0.13.8", "esbuild": "^0.28.2", @@ -250,12 +250,12 @@ "license": "MIT" }, "node_modules/@agentskit/net": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@agentskit/net/-/net-0.1.0.tgz", - "integrity": "sha512-BGJe9JuoeDzlYslSzhWgb1weyCQqWzfMbWLUl10iQWm3Py0bqzgxjGOEmmiVAo3QhTU+Unvxt9NQcewg8koKwQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@agentskit/net/-/net-0.2.2.tgz", + "integrity": "sha512-jse659/Z3+iqlz8PN8xzK3zzknBQ7/3b9/C4kcPX0eq6sYdH+QKIxug7cFKxm1meB2kPNmiECClsqhVYwkknvA==", "license": "MIT", "dependencies": { - "@agentskit/core": "1.13.0", + "@agentskit/core": "1.14.1", "eventsource-parser": "^4.1.1", "ipaddr.js": "^2.5.0" }, @@ -263,12 +263,6 @@ "node": ">=20.19" } }, - "node_modules/@agentskit/net/node_modules/@agentskit/core": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@agentskit/core/-/core-1.13.0.tgz", - "integrity": "sha512-KgBCpF8232hfGm5ZIzAIEVtjQdLAroGNS2Sd091S0oDr5ouDXvqwk66tVHyVOqezFTulcAyiMKvDVw4Vg89yBA==", - "license": "MIT" - }, "node_modules/@agentskit/runtime": { "version": "0.10.17", "resolved": "https://registry.npmjs.org/@agentskit/runtime/-/runtime-0.10.17.tgz", diff --git a/package.json b/package.json index f21370c..886bc0a 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "@agentskit/core": "^1.14.1", "@agentskit/cross-platform": "^0.1.0", "@agentskit/ink": "0.10.11", - "@agentskit/net": "^0.1.0", + "@agentskit/net": "^0.2.2", "@agentskit/runtime": "^0.10.17", "@agentskit/tools": "^0.13.8", "esbuild": "^0.28.2", diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 2e960e0..0175f8d 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -220,7 +220,7 @@ "docs/OPERATIONS.md", "test/cli-smoke.test.mjs" ], - "sourceHash": "sha256:67da19f61256993b62f271160769a85799aa6ba580cbf326af86c3898f75f61a" + "sourceHash": "sha256:5d7952ee710a718d2edee3081b5cd87f2292d6e22a57da62206035ee5ed4f3c4" }, "exceptions": [] } diff --git a/src/github-api-client.ts b/src/github-api-client.ts new file mode 100644 index 0000000..dbb0d2f --- /dev/null +++ b/src/github-api-client.ts @@ -0,0 +1,202 @@ +import { fetchWithRetry, parseRetryAfter, readJson, readText, retry } from '@agentskit/net' + +export const GITHUB_API_BASE_URL = 'https://api.github.com' +export const GITHUB_API_VERSION = '2022-11-28' +const MAX_ERROR_BYTES = 4 * 1024 +const MAX_RETRY_AFTER_MS = 60_000 +const MAX_PAGES = 100 +const MAX_REDIRECTS = 3 +export const GITHUB_REQUEST_TIMEOUT_MS = 30_000 +export const MAX_GITHUB_RESPONSE_BYTES = 25 * 1024 * 1024 + +interface GithubClientOptions { + token: string + fetch?: typeof fetch + baseUrl?: string +} + +interface GithubRequestOptions { + method?: string + accept?: string + body?: unknown + authenticated?: boolean +} + +interface RetryableRateLimit { + response: Response + message: string +} + +function isRetryableRateLimit(error: unknown): error is RetryableRateLimit { + return typeof error === 'object' && error !== null && 'response' in error && 'message' in error + && (error as RetryableRateLimit).response instanceof Response +} + +function parseNextLink(link: string | null): string | undefined { + if (!link) return undefined + for (const part of link.split(',')) { + const match = part.match(/^\s*<([^>]+)>\s*;(.*)$/) + if (match && /(?:^|;)\s*rel\s*=\s*(?:"next"|next)(?:\s*;|$)/i.test(match[2]!)) return match[1] + } + return undefined +} + +function isRateLimit(response: Response, message: string): boolean { + return response.status === 429 + || (response.status === 403 && (response.headers.get('x-ratelimit-remaining') === '0' + || response.headers.has('retry-after') || response.headers.has('x-ratelimit-reset') || /rate limit/i.test(message))) +} + +function rateLimitDelay(response: Response): number | undefined { + const retryAfter = parseRetryAfter(response.headers.get('retry-after')) + if (retryAfter !== undefined) return retryAfter + const reset = Number(response.headers.get('x-ratelimit-reset')) + if (Number.isFinite(reset) && reset > 0) return Math.max(0, reset * 1000 - Date.now()) + return undefined +} + +/** Internal GitHub REST client shared by review-state and SCM calls. */ +export function createGithubApiClient({ token, fetch: fetcher = fetch, baseUrl = GITHUB_API_BASE_URL }: GithubClientOptions) { + const base = new URL(baseUrl) + if (!token) throw new Error('GitHub API client needs a token') + + function resolveUrl(input: string): URL { + const url = new URL(input, base) + if (url.origin !== base.origin) throw new Error(`GitHub API URL escaped configured origin: ${url.origin}`) + return url + } + + async function request(input: string, options: GithubRequestOptions = {}): Promise { + const url = resolveUrl(input) + const method = (options.method ?? 'GET').toUpperCase() + const authenticated = options.authenticated !== false + const headers = new Headers({ + accept: options.accept ?? 'application/vnd.github+json', + 'user-agent': 'agentskit-code-review', + 'x-github-api-version': GITHUB_API_VERSION, + }) + if (authenticated) headers.set('authorization', `Bearer ${token}`) + if (options.body !== undefined) headers.set('content-type', 'application/json') + const init: RequestInit = { + method, + headers, + redirect: 'manual', + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + } + const fetchOptions = { + timeoutMs: GITHUB_REQUEST_TIMEOUT_MS, + retries: method === 'GET' ? 1 : 0, + retryMethods: method === 'GET' ? ['GET'] : [], + retryStatuses: Array.from({ length: 100 }, (_, index) => 500 + index), + minDelayMs: 250, + maxDelayMs: 10_000, + jitter: 'full' as const, + maxRetryAfterMs: MAX_RETRY_AFTER_MS, + // Keep manual redirects so credentials never leave the configured origin. + // Re-requests use fetchWithRetry's signal, so the entire redirect chain + // shares one timeout budget and remains part of the same retry attempt. + fetch: async (input: RequestInfo | URL, requestInit?: RequestInit): Promise => { + let target = new URL(input instanceof Request ? input.url : input.toString()) + for (let redirects = 0; ; redirects++) { + const result = await fetcher(target, { ...requestInit, redirect: 'manual' }) + if (![301, 302, 303, 307, 308].includes(result.status)) return result + const location = result.headers.get('location') + if (!location) return result + const redirected = new URL(location, target) + if (redirected.origin !== base.origin) { + void result.body?.cancel().catch(() => {}) + resultRedirectError = `GitHub ${method} redirect to a different origin is blocked: ${redirected.origin}` + return result + } + if (method !== 'GET' && method !== 'HEAD') { + void result.body?.cancel().catch(() => {}) + resultRedirectError = `GitHub ${method} request was redirected (${result.status}); refusing to follow a write request` + return result + } + if (redirects >= MAX_REDIRECTS) { + void result.body?.cancel().catch(() => {}) + resultRedirectError = `GitHub ${method} redirect limit exceeded (${MAX_REDIRECTS})` + return result + } + target = redirected + } + }, + } + let response: Response + let resultRedirectError: string | undefined + try { + response = await retry(async () => { + const result = await fetchWithRetry(url, init, fetchOptions) + if (method === 'GET' && (result.status === 403 || result.status === 429)) { + let message = '' + try { message = await readText(result.clone(), { maxBytes: MAX_ERROR_BYTES }) } + catch { /* A bounded error preview is optional; headers can still identify primary limits. */ } + if (isRateLimit(result, message)) throw { response: result, message } satisfies RetryableRateLimit + } + return result + }, { + retries: 1, + minDelayMs: 250, + maxDelayMs: MAX_RETRY_AFTER_MS, + jitter: 'full', + shouldRetry: (error) => isRetryableRateLimit(error) && (rateLimitDelay(error.response) ?? 0) <= MAX_RETRY_AFTER_MS, + delayFor: (error) => isRetryableRateLimit(error) ? rateLimitDelay(error.response) : undefined, + }) + } catch (error) { + if (!isRetryableRateLimit(error)) throw error + response = error.response + } + if (resultRedirectError) throw new Error(resultRedirectError) + if (!response.ok) { + let detail = '' + try { detail = (await readText(response, { maxBytes: MAX_ERROR_BYTES })).trim().slice(0, 300) } + catch { /* Keep status context when GitHub returns an oversized error body. */ } + throw new Error(`GitHub ${method} ${url} → ${response.status}${detail ? `: ${detail}` : ''}`) + } + return response + } + + async function getJson(input: string): Promise<{ data: T; response: Response }> { + const response = await request(input) + return { data: await readJson(response, { maxBytes: MAX_GITHUB_RESPONSE_BYTES }), response } + } + + async function get(input: string): Promise { + return (await getJson(input)).data + } + + async function list(input: string, maxPages = MAX_PAGES): Promise<{ items: T[]; truncated: boolean }> { + const values: T[] = [] + let next: string | undefined = input + for (let page = 0; next && page < maxPages; page++) { + const { data, response } = await getJson(next) + if (!Array.isArray(data)) throw new Error(`GitHub list response was not an array: ${response.url}`) + values.push(...data) + const link = parseNextLink(response.headers.get('link')) + next = link ? resolveUrl(link).toString() : undefined + } + return { items: values, truncated: Boolean(next) } + } + + async function listObject>(input: string, key: string): Promise { + let next: string | undefined = input + let result: T | undefined + for (let page = 0; next && page < MAX_PAGES; page++) { + const { data, response } = await getJson(next) + const batch = data[key] + if (!Array.isArray(batch)) throw new Error(`GitHub list response omitted ${key}: ${response.url}`) + result = { ...data, [key]: [...(result?.[key] as unknown[] ?? []), ...batch] } as T + const link = parseNextLink(response.headers.get('link')) + next = link ? resolveUrl(link).toString() : undefined + } + if (next) throw new Error(`GitHub pagination exceeded ${MAX_PAGES} pages`) + return result! + } + + async function json(input: string, options: GithubRequestOptions): Promise { + const response = await request(input, options) + return readJson(response, { maxBytes: MAX_GITHUB_RESPONSE_BYTES }) + } + + return { request, get, getJson, list, listObject, json } +} diff --git a/src/github-review-state.ts b/src/github-review-state.ts index 996d30a..dfb493a 100644 --- a/src/github-review-state.ts +++ b/src/github-review-state.ts @@ -1,17 +1,14 @@ import { createHash } from 'node:crypto' -import { fetchWithRetry, NetError, NetErrorCodes, readText } from '@agentskit/net' +import { NetError, NetErrorCodes, readText } from '@agentskit/net' +import { createGithubApiClient, GITHUB_REQUEST_TIMEOUT_MS, MAX_GITHUB_RESPONSE_BYTES } from './github-api-client.js' + +export { GITHUB_REQUEST_TIMEOUT_MS, MAX_GITHUB_RESPONSE_BYTES } -const API = 'https://api.github.com' -/** Per-attempt GitHub request timeout in milliseconds. */ -export const GITHUB_REQUEST_TIMEOUT_MS = 30_000 -/** Default maximum buffered GitHub response body (25 MiB). */ -export const MAX_GITHUB_RESPONSE_BYTES = 25 * 1024 * 1024 const MARKER_PREFIX = '