Skip to content
16 changes: 13 additions & 3 deletions eslint-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,27 @@ This project hosts custom ESLint linters for `/actions/setup/js`.

### `no-github-request-interpolated-route`

Disallow template literals with interpolations or string concatenation expressions as the route argument of Octokit `github` / `octokit` / `githubClient` / `octokitClient` `.request()` calls.
Disallow template literals with interpolations or string concatenation expressions as the route argument of Octokit `.request()` calls.

Using an interpolated route bypasses Octokit's typed route dispatch, can silently produce malformed paths when values contain special characters, and prevents static analysis of the route string.

**Detected Octokit clients:**
- Well-known names: `github`, `octokit`, `githubClient`, `octokitClient`.
- `context.github` — the GitHub context object's client property.
- Identifiers initialized by calling `getOctokit(...)` directly or via known module objects (`github.getOctokit(...)`, `actions.getOctokit(...)`). (Known module object names currently: `github`, `actions`.)
- Simple `const` aliases of any of the above:
`const gh = github`, `const client = getOctokit(token)`, `const myClient = context.github`.

**Flagged forms:**
- `` github.request(`GET /repos/${owner}/${repo}`, ...) `` — template literal with interpolations.
- `github.request("GET /repos/" + owner + "/" + repo, ...)` — string concatenation.
- `` 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.

**Out of scope:**
- `this.github.request(...)` / `context.github.request(...)` — only direct identifier clients are matched today.
- `github.request(route, ...)` — variable indirection is not resolved.
- `this.github.request(...)` — `this`-based member expressions are not resolved.
- `github.request(route, ...)` — variable indirection for the route argument is not resolved.
- `github.request("GET /repos/".concat(owner), ...)` — `.concat()`-built routes are not inspected.
- `github.request("GET /repos" + "/{owner}/{repo}", ...)` — compile-time constant concatenations are accepted.

Expand Down
142 changes: 140 additions & 2 deletions eslint-factory/src/rules/no-github-request-interpolated-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ describe("no-github-request-interpolated-route", () => {
it("valid: request() on an unknown client name is not flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
// 'client' is not in the allow-list
// 'client' is not in the allow-list and not bound to an Octokit source
"client.request(`GET /repos/${owner}/${repo}`);",
// Dynamic computed access on known client name is not flagged
"github['request'](`GET /repos/${owner}/${repo}`);",
// Node.js http/https request — must not be flagged (false-positive guard)
"http.request(`http://example.com/${path}`, callback);",
"https.request(`https://example.com/${path}`, options, callback);",
// Client bound to an unrelated value — must not be flagged
"const client = http.createServer(); client.request(`GET /repos/${owner}/${repo}`);",
],
invalid: [],
});
Expand All @@ -65,8 +70,9 @@ describe("no-github-request-interpolated-route", () => {
it("valid: intentionally out-of-scope route forms are accepted", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
// `this.github` is not resolved — `this` is not an Identifier
"this.github.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
"context.github.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
// Variable indirection for the route argument is not resolved
"const route = `GET /repos/${owner}/${repo}`; github.request(route, { owner, repo });",
`github.request("GET /repos/".concat(owner, "/", repo), { owner, repo });`,
`github.request("GET /repos" + "/{owner}/{repo}", { owner, repo });`,
Expand Down Expand Up @@ -162,4 +168,136 @@ describe("no-github-request-interpolated-route", () => {
],
});
});

it("valid: simple aliases bound to non-Octokit sources are not flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
// Alias of an unknown source — must not be flagged
"const gh = someUnknownClient; gh.request(`GET /repos/${owner}/${repo}`);",
// Alias of http module — must not be flagged
"const client = http; client.request(`GET /repos/${owner}/${repo}`);",
// Unknown .getOctokit() owner must not be treated as toolkit Octokit source
"const helperClient = myHelper.getOctokit(token); helperClient.request(`GET /repos/${owner}/${repo}`);",
],
invalid: [],
});
});

it("invalid: context.github.request() with interpolated route is flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "context.github.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "context.github" },
},
],
},
{
code: `context.github.request("GET /repos/" + owner + "/" + repo, { owner, repo });`,
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: "context.github" },
},
],
},
],
});
});

it("invalid: simple const alias of a known Octokit client is flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "const gh = github; gh.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "gh" },
},
],
},
{
code: `const myClient = octokit; myClient.request("GET /repos/" + owner + "/" + repo, { });`,
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: "myClient" },
},
],
},
],
});
});

it("invalid: const alias of getOctokit() result is flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "const fallbackClient = getOctokit(token); fallbackClient.request(`POST /repos/${owner}/${repo}/issues/${number}/comments`, { body });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "fallbackClient" },
},
],
},
{
code: `const client = getOctokit(token); client.request("POST /repos/" + owner + "/" + repo, { });`,
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: "client" },
},
],
},
{
code: "const client = actions.getOctokit(token); client.request(`POST /repos/${owner}/${repo}/issues`, { });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "client" },
},
],
},
],
});
});

it("valid: mutable aliases are out of scope and not flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
// Mutable let bindings are not trusted aliases.
"let gh = github; gh.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
// var aliases are always out of scope, even without reassignment.
"var legacyClient = github; legacyClient.request(`GET /repos/${owner}/${repo}`);",
// var alias can be reassigned, so it is intentionally out of scope.
"var client = getOctokit(token); client = http; client.request(`GET /repos/${owner}/${repo}`);",
],
invalid: [],
});
});

it("invalid: const alias of context.github is flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "const gh = context.github; gh.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "gh" },
},
],
},
],
});
});
});
112 changes: 103 additions & 9 deletions eslint-factory/src/rules/no-github-request-interpolated-route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);

const OCTOKIT_CLIENT_NAMES = new Set(["github", "octokit", "githubClient", "octokitClient"]);
const GET_OCTOKIT_MEMBER_OBJECT_NAMES = new Set(["github", "actions"]);

/**
* Returns true when the node is a template literal that contains at least one
Expand Down Expand Up @@ -38,14 +39,55 @@ function isStringConcatenation(node: TSESTree.Node): boolean {
return node.type === "BinaryExpression" && node.operator === "+" && !isStaticRouteExpression(node);
}

/**
* Returns true when `node` is the `context.github` member expression.
*/
function isContextGithubExpression(node: TSESTree.Node): boolean {
return (
node.type === AST_NODE_TYPES.MemberExpression && !node.computed && node.object.type === AST_NODE_TYPES.Identifier && node.object.name === "context" && node.property.type === AST_NODE_TYPES.Identifier && node.property.name === "github"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] Line 46 is very long (170+ characters). The existing helpers in this file each fit comfortably on one readable line. Breaking this into multiple lines would match the surrounding style and improve readability in code review and diffs.

💡 Suggested formatting
function isContextGithubExpression(node: TSESTree.Node): boolean {
  return (
    node.type === AST_NODE_TYPES.MemberExpression &&
    !node.computed &&
    node.object.type === AST_NODE_TYPES.Identifier &&
    node.object.name === 'context' &&
    node.property.type === AST_NODE_TYPES.Identifier &&
    node.property.name === 'github'
  );
}

@copilot please address this.

);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isOctokitSourceExpression function handles module-member getOctokit calls (e.g. actions.getOctokit(token)) via the MemberExpression callee branch here, but there is no test exercising that path. Consider adding an invalid test case such as:

const client = actions.getOctokit(token);
client.request(`GET /repos/${owner}/${repo}`);

to confirm the branch works as intended.

@copilot please address this.


/**
* Returns true when a syntactic expression node directly represents a known
* Octokit client source without scope resolution. Recognizes:
* - Direct known names: github, octokit, githubClient, octokitClient
* - `getOctokit(...)` call results (bare or via known module objects, e.g.
* `github.getOctokit(...)` or `actions.getOctokit(...)`)
* - `context.github` member expression
*/
function isOctokitSourceExpression(node: TSESTree.Node): boolean {
if (node.type === AST_NODE_TYPES.Identifier && OCTOKIT_CLIENT_NAMES.has(node.name)) return true;

if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee;
if (callee.type === AST_NODE_TYPES.Identifier && callee.name === "getOctokit") return true;
if (
callee.type === AST_NODE_TYPES.MemberExpression &&
!callee.computed &&
callee.object.type === AST_NODE_TYPES.Identifier &&
GET_OCTOKIT_MEMBER_OBJECT_NAMES.has(callee.object.name) &&
callee.property.type === AST_NODE_TYPES.Identifier &&
callee.property.name === "getOctokit"
) {
return true;
}
}

if (isContextGithubExpression(node)) return true;

return false;
}

export const noGithubRequestInterpolatedRouteRule = createRule({
name: "no-github-request-interpolated-route",
meta: {
type: "problem",
docs: {
description:
"Disallow template literals with interpolations or string concatenation as the route argument of Octokit " +
"github/octokit/githubClient/octokitClient .request() calls. " +
"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.',
Comment on lines 87 to 91
},
schema: [],
Expand All @@ -58,23 +100,75 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
},
defaultOptions: [],
create(context) {
const sourceCode = context.sourceCode;
type SourceCodeScope = ReturnType<typeof sourceCode.getScope>;

/**
* Resolves an identifier name to check if it is bound to a known Octokit
* client source in the visible scope chain. Handles simple single-level
* assignments:
* const x = github
* const x = getOctokit(token)
* const x = context.github
*/
function isIdentifierBoundToOctokitClient(name: string, scopeNode: TSESTree.Node): boolean {
if (OCTOKIT_CLIENT_NAMES.has(name)) return true;

let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourceCode.getScope() is called with the CallExpression node, not the Identifier — scope resolution may be imprecise in some ESLint versions.

💡 Details

sourceCode.getScope(callNode) returns the scope at the call site (the CallExpression). The identifier being resolved (calleeObject.name) is a reference at or inside the call expression, so this will usually work, but ESLint's getScope semantics are node-type sensitive — for some node types the returned scope is the parent scope, not the node's own scope.

The safer and more precise call is to pass the Identifier node itself:

function isIdentifierBoundToOctokitClient(
  name: string,
  scopeNode: TSESTree.Node,  // pass the Identifier node, not the CallExpression
): boolean {

And in the caller:

// In resolveOctokitClientName:
return isIdentifierBoundToOctokitClient(calleeObject.name, calleeObject) ? calleeObject.name : null;
//                                                                          ^^^^^^^^^^^^^^^^^^^
//                                                                          Identifier node, not callNode

This is a low-risk but subtle correctness gap that could surface in non-trivial scope nesting.

while (scope) {
const variable = scope.set.get(name);
if (variable && variable.defs.length > 0) {
for (const def of variable.defs) {
if (def.type !== "Variable") continue;
const declarator = def.node as TSESTree.VariableDeclarator;
const declaration = declarator.parent;
if (!declaration || declaration.type !== AST_NODE_TYPES.VariableDeclaration || declaration.kind !== "const") continue;
if (declarator.init && isOctokitSourceExpression(declarator.init)) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

** reassignment makes the Octokit check stale**: only the declaration initializer is inspected, not subsequent write-references, so a reassigned binding produces a false positive (or false negative in the reverse direction).

💡 Details and suggested fix

The current logic only checks variable.defs (VariableDeclarator inits):

let client = getOctokit(token);
client = httpModule;                   // ← reassigned to non-Octokit
client.request(`GET /repos/${o}/${r}`); // ← still flagged (false positive)

And the inverse:

let client = http;
client = getOctokit(token);            // ← write reference, not a def
client.request(`GET /repos/${o}/${r}`); // ← NOT flagged (false negative)

To handle mutable bindings correctly, also check write-references via variable.references.filter(r => r.isWrite()). Since full dataflow is expensive in ESLint, the safer option is to restrict detection to const-declared bindings only (check def.node.parent.kind === 'const'), which eliminates mutable aliasing entirely.

if (declarator.init && isOctokitSourceExpression(declarator.init)) {
  // Only trust const bindings — let/var can be reassigned
  const declKind = (def.node as TSESTree.VariableDeclarator).parent?.kind;
  if (declKind === 'const') return true;
}

This keeps the rule's stated contract ("simple const/let aliases") while avoiding the false-positive/negative on reassigned let bindings.

}
return false;
}
scope = scope.upper;
}
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] When the variable is found in scope but has no Variable defs (e.g. a function parameter or a destructured import), the loop hits return false at line 115, which is correct. However, if a variable has multiple defs (e.g. a var that is declared then reassigned), only the first Variable def that passes isOctokitSourceExpression wins — later reassignments to a non-Octokit value are silently ignored, potentially causing false positives.

💡 Suggested test to surface the issue
it('valid: var re-assigned to non-Octokit value is not flagged', () => {
  cjsRuleTester.run('...', rule, {
    valid: [
      // gh was initially octokit but is then overwritten
      'var gh = github; gh = someOtherThing; gh.request(`GET /repos/${owner}/${repo}`);',
    ],
    invalid: [],
  });
});

The PR description scopes this to const/let single-level assignments, so adding a guard if (def.parent?.kind !== 'const' && def.parent?.kind !== 'let') continue; would close this gap.

@copilot please address this.

}

/**
* Returns the display name (for error messages) if the callee object
* resolves to a known Octokit client, or null otherwise.
*
* Recognized shapes:
* - `<name>.request(...)` where name is in OCTOKIT_CLIENT_NAMES or is a
* simple alias of an Octokit source (scope-resolved)
* - `context.github.request(...)`
*/
function resolveOctokitClientName(calleeObject: TSESTree.Expression | TSESTree.Super, callNode: TSESTree.Node): string | null {
if (calleeObject.type === AST_NODE_TYPES.Identifier) {
return isIdentifierBoundToOctokitClient(calleeObject.name, callNode) ? calleeObject.name : null;
}

if (isContextGithubExpression(calleeObject)) {
return "context.github";
}

return null;
}

return {
CallExpression(node) {
const callee = node.callee;

// Only match <client>.request(...)
if (callee.type !== "MemberExpression") return;
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
if (callee.computed) return;
if (callee.object.type !== "Identifier") return;
if (!OCTOKIT_CLIENT_NAMES.has(callee.object.name)) return;
if (callee.property.type !== "Identifier") return;
if (callee.property.type !== AST_NODE_TYPES.Identifier) return;
if (callee.property.name !== "request") return;

const clientName = resolveOctokitClientName(callee.object, node);
if (!clientName) return;

const firstArg = node.arguments[0];
if (!firstArg) return;

const clientName = callee.object.name;

if (isInterpolatedTemplateLiteral(firstArg)) {
context.report({
node: firstArg,
Expand Down
Loading