From 1b3895a944e560270f7a932184341dc0051a2849 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:48:21 +0000 Subject: [PATCH 1/3] Initial plan From 47e2599b3b641de91aa4b3f807b19c75eb0e2afb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:11:28 +0000 Subject: [PATCH 2/3] fix: bots allowlist does not override pre_activation team membership check When a GitHub App (e.g., Greptile, Copilot) triggers an event and is in the bots: allowlist, checkBotStatus was querying getCollaboratorPermissionLevel with the [bot]-suffixed form (e.g., greptile-apps[bot]). For GitHub Apps that appear as plain slugs in the collaborator API, this returned 404, causing the bot to be incorrectly marked as inactive and the workflow to not activate. Fix: when the [bot]-suffixed form returns 404, fall back to the slug (non-[bot]) form. Only mark the bot as inactive if both forms return 404. Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_membership.test.cjs | 83 +++++++++++++++++++ actions/setup/js/check_permissions_utils.cjs | 25 +++++- .../setup/js/check_permissions_utils.test.cjs | 71 ++++++++++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/check_membership.test.cjs b/actions/setup/js/check_membership.test.cjs index 8ec71b5c740..81d61457d3b 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 d78f3674a98..77dff63e771 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -61,6 +61,7 @@ async function checkBotStatus(actor, owner, repo) { // 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 actorWithoutBotSuffix = actorHasBotSuffix ? actor.slice(0, -5) : actor; core.info(`Checking if bot '${actor}' is active on ${owner}/${repo}`); @@ -77,11 +78,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: actorWithoutBotSuffix, + }); + 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 c745ff0572d..98ca23b5b8f 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"); + }); }); }); From 8afeb8fcc2e624301589c9a466b9a804f8b944fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:23:32 +0000 Subject: [PATCH 3/3] refactor: reuse canonicalizeBotIdentifier helper in checkBotStatus Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/check_permissions_utils.cjs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/check_permissions_utils.cjs b/actions/setup/js/check_permissions_utils.cjs index 77dff63e771..526dfe9f4b6 100644 --- a/actions/setup/js/check_permissions_utils.cjs +++ b/actions/setup/js/check_permissions_utils.cjs @@ -59,9 +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 actorWithoutBotSuffix = actorHasBotSuffix ? actor.slice(0, -5) : actor; + const actorSlug = canonicalizeBotIdentifier(actor); + const actorForApi = actor.endsWith("[bot]") ? actor : `${actorSlug}[bot]`; core.info(`Checking if bot '${actor}' is active on ${owner}/${repo}`); @@ -87,7 +86,7 @@ async function checkBotStatus(actor, owner, repo) { const slugPermission = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, - username: actorWithoutBotSuffix, + username: actorSlug, }); core.info(`Bot '${actor}' is active (via slug form) with permission level: ${slugPermission.data.permission}`); return { isBot: true, isActive: true };