From 1c71ca32729e9c9b879ca398f85e76eb6c7334e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:09:39 +0000 Subject: [PATCH 1/6] Initial plan From f642b7dc2300e21370e72499309020cdfdf4cc28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:24:30 +0000 Subject: [PATCH 2/6] Refine opaque route diagnostics in eslint rule Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- eslint-factory/README.md | 3 ++ ...-github-request-interpolated-route.test.ts | 26 ++++++++++++ .../no-github-request-interpolated-route.ts | 40 ++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/eslint-factory/README.md b/eslint-factory/README.md index 64413851c51..a734ae0913b 100644 --- a/eslint-factory/README.md +++ b/eslint-factory/README.md @@ -32,6 +32,7 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl **Flagged forms:** - `` github.request(`GET /repos/${owner}/${repo}`, ...) `` — template literal with interpolations. - `github.request("GET /repos/" + owner + "/" + repo, ...)` — string concatenation. +- `` github.request(`POST ${endpoint}`, ...) `` — opaque whole-route helper; thread a typed route from the caller instead of interpolating the entire path. - `` context.github.request(`GET /repos/${owner}/${repo}`, ...) `` — `context.github` client. - `` const gh = github; gh.request(`GET /repos/${owner}/${repo}`, ...) `` — aliased client. - `` const client = getOctokit(token); client.request(`GET /repos/${owner}/${repo}`, ...) `` — `getOctokit` result alias. @@ -47,6 +48,8 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl github.request("GET /repos/{owner}/{repo}", { owner, repo }); ``` +For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `POST ${endpoint}` or `"POST " + endpoint` at the helper call site. + ### `no-json-stringify-error` Disallow `JSON.stringify()` on caught error variables. `Error` properties (`message`, `stack`, etc.) are non-enumerable, so `JSON.stringify(err)` silently produces `{}`. diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts index 52a5b35b59e..8cc10df7f1b 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.test.ts @@ -169,6 +169,32 @@ describe("no-github-request-interpolated-route", () => { }); }); + it("invalid: opaque whole-route helpers are flagged with tailored guidance", () => { + cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { + valid: [], + invalid: [ + { + code: "function addReaction(endpoint) { github.request(`POST ${endpoint}`, { content: '+1' }); }", + errors: [ + { + messageId: "opaqueWholeRoute", + data: { kind: "template literal with interpolations", client: "github" }, + }, + ], + }, + { + code: 'function addComment(endpoint) { github.request("POST " + endpoint, { body }); }', + errors: [ + { + messageId: "opaqueWholeRoute", + data: { kind: "string concatenation expression", client: "github" }, + }, + ], + }, + ], + }); + }); + it("valid: simple aliases bound to non-Octokit sources are not flagged", () => { cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, { valid: [ diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index 00241923df4..b5e44100d9c 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -39,6 +39,31 @@ function isStringConcatenation(node: TSESTree.Node): boolean { return node.type === "BinaryExpression" && node.operator === "+" && !isStaticRouteExpression(node); } +function isHttpMethodPrefix(text: string | null | undefined): boolean { + return typeof text === "string" && /^[A-Z]+ $/.test(text); +} + +/** + * Returns true when the route expression is exactly an HTTP method plus a + * single opaque route expression, such as: + * - `POST ${endpoint}` + * - `"POST " + endpoint` + * + * This shape differs from value-into-path interpolation because there is no + * known static path at the call site to rewrite with `{placeholder}` segments. + */ +function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean { + if (node.type === "TemplateLiteral") { + return node.expressions.length === 1 && node.quasis.length === 2 && isHttpMethodPrefix(node.quasis[0].value.cooked) && node.quasis[1].value.cooked === "" && !isStaticRouteExpression(node.expressions[0]); + } + + if (node.type === "BinaryExpression" && node.operator === "+") { + return node.left.type === "Literal" && typeof node.left.value === "string" && isHttpMethodPrefix(node.left.value) && !isStaticRouteExpression(node.right); + } + + return false; +} + /** * Returns true when `node` is the `context.github` member expression. */ @@ -88,10 +113,13 @@ export const noGithubRequestInterpolatedRouteRule = createRule({ "Disallow template literals with interpolations or string concatenation as the route argument of Octokit.request() calls. " + "Octokit clients are detected by well-known names (github, octokit, githubClient, octokitClient), " + "identifiers initialized from getOctokit(...) call results, context.github, and simple const aliases of any of these. " + - 'Use the typed placeholder form instead: "GET /repos/{owner}/{repo}" with a separate params object.', + "Use the typed placeholder form for value interpolation, or thread a typed route string from the caller when the entire route is dynamic.", }, schema: [], messages: { + opaqueWholeRoute: + "Avoid using an opaque whole-route {{kind}} as the route argument of {{client}}.request(). " + + "When the entire route is dynamic, pass a typed route string from the caller instead of interpolating or concatenating a route variable.", interpolatedRoute: "Avoid using a {{kind}} as the route argument of {{client}}.request(). " + 'Use the typed placeholder form instead — e.g. github.request("GET /repos/{owner}/{repo}", { owner, repo }) — ' + @@ -169,6 +197,16 @@ export const noGithubRequestInterpolatedRouteRule = createRule({ const firstArg = node.arguments[0]; if (!firstArg) return; + if (isOpaqueWholeRouteInterpolation(firstArg)) { + const kind = firstArg.type === "TemplateLiteral" ? "template literal with interpolations" : "string concatenation expression"; + context.report({ + node: firstArg, + messageId: "opaqueWholeRoute", + data: { kind, client: clientName }, + }); + return; + } + if (isInterpolatedTemplateLiteral(firstArg)) { context.report({ node: firstArg, From 5c27ee11d04bdbdda92cbeaaab5a55f99cdb40e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:27:14 +0000 Subject: [PATCH 3/6] Polish opaque route detector readability Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/rules/no-github-request-interpolated-route.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index b5e44100d9c..e94a6794ad1 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -54,11 +54,18 @@ function isHttpMethodPrefix(text: string | null | undefined): boolean { */ function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean { if (node.type === "TemplateLiteral") { - return node.expressions.length === 1 && node.quasis.length === 2 && isHttpMethodPrefix(node.quasis[0].value.cooked) && node.quasis[1].value.cooked === "" && !isStaticRouteExpression(node.expressions[0]); + const hasSingleExpression = node.expressions.length === 1; + const hasMethodPrefix = node.quasis.length === 2 && isHttpMethodPrefix(node.quasis[0].value.cooked); + const hasEmptyTrailingQuasi = node.quasis.length === 2 && node.quasis[1].value.cooked === ""; + const isDynamicWholeRoute = hasSingleExpression && !isStaticRouteExpression(node.expressions[0]); + return hasMethodPrefix && hasEmptyTrailingQuasi && isDynamicWholeRoute; } if (node.type === "BinaryExpression" && node.operator === "+") { - return node.left.type === "Literal" && typeof node.left.value === "string" && isHttpMethodPrefix(node.left.value) && !isStaticRouteExpression(node.right); + if (node.left.type !== "Literal" || typeof node.left.value !== "string") return false; + const hasMethodPrefix = isHttpMethodPrefix(node.left.value); + const isRightDynamic = !isStaticRouteExpression(node.right); + return hasMethodPrefix && isRightDynamic; } return false; From ec16b2b1689e658b7e91bb49fe3c760f375dcc3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:29:11 +0000 Subject: [PATCH 4/6] Tighten opaque route shape detection Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-github-request-interpolated-route.ts | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index e94a6794ad1..ddcaeccfeab 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -4,6 +4,7 @@ const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh const OCTOKIT_CLIENT_NAMES = new Set(["github", "octokit", "githubClient", "octokitClient"]); const GET_OCTOKIT_MEMBER_OBJECT_NAMES = new Set(["github", "actions"]); +const HTTP_METHOD_PREFIXES = new Set(["GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS "]); /** * Returns true when the node is a template literal that contains at least one @@ -40,7 +41,13 @@ function isStringConcatenation(node: TSESTree.Node): boolean { } function isHttpMethodPrefix(text: string | null | undefined): boolean { - return typeof text === "string" && /^[A-Z]+ $/.test(text); + return typeof text === "string" && HTTP_METHOD_PREFIXES.has(text); +} + +function getInterpolatedRouteKind(node: TSESTree.Node): string | null { + if (isInterpolatedTemplateLiteral(node)) return "template literal with interpolations"; + if (isStringConcatenation(node)) return "string concatenation expression"; + return null; } /** @@ -204,32 +211,23 @@ export const noGithubRequestInterpolatedRouteRule = createRule({ const firstArg = node.arguments[0]; if (!firstArg) return; + const routeKind = getInterpolatedRouteKind(firstArg); + if (!routeKind) return; + if (isOpaqueWholeRouteInterpolation(firstArg)) { - const kind = firstArg.type === "TemplateLiteral" ? "template literal with interpolations" : "string concatenation expression"; context.report({ node: firstArg, messageId: "opaqueWholeRoute", - data: { kind, client: clientName }, + data: { kind: routeKind, client: clientName }, }); return; } - if (isInterpolatedTemplateLiteral(firstArg)) { - context.report({ - node: firstArg, - messageId: "interpolatedRoute", - data: { kind: "template literal with interpolations", client: clientName }, - }); - return; - } - - if (isStringConcatenation(firstArg)) { - context.report({ - node: firstArg, - messageId: "interpolatedRoute", - data: { kind: "string concatenation expression", client: clientName }, - }); - } + context.report({ + node: firstArg, + messageId: "interpolatedRoute", + data: { kind: routeKind, client: clientName }, + }); }, }; }, From 9f692168fe0ba2e64cb79e1628f4f39a54f578e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:31:05 +0000 Subject: [PATCH 5/6] Polish opaque route docs and helpers Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- eslint-factory/README.md | 2 +- .../src/rules/no-github-request-interpolated-route.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/eslint-factory/README.md b/eslint-factory/README.md index a734ae0913b..0721e03cced 100644 --- a/eslint-factory/README.md +++ b/eslint-factory/README.md @@ -48,7 +48,7 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl github.request("GET /repos/{owner}/{repo}", { owner, repo }); ``` -For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `POST ${endpoint}` or `"POST " + endpoint` at the helper call site. +For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `` `POST ${endpoint}` `` or `` `"POST " + endpoint` `` at the helper call site. ### `no-json-stringify-error` diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index ddcaeccfeab..0bc6aa164c0 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -61,9 +61,10 @@ function getInterpolatedRouteKind(node: TSESTree.Node): string | null { */ function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean { if (node.type === "TemplateLiteral") { + const hasTwoQuasis = node.quasis.length === 2; const hasSingleExpression = node.expressions.length === 1; - const hasMethodPrefix = node.quasis.length === 2 && isHttpMethodPrefix(node.quasis[0].value.cooked); - const hasEmptyTrailingQuasi = node.quasis.length === 2 && node.quasis[1].value.cooked === ""; + const hasMethodPrefix = hasTwoQuasis && isHttpMethodPrefix(node.quasis[0].value.cooked); + const hasEmptyTrailingQuasi = hasTwoQuasis && node.quasis[1].value.cooked === ""; const isDynamicWholeRoute = hasSingleExpression && !isStaticRouteExpression(node.expressions[0]); return hasMethodPrefix && hasEmptyTrailingQuasi && isDynamicWholeRoute; } From a96a893c5a3e31e025c96312a37e34402b7681c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:33:01 +0000 Subject: [PATCH 6/6] Clarify opaque route helper diagnostics Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- eslint-factory/README.md | 2 +- .../rules/no-github-request-interpolated-route.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/eslint-factory/README.md b/eslint-factory/README.md index 0721e03cced..a734ae0913b 100644 --- a/eslint-factory/README.md +++ b/eslint-factory/README.md @@ -48,7 +48,7 @@ Using an interpolated route bypasses Octokit's typed route dispatch, can silentl github.request("GET /repos/{owner}/{repo}", { owner, repo }); ``` -For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `` `POST ${endpoint}` `` or `` `"POST " + endpoint` `` at the helper call site. +For helpers that receive the entire route as a parameter, there is no mechanical `{owner}` / `{repo}` rewrite. Pass a typed route string from the caller instead of interpolating `POST ${endpoint}` or `"POST " + endpoint` at the helper call site. ### `no-json-stringify-error` diff --git a/eslint-factory/src/rules/no-github-request-interpolated-route.ts b/eslint-factory/src/rules/no-github-request-interpolated-route.ts index 0bc6aa164c0..25306410a83 100644 --- a/eslint-factory/src/rules/no-github-request-interpolated-route.ts +++ b/eslint-factory/src/rules/no-github-request-interpolated-route.ts @@ -40,10 +40,19 @@ function isStringConcatenation(node: TSESTree.Node): boolean { return node.type === "BinaryExpression" && node.operator === "+" && !isStaticRouteExpression(node); } -function isHttpMethodPrefix(text: string | null | undefined): boolean { +/** + * Returns true when `text` is an exact HTTP-method route prefix including the + * required trailing space, such as `GET ` or `POST `. + */ +function isValidHttpMethodPrefix(text: string | null | undefined): boolean { return typeof text === "string" && HTTP_METHOD_PREFIXES.has(text); } +/** + * Returns the human-readable route-expression kind string used in diagnostics, + * or null when `node` is not one of the interpolated route shapes this rule + * reports on. + */ function getInterpolatedRouteKind(node: TSESTree.Node): string | null { if (isInterpolatedTemplateLiteral(node)) return "template literal with interpolations"; if (isStringConcatenation(node)) return "string concatenation expression"; @@ -63,7 +72,7 @@ function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean { if (node.type === "TemplateLiteral") { const hasTwoQuasis = node.quasis.length === 2; const hasSingleExpression = node.expressions.length === 1; - const hasMethodPrefix = hasTwoQuasis && isHttpMethodPrefix(node.quasis[0].value.cooked); + const hasMethodPrefix = hasTwoQuasis && isValidHttpMethodPrefix(node.quasis[0].value.cooked); const hasEmptyTrailingQuasi = hasTwoQuasis && node.quasis[1].value.cooked === ""; const isDynamicWholeRoute = hasSingleExpression && !isStaticRouteExpression(node.expressions[0]); return hasMethodPrefix && hasEmptyTrailingQuasi && isDynamicWholeRoute; @@ -71,7 +80,7 @@ function isOpaqueWholeRouteInterpolation(node: TSESTree.Node): boolean { if (node.type === "BinaryExpression" && node.operator === "+") { if (node.left.type !== "Literal" || typeof node.left.value !== "string") return false; - const hasMethodPrefix = isHttpMethodPrefix(node.left.value); + const hasMethodPrefix = isValidHttpMethodPrefix(node.left.value); const isRightDynamic = !isStaticRouteExpression(node.right); return hasMethodPrefix && isRightDynamic; }