diff --git a/actions/setup/js/check_membership.test.cjs b/actions/setup/js/check_membership.test.cjs index 8ec71b5c74..81d61457d3 100644 --- a/actions/setup/js/check_membership.test.cjs +++ b/actions/setup/js/check_membership.test.cjs @@ -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 () => { @@ -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"); + }); + }); }); diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index d78f3674a9..526dfe9f4b 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -59,8 +59,8 @@ async function checkBotStatus(actor, owner, repo) { try { // GitHub Apps can appear as either or [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}`); @@ -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) { - 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); diff --git a/actions/setup/js/check_permissions_utils.test.cjs b/actions/setup/js/check_permissions_utils.test.cjs index c745ff0572..98ca23b5b8 100644 --- a/actions/setup/js/check_permissions_utils.test.cjs +++ b/actions/setup/js/check_permissions_utils.test.cjs @@ -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"); + }); }); });