Skip to content
Merged
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
83 changes: 83 additions & 0 deletions actions/setup/js/check_membership.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe("check_membership.cjs", () => {
delete global.github;
delete global.context;
delete process.env.GH_AW_REQUIRED_ROLES;
delete process.env.GH_AW_ALLOWED_BOTS;
});

const runScript = async () => {
Expand Down Expand Up @@ -314,4 +315,86 @@ describe("check_membership.cjs", () => {
expect(mockCore.info).toHaveBeenCalledWith("✅ User has write access to repository");
});
});

describe("bots allowlist", () => {
beforeEach(() => {
process.env.GH_AW_REQUIRED_ROLES = "write";
mockContext.actor = "greptile-apps";
});

it("should authorize a bot in the allowlist when [bot] form is active on the repo", async () => {
process.env.GH_AW_ALLOWED_BOTS = "greptile-apps";

mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockResolvedValueOnce({ data: { permission: "none" } }) // initial permission check
.mockResolvedValueOnce({ data: { permission: "none" } }); // bot status check ([bot] form)

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
expect(mockCore.setOutput).toHaveBeenCalledWith("user_permission", "bot");
});

it("should authorize a bot in the allowlist when [bot] form returns 404 but slug form is active", async () => {
process.env.GH_AW_ALLOWED_BOTS = "greptile-apps";

const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockResolvedValueOnce({ data: { permission: "none" } }) // initial permission check (slug form)
.mockRejectedValueOnce(notFoundError) // bot status [bot] form → 404
.mockResolvedValueOnce({ data: { permission: "none" } }); // bot status slug fallback → none

await runScript();

expect(mockCore.info).toHaveBeenCalledWith("Actor 'greptile-apps' is in the allowed bots list");
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
expect(mockCore.setOutput).toHaveBeenCalledWith("user_permission", "bot");
});

it("should deny a bot in the allowlist when both [bot] and slug forms return 404", async () => {
process.env.GH_AW_ALLOWED_BOTS = "greptile-apps";

const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockResolvedValueOnce({ data: { permission: "none" } }) // initial permission check
.mockRejectedValue(notFoundError); // bot status checks all return 404

await runScript();

expect(mockCore.warning).toHaveBeenCalledWith("Bot 'greptile-apps' is in the allowed list but not active/installed on testorg/testrepo");
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "bot_not_active");
});

it("should deny a bot not in the allowlist", async () => {
process.env.GH_AW_ALLOWED_BOTS = "some-other-bot";

mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({
data: { permission: "none" },
});

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "insufficient_permissions");
});

it("should authorize a bot with [bot] suffix in the allowlist via slug fallback", async () => {
process.env.GH_AW_ALLOWED_BOTS = "copilot";
mockContext.actor = "copilot[bot]";

const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockResolvedValueOnce({ data: { permission: "none" } }) // initial permission check
.mockRejectedValueOnce(notFoundError) // bot status [bot] form → 404
.mockResolvedValueOnce({ data: { permission: "none" } }); // bot status slug fallback → none

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized_bot");
});
});
});
28 changes: 23 additions & 5 deletions actions/setup/js/check_permissions_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ async function checkBotStatus(actor, owner, repo) {
try {
// GitHub Apps can appear as either <slug> or <slug>[bot].
// Treat both forms as a bot identity; always query the API with the [bot] form.
const actorHasBotSuffix = actor.endsWith("[bot]");
const actorForApi = actorHasBotSuffix ? actor : `${actor}[bot]`;
const actorSlug = canonicalizeBotIdentifier(actor);
const actorForApi = actor.endsWith("[bot]") ? actor : `${actorSlug}[bot]`;

core.info(`Checking if bot '${actor}' is active on ${owner}/${repo}`);

Expand All @@ -77,11 +77,29 @@ async function checkBotStatus(actor, owner, repo) {
core.info(`Bot '${actor}' is active with permission level: ${botPermission.data.permission}`);
return { isBot: true, isActive: true };
} catch (botError) {
// If we get a 404, the bot is not installed/active on this repository
// If we get a 404, the [bot]-suffixed form may not be listed as a collaborator.
// Fall back to checking the non-[bot] (slug) form, as some GitHub Apps appear
// under their plain slug name rather than the [bot]-suffixed form.
// @ts-expect-error - Error handling with optional chaining
if (botError?.status === 404) {
Comment on lines +80 to 84
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

Slug fallback makes checkBotStatus() return { isBot: true, isActive: true } for any allowlisted human collaborator whenever <user>[bot] 404s and the plain <user> lookup succeeds. That changes GH_AW_ALLOWED_BOTS from “bots only” to a general override that can bypass GH_AW_REQUIRED_ROLES for regular users. Consider gating the slug fallback (or the isBot: true result) on an explicit bot signal (e.g., context.payload.sender.type === "Bot" when available, or a caller-provided isBotActor flag), so allowlisting a non-bot username can’t silently authorize a user with insufficient permissions.

Copilot uses AI. Check for mistakes.
core.warning(`Bot '${actor}' is not active/installed on ${owner}/${repo}`);
return { isBot: true, isActive: false };
try {
const slugPermission = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: actorSlug,
});
core.info(`Bot '${actor}' is active (via slug form) with permission level: ${slugPermission.data.permission}`);
return { isBot: true, isActive: true };
} catch (slugError) {
// @ts-expect-error - Error handling with optional chaining
if (slugError?.status === 404) {
core.warning(`Bot '${actor}' is not active/installed on ${owner}/${repo}`);
return { isBot: true, isActive: false };
}
const errorMessage = getErrorMessage(slugError);
core.warning(`Failed to check bot status: ${errorMessage}`);
return { isBot: true, isActive: false, error: errorMessage };
}
}
// For other errors, we'll treat as inactive to be safe
const errorMessage = getErrorMessage(botError);
Expand Down
71 changes: 71 additions & 0 deletions actions/setup/js/check_permissions_utils.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -458,5 +458,76 @@ describe("check_permissions_utils", () => {
expect(result.isBot).toBe(true);
expect(result.isActive).toBe(true);
});

it("should fall back to slug form when [bot] form is not found as collaborator", async () => {
const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockRejectedValueOnce(notFoundError) // [bot] form returns 404
.mockResolvedValueOnce({ data: { permission: "none" } }); // slug form returns none

const result = await checkBotStatus("greptile-apps", "testowner", "testrepo");

expect(result).toEqual({ isBot: true, isActive: true });

// Verify [bot] form was tried first
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenNthCalledWith(1, {
owner: "testowner",
repo: "testrepo",
username: "greptile-apps[bot]",
});
// Verify slug form was tried as fallback
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenNthCalledWith(2, {
owner: "testowner",
repo: "testrepo",
username: "greptile-apps",
});
expect(mockCore.info).toHaveBeenCalledWith("Bot 'greptile-apps' is active (via slug form) with permission level: none");
});

it("should fall back to slug form when actor has [bot] suffix and [bot] form is not found", async () => {
const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockRejectedValueOnce(notFoundError) // [bot] form returns 404
.mockResolvedValueOnce({ data: { permission: "none" } }); // slug form returns none

const result = await checkBotStatus("copilot[bot]", "testowner", "testrepo");

expect(result).toEqual({ isBot: true, isActive: true });

expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenNthCalledWith(1, {
owner: "testowner",
repo: "testrepo",
username: "copilot[bot]",
});
expect(mockGithub.rest.repos.getCollaboratorPermissionLevel).toHaveBeenNthCalledWith(2, {
owner: "testowner",
repo: "testrepo",
username: "copilot",
});
expect(mockCore.info).toHaveBeenCalledWith("Bot 'copilot[bot]' is active (via slug form) with permission level: none");
});

it("should return inactive when both [bot] and slug forms return 404", async () => {
const notFoundError = { status: 404, message: "Not Found" };
mockGithub.rest.repos.getCollaboratorPermissionLevel.mockRejectedValue(notFoundError);

const result = await checkBotStatus("unknown-app", "testowner", "testrepo");

expect(result).toEqual({ isBot: true, isActive: false });
expect(mockCore.warning).toHaveBeenCalledWith("Bot 'unknown-app' is not active/installed on testowner/testrepo");
});

it("should return inactive with error when slug form returns non-404 error", async () => {
const notFoundError = { status: 404, message: "Not Found" };
const rateLimit = new Error("API rate limit exceeded");
mockGithub.rest.repos.getCollaboratorPermissionLevel
.mockRejectedValueOnce(notFoundError) // [bot] form returns 404
.mockRejectedValueOnce(rateLimit); // slug form returns rate limit error

const result = await checkBotStatus("greptile-apps", "testowner", "testrepo");

expect(result).toEqual({ isBot: true, isActive: false, error: "API rate limit exceeded" });
expect(mockCore.warning).toHaveBeenCalledWith("Failed to check bot status: API rate limit exceeded");
});
});
});
Loading