diff --git a/actions/setup/js/assign_issue.cjs b/actions/setup/js/assign_issue.cjs deleted file mode 100644 index 5bbf202b1f0..00000000000 --- a/actions/setup/js/assign_issue.cjs +++ /dev/null @@ -1,97 +0,0 @@ -// @ts-check -/// - -const { getAgentName, getIssueDetails, findAgent, assignAgentToIssue } = require("./assign_agent_helpers.cjs"); -const { getErrorMessage } = require("./error_helpers.cjs"); -const { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PERMISSION } = require("./error_codes.cjs"); - -/** - * Assign an issue to a user or bot (including copilot) - * This script handles assigning issues after they are created - */ - -async function main() { - // Validate required environment variables - const ghToken = process.env.GH_TOKEN; - const assignee = process.env.ASSIGNEE; - const issueNumber = process.env.ISSUE_NUMBER; - - // Check if GH_TOKEN is present - if (!ghToken?.trim()) { - const docsUrl = "https://github.github.com/gh-aw/reference/safe-outputs/#assigning-issues-to-copilot"; - core.setFailed(`${ERR_CONFIG}: GH_TOKEN environment variable is required but not set. This token is needed to assign issues. For more information on configuring Copilot tokens, see: ${docsUrl}`); - return; - } - - // Validate assignee - if (!assignee?.trim()) { - core.setFailed(`${ERR_CONFIG}: ASSIGNEE environment variable is required but not set`); - return; - } - - // Validate issue number - if (!issueNumber?.trim()) { - core.setFailed(`${ERR_CONFIG}: ISSUE_NUMBER environment variable is required but not set`); - return; - } - - const trimmedAssignee = assignee.trim(); - const issueNum = parseInt(issueNumber.trim(), 10); - - core.info(`Assigning issue #${issueNum} to ${trimmedAssignee}`); - - // Check if the assignee is a known coding agent (e.g., copilot, @copilot) - const agentName = getAgentName(trimmedAssignee); - - try { - if (agentName) { - // Use GraphQL API for agent assignment - // The token is set at the step level via github-token parameter - core.info(`Detected coding agent: ${agentName}. Using GraphQL API for assignment.`); - - // Get repository owner and repo from context - const { owner, repo } = context.repo; - - // Find the agent in the repository - const agentId = await findAgent(owner, repo, agentName); - if (!agentId) { - throw new Error(`${ERR_PERMISSION}: ${agentName} coding agent is not available for this repository`); - } - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - - // Get issue details - const issueDetails = await getIssueDetails(owner, repo, issueNum); - if (!issueDetails) { - throw new Error(`${ERR_API}: Failed to get issue details`); - } - - // Check if agent is already assigned - if (issueDetails.currentAssignees.some(a => a.id === agentId)) { - core.info(`${agentName} is already assigned to issue #${issueNum}`); - } else { - // Assign agent using GraphQL mutation - uses built-in github object authenticated via github-token (no allowed list filtering) - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, null); - - if (!success) { - throw new Error(`${ERR_API}: Failed to assign ${agentName} via GraphQL`); - } - } - } else { - // Use gh CLI for regular user assignment - await exec.exec("gh", ["issue", "edit", String(issueNum), "--add-assignee", trimmedAssignee], { - env: { ...process.env, GH_TOKEN: ghToken }, - }); - } - - core.info(`✅ Successfully assigned issue #${issueNum} to ${trimmedAssignee}`); - - // Write summary - await core.summary.addRaw(`## Issue Assignment\n\nSuccessfully assigned issue #${issueNum} to \`${trimmedAssignee}\`.\n`).write(); - } catch (error) { - const errorMessage = getErrorMessage(error); - core.error(`Failed to assign issue: ${errorMessage}`); - core.setFailed(`${ERR_NOT_FOUND}: Failed to assign issue #${issueNum} to ${trimmedAssignee}: ${errorMessage}`); - } -} - -module.exports = { main }; diff --git a/actions/setup/js/assign_issue.test.cjs b/actions/setup/js/assign_issue.test.cjs deleted file mode 100644 index 44fd507e432..00000000000 --- a/actions/setup/js/assign_issue.test.cjs +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import fs from "fs"; -import path from "path"; -const { ERR_CONFIG, ERR_NOT_FOUND } = require("./error_codes.cjs"); -const mockCore = { - debug: vi.fn(), - info: vi.fn(), - notice: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), - exportVariable: vi.fn(), - setSecret: vi.fn(), - getInput: vi.fn(), - getBooleanInput: vi.fn(), - getMultilineInput: vi.fn(), - getState: vi.fn(), - saveState: vi.fn(), - startGroup: vi.fn(), - endGroup: vi.fn(), - group: vi.fn(), - addPath: vi.fn(), - setCommandEcho: vi.fn(), - isDebug: vi.fn().mockReturnValue(!1), - getIDToken: vi.fn(), - toPlatformPath: vi.fn(), - toPosixPath: vi.fn(), - toWin32Path: vi.fn(), - summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() }, - }, - mockExec = { exec: vi.fn() }, - mockGithub = { graphql: vi.fn() }, - mockContext = { repo: { owner: "testowner", repo: "testrepo" } }; -((global.core = mockCore), - (global.exec = mockExec), - (global.github = mockGithub), - (global.context = mockContext), - describe("assign_issue.cjs", () => { - let assignIssueScript; - (beforeEach(() => { - (vi.clearAllMocks(), delete process.env.GH_TOKEN, delete process.env.ASSIGNEE, delete process.env.ISSUE_NUMBER); - const scriptPath = path.join(process.cwd(), "assign_issue.cjs"); - assignIssueScript = fs.readFileSync(scriptPath, "utf8"); - }), - describe("Environment variable validation", () => { - (it("should fail when GH_TOKEN is not set", async () => { - ((process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "123"), - delete process.env.GH_TOKEN, - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_TOKEN environment variable is required but not set")), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("https://github.github.com/gh-aw/reference/safe-outputs/#assigning-issues-to-copilot")), - expect(mockExec.exec).not.toHaveBeenCalled()); - }), - it("should fail when GH_TOKEN is empty string", async () => { - ((process.env.GH_TOKEN = " "), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "123"), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("GH_TOKEN environment variable is required but not set")), - expect(mockExec.exec).not.toHaveBeenCalled()); - }), - it("should fail when ASSIGNEE is not set", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ISSUE_NUMBER = "123"), - delete process.env.ASSIGNEE, - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: ASSIGNEE environment variable is required but not set`), - expect(mockExec.exec).not.toHaveBeenCalled()); - }), - it("should fail when ASSIGNEE is empty string", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = " "), - (process.env.ISSUE_NUMBER = "123"), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: ASSIGNEE environment variable is required but not set`), - expect(mockExec.exec).not.toHaveBeenCalled()); - }), - it("should fail when ISSUE_NUMBER is not set", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - delete process.env.ISSUE_NUMBER, - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: ISSUE_NUMBER environment variable is required but not set`), - expect(mockExec.exec).not.toHaveBeenCalled()); - }), - it("should fail when ISSUE_NUMBER is empty string", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = " "), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_CONFIG}: ISSUE_NUMBER environment variable is required but not set`), - expect(mockExec.exec).not.toHaveBeenCalled()); - })); - }), - describe("Successful assignment for regular users", () => { - (it("should successfully assign issue to a regular user", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "456"), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.info).toHaveBeenCalledWith("Assigning issue #456 to test-user"), - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["issue", "edit", "456", "--add-assignee", "test-user"], expect.objectContaining({ env: expect.objectContaining({ GH_TOKEN: "ghp_test123" }) })), - expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully assigned issue #456 to test-user"), - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Successfully assigned issue #456")), - expect(mockCore.summary.write).toHaveBeenCalled(), - expect(mockCore.setFailed).not.toHaveBeenCalled()); - }), - it("should trim whitespace from environment variables", async () => { - ((process.env.GH_TOKEN = " ghp_test123 "), - (process.env.ASSIGNEE = " test-user "), - (process.env.ISSUE_NUMBER = " 123 "), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.info).toHaveBeenCalledWith("Assigning issue #123 to test-user"), - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["issue", "edit", "123", "--add-assignee", "test-user"], expect.any(Object)), - expect(mockCore.setFailed).not.toHaveBeenCalled()); - }), - it("should include summary in output", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "123"), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("## Issue Assignment")), - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Successfully assigned issue #123 to `test-user`")), - expect(mockCore.summary.write).toHaveBeenCalled()); - })); - }), - describe("Error handling for regular users", () => { - (it("should handle gh CLI execution errors", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), (process.env.ASSIGNEE = "test-user"), (process.env.ISSUE_NUMBER = "999")); - const testError = new Error("User not found"); - (mockExec.exec.mockRejectedValue(testError), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.error).toHaveBeenCalledWith("Failed to assign issue: User not found"), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Failed to assign issue #999 to test-user: User not found`)); - }), - it("should handle non-Error objects in catch block", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), (process.env.ASSIGNEE = "test-user"), (process.env.ISSUE_NUMBER = "999")); - const stringError = "Command failed"; - (mockExec.exec.mockRejectedValue(stringError), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockCore.error).toHaveBeenCalledWith("Failed to assign issue: Command failed"), - expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_NOT_FOUND}: Failed to assign issue #999 to test-user: Command failed`)); - }), - it("should handle top-level errors with catch handler", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), (process.env.ASSIGNEE = "test-user"), (process.env.ISSUE_NUMBER = "123")); - const uncaughtError = new Error("Uncaught error"); - (mockExec.exec.mockRejectedValue(uncaughtError), await eval(`(async () => { ${assignIssueScript}; await main(); })()`), expect(mockCore.setFailed).toHaveBeenCalled()); - })); - }), - describe("Edge cases for regular users", () => { - (it("should handle numeric issue number", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "123"), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["issue", "edit", "123", "--add-assignee", "test-user"], expect.any(Object))); - }), - it("should pass through GH_TOKEN in exec environment", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "test-user"), - (process.env.ISSUE_NUMBER = "123"), - (process.env.OTHER_VAR = "other_value"), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["issue", "edit", "123", "--add-assignee", "test-user"], { env: expect.objectContaining({ GH_TOKEN: "ghp_test123", OTHER_VAR: "other_value" }) })); - }), - it("should handle special characters in assignee name", async () => { - ((process.env.GH_TOKEN = "ghp_test123"), - (process.env.ASSIGNEE = "user-with-dash"), - (process.env.ISSUE_NUMBER = "123"), - mockExec.exec.mockResolvedValue(0), - await eval(`(async () => { ${assignIssueScript}; await main(); })()`), - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["issue", "edit", "123", "--add-assignee", "user-with-dash"], expect.any(Object)), - expect(mockCore.setFailed).not.toHaveBeenCalled()); - }), - it("should include documentation link in error message", async () => { - (delete process.env.GH_TOKEN, (process.env.ASSIGNEE = "test-user"), (process.env.ISSUE_NUMBER = "123"), await eval(`(async () => { ${assignIssueScript}; await main(); })()`)); - const failedCall = mockCore.setFailed.mock.calls[0][0]; - expect(failedCall).toContain("https://github.github.com/gh-aw/reference/safe-outputs/#assigning-issues-to-copilot"); - })); - })); - })); diff --git a/actions/setup/js/checkout_pr_branch.cjs b/actions/setup/js/checkout_pr_branch.cjs index 2396ac946be..2ffddab8411 100644 --- a/actions/setup/js/checkout_pr_branch.cjs +++ b/actions/setup/js/checkout_pr_branch.cjs @@ -26,6 +26,7 @@ */ const { getErrorMessage } = require("./error_helpers.cjs"); +const { getGhEnvBypassingIntegrityFilteringForGitOps } = require("./git_helpers.cjs"); const { renderTemplateFromFile } = require("./messages_core.cjs"); const { detectForkPR } = require("./pr_helpers.cjs"); const { ERR_API } = require("./error_codes.cjs"); @@ -176,7 +177,13 @@ async function main() { } core.info(`Checking out PR #${prNumber} using gh CLI`); - await exec.exec("gh", ["pr", "checkout", prNumber.toString()]); + + // Override GH_HOST with the real GitHub hostname so gh pr checkout can resolve + // the repository from git remotes. The DIFC proxy may have set GH_HOST to + // localhost:18443 which doesn't match any remote. + await exec.exec("gh", ["pr", "checkout", prNumber.toString()], { + env: getGhEnvBypassingIntegrityFilteringForGitOps(), + }); // Log the resulting branch after checkout let currentBranch = ""; diff --git a/actions/setup/js/checkout_pr_branch.test.cjs b/actions/setup/js/checkout_pr_branch.test.cjs index b8056c6df6d..54b6a8119c4 100644 --- a/actions/setup/js/checkout_pr_branch.test.cjs +++ b/actions/setup/js/checkout_pr_branch.test.cjs @@ -67,6 +67,7 @@ describe("checkout_pr_branch.cjs", () => { global.exec = mockExec; global.context = mockContext; process.env.GITHUB_TOKEN = "test-token"; + process.env.GITHUB_SERVER_URL = "https://github.com"; }); afterEach(() => { @@ -75,6 +76,7 @@ describe("checkout_pr_branch.cjs", () => { delete global.context; delete global.github; delete process.env.GITHUB_TOKEN; + delete process.env.GITHUB_SERVER_URL; vi.clearAllMocks(); }); @@ -151,6 +153,9 @@ If the pull request is still open, verify that: if (module === "./error_codes.cjs") { return require("./error_codes.cjs"); } + if (module === "./git_helpers.cjs") { + return require("./git_helpers.cjs"); + } throw new Error(`Module ${module} not mocked in test`); }; @@ -241,8 +246,8 @@ If the pull request is still open, verify that: expect(mockCore.info).toHaveBeenCalledWith("Strategy: gh pr checkout"); expect(mockCore.info).toHaveBeenCalledWith("Reason: pull_request event from fork repository; head branch exists only in fork, not in origin"); - // Verify gh pr checkout is used instead of git fetch - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // Verify gh pr checkout is used instead of git fetch, with GH_HOST override + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); expect(mockExec.exec).not.toHaveBeenCalledWith("git", ["fetch", "origin", "feature-branch", "--depth=2"]); expect(mockCore.setFailed).not.toHaveBeenCalled(); @@ -301,8 +306,8 @@ If the pull request is still open, verify that: expect(mockCore.info).toHaveBeenCalledWith("Checking out PR #123 using gh CLI"); - // Updated expectation: no env options passed, GH_TOKEN comes from step environment - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // GH_HOST is overridden with value derived from GITHUB_SERVER_URL to avoid proxy/stale values + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully checked out PR #123"); expect(mockCore.setFailed).not.toHaveBeenCalled(); @@ -324,16 +329,14 @@ If the pull request is still open, verify that: expect(mockCore.setFailed).toHaveBeenCalledWith(`${ERR_API}: Failed to checkout PR branch: gh pr checkout failed`); }); - it("should pass environment variables to gh command", async () => { - // This test is no longer relevant since we don't pass env options explicitly - // The GH_TOKEN is now set at the step level, not in the exec options - // Keeping the test but updating to verify the call without env options + it("should pass GH_HOST derived from GITHUB_SERVER_URL to gh command", async () => { + // GH_HOST is always derived from GITHUB_SERVER_URL to avoid stale/proxy values process.env.CUSTOM_VAR = "custom-value"; await runScript(); - // Verify exec is called without env options - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // Verify exec is called with GH_HOST derived from GITHUB_SERVER_URL + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); delete process.env.CUSTOM_VAR; }); @@ -378,9 +381,8 @@ If the pull request is still open, verify that: await runScript(); expect(mockCore.info).toHaveBeenCalledWith("Event: pull_request_target"); - // pull_request_target uses gh pr checkout, not git - // Updated expectation: no third argument (env options removed) - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // pull_request_target uses gh pr checkout with GH_HOST override + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); }); it("should handle pull_request_review event", async () => { @@ -389,9 +391,8 @@ If the pull request is still open, verify that: await runScript(); expect(mockCore.info).toHaveBeenCalledWith("Event: pull_request_review"); - // pull_request_review uses gh pr checkout, not git - // Updated expectation: no third argument (env options removed) - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // pull_request_review uses gh pr checkout with GH_HOST override + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); }); it("should handle pull_request_review_comment event", async () => { @@ -399,8 +400,8 @@ If the pull request is still open, verify that: await runScript(); - // Updated expectation: no third argument (env options removed) - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + // pull_request_review_comment uses gh pr checkout with GH_HOST override + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); }); }); @@ -500,7 +501,7 @@ If the pull request is still open, verify that: // Verify fork detection logging with reason expect(mockCore.info).toHaveBeenCalledWith("Is fork PR: true (different repository names)"); expect(mockCore.warning).toHaveBeenCalledWith("⚠️ Fork PR detected - gh pr checkout will fetch from fork repository"); - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); }); it("should NOT detect fork when repo has fork flag but same full_name", async () => { @@ -516,7 +517,7 @@ If the pull request is still open, verify that: expect(mockCore.info).toHaveBeenCalledWith("Is fork PR: false (same repository)"); expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("Fork PR detected")); // Still uses gh pr checkout because pull_request_target always does - expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"]); + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); }); it("should detect non-fork PRs in pull_request_target events", async () => { @@ -887,4 +888,56 @@ If the pull request is still open, verify that: expect(mockCore.setFailed).not.toHaveBeenCalled(); }); }); + + describe("GH_HOST override for gh pr checkout", () => { + it("should override DIFC proxy GH_HOST (localhost:18443) with actual GitHub host", async () => { + const previousGhHost = process.env.GH_HOST; + + try { + // Simulate active DIFC proxy that set GH_HOST=localhost:18443 in env + process.env.GH_HOST = "localhost:18443"; + process.env.GITHUB_SERVER_URL = "https://github.com"; + mockContext.eventName = "issue_comment"; + + await runScript(); + + // GH_HOST should be overridden to github.com, not localhost:18443 + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); + } finally { + if (previousGhHost === undefined) { + delete process.env.GH_HOST; + } else { + process.env.GH_HOST = previousGhHost; + } + } + }); + + it("should use GHE host from GITHUB_SERVER_URL for gh pr checkout", async () => { + process.env.GITHUB_SERVER_URL = "https://myorg.ghe.com"; + mockContext.eventName = "pull_request_target"; + + await runScript(); + + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "myorg.ghe.com" }) })); + }); + + it("should strip https:// protocol from GITHUB_SERVER_URL when deriving GH_HOST", async () => { + process.env.GITHUB_SERVER_URL = "https://github.com"; + mockContext.eventName = "pull_request_target"; + + await runScript(); + + // Should not include the protocol in GH_HOST + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); + }); + + it("should default to github.com when GITHUB_SERVER_URL is not set", async () => { + delete process.env.GITHUB_SERVER_URL; + mockContext.eventName = "issue_comment"; + + await runScript(); + + expect(mockExec.exec).toHaveBeenCalledWith("gh", ["pr", "checkout", "123"], expect.objectContaining({ env: expect.objectContaining({ GH_HOST: "github.com" }) })); + }); + }); }); diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index 70becf63bb2..29feca1924b 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -114,7 +114,39 @@ function execGitSync(args, options = {}) { return result.stdout; } +/** + * Derive the real GitHub hostname from GITHUB_SERVER_URL. + * + * When the DIFC proxy is active, GH_HOST is overridden to localhost:18443 + * in GITHUB_ENV. This causes `gh` CLI commands that resolve the repository + * from git remotes (e.g. `gh pr checkout`) to fail because the proxy address + * doesn't match any remote. Use this helper to get the actual GitHub host + * (e.g. "github.com" or "myorg.ghe.com") for per-call GH_HOST overrides. + * + * @returns {string} The GitHub hostname (e.g. "github.com") + */ +function getGitHubHost() { + const serverUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; + return serverUrl.replace(/^https?:\/\/|\/+$/g, ""); +} + +/** + * Build environment variables for a `gh` CLI exec call with the correct GH_HOST. + * + * Spreads process.env and any additional environment variables, then enforces + * GH_HOST to the real GitHub hostname derived from GITHUB_SERVER_URL. Use this + * for any `gh` CLI call that needs to bypass a DIFC proxy GH_HOST override. + * + * @param {Object} [extraEnv] - Additional environment variables to set (e.g. { GH_TOKEN: token }) + * @returns {Object} Environment object suitable for exec.exec options + */ +function getGhEnvBypassingIntegrityFilteringForGitOps(extraEnv) { + return { ...process.env, ...extraEnv, GH_HOST: getGitHubHost() }; +} + module.exports = { execGitSync, + getGhEnvBypassingIntegrityFilteringForGitOps, getGitAuthEnv, + getGitHubHost, };