Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,26 @@ describe("no-github-request-interpolated-route", () => {
valid: [
// `this.github` is not resolved — `this` is not an Identifier
"this.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 });",
// Multi-hop route aliases are intentionally out of scope.
"const baseRoute = `GET /repos/${owner}/${repo}`; const route = baseRoute; github.request(route, { owner, repo });",
`github.request("GET /repos/".concat(owner, "/", repo), { owner, repo });`,
`github.request("GET /repos" + "/{owner}/{repo}", { owner, repo });`,
],
invalid: [],
});
});

it("valid: single-hop const route alias to static routes is accepted", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
"const route = 'GET /repos/{owner}/{repo}'; github.request(route, { owner, repo });",
"const route = `GET /repos/{owner}/{repo}`; github.request(route, { owner, repo });",
"const route = 'GET /repos/' + '{owner}/{repo}'; github.request(route, { owner, repo });",
],
invalid: [],
});
});

it("invalid: template literal with interpolations is flagged for all known client names", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
Expand Down Expand Up @@ -266,6 +277,41 @@ describe("no-github-request-interpolated-route", () => {
},
],
},
{
code: "const gh = global.getOctokit(token); gh.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "gh" },
},
],
},
],
});
});

it("invalid: single-hop const route aliases with interpolation are flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
invalid: [
{
code: "const route = `GET /repos/${owner}/${repo}`; github.request(route, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: "github" },
},
],
},
{
code: "const route = 'GET /repos/' + owner + '/' + repo; github.request(route, { owner, repo });",
errors: [
{
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: "github" },
},
],
},
],
});
});
Expand Down
45 changes: 39 additions & 6 deletions eslint-factory/src/rules/no-github-request-interpolated-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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"]);
const GET_OCTOKIT_MEMBER_OBJECT_NAMES = new Set(["github", "actions", "global"]);

/**
* Returns true when the node is a template literal that contains at least one
Expand Down Expand Up @@ -53,7 +53,7 @@ function isContextGithubExpression(node: TSESTree.Node): boolean {
* 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(...)`)
* `github.getOctokit(...)`, `actions.getOctokit(...)`, `global.getOctokit(...)`)
* - `context.github` member expression
*/
function isOctokitSourceExpression(node: TSESTree.Node): boolean {
Expand Down Expand Up @@ -153,6 +153,37 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
return null;
}

/**
* Resolves a route argument for one-hop const identifier aliases:
* const route = `GET /repos/${owner}/${repo}`
* github.request(route, ...)
*/
function resolveRouteArgument(node: TSESTree.Node, scopeNode: TSESTree.Node): TSESTree.Node {
if (node.type !== AST_NODE_TYPES.Identifier) return node;

let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode);
while (scope) {
const variable = scope.set.get(node.name);
if (variable && variable.defs.length > 0) {
let resolvedInit: TSESTree.Node | null = null;
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) {
resolvedInit = declarator.init;
break;
}
}
return resolvedInit ?? node;
}
scope = scope.upper;
}

return node;
}

return {
CallExpression(node) {
const callee = node.callee;
Expand All @@ -169,18 +200,20 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
const firstArg = node.arguments[0];
if (!firstArg) return;

if (isInterpolatedTemplateLiteral(firstArg)) {
const resolvedRouteArg = resolveRouteArgument(firstArg, node);

if (isInterpolatedTemplateLiteral(resolvedRouteArg)) {
context.report({
node: firstArg,
node: resolvedRouteArg,
messageId: "interpolatedRoute",
data: { kind: "template literal with interpolations", client: clientName },
});
return;
}

if (isStringConcatenation(firstArg)) {
if (isStringConcatenation(resolvedRouteArg)) {
context.report({
node: firstArg,
node: resolvedRouteArg,
messageId: "interpolatedRoute",
data: { kind: "string concatenation expression", client: clientName },
});
Expand Down