From 3f0f0c154c73a0030d7987eb965ef6c63a29defd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:52:00 +0000 Subject: [PATCH 1/8] Plan assign-to-agent simplification Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/mcp.json | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/mcp.json b/.github/mcp.json index 341a8b87588..3d0d72b2af5 100644 --- a/.github/mcp.json +++ b/.github/mcp.json @@ -3,18 +3,8 @@ "github-agentic-workflows": { "type": "local", "command": "gh", - "args": [ - "aw", - "mcp-server" - ], - "tools": [ - "compile", - "audit", - "logs", - "inspect", - "status", - "audit-diff" - ] + "args": ["aw", "mcp-server"], + "tools": ["compile", "audit", "logs", "inspect", "status", "audit-diff"] } } -} \ No newline at end of file +} From 30f59fffca54c4a828906c518d63c04c9ed3862c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:58:57 +0000 Subject: [PATCH 2/8] Simplify assign-to-agent REST assignment flow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_agent_helpers.cjs | 197 ++++-------------- .../setup/js/assign_agent_helpers.test.cjs | 197 +++++++----------- actions/setup/js/assign_to_agent.cjs | 19 +- actions/setup/js/assign_to_agent.test.cjs | 86 ++++---- 4 files changed, 157 insertions(+), 342 deletions(-) diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index b69f0c3d2cd..a43b1d77a0e 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -15,7 +15,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @type {Record} */ const AGENT_LOGIN_NAMES = { - copilot: ["copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], + copilot: ["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], }; /** @@ -111,45 +111,20 @@ async function getAvailableAgentLogins(owner, repo, issueNumber = null, githubCl async function validateAssigneeAlias(owner, repo, assignee, issueNumber, githubClient) { const parsedIssueNumber = Number(issueNumber); const hasValidIssueNumber = Number.isInteger(parsedIssueNumber) && parsedIssueNumber > 0; - const hasIssueScopedRequest = typeof githubClient?.request === "function"; - - if (issueNumber && hasValidIssueNumber && hasIssueScopedRequest) { - core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); - try { - const issueScopedResponse = await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { - owner, - repo, - issue_number: parsedIssueNumber, - assignee, - }); - const issueScopedStatus = issueScopedResponse && typeof issueScopedResponse === "object" && "status" in issueScopedResponse ? Number(issueScopedResponse.status) : undefined; - if (issueScopedStatus !== undefined && Number.isInteger(issueScopedStatus) && issueScopedStatus >= 200 && issueScopedStatus < 300) { - core.info(`Assignee alias ${assignee} is assignable via issue-scoped check`); - return; - } - core.info(`Issue-scoped assignee check returned unexpected response for ${assignee} (status ${issueScopedStatus ?? "unknown"}); falling back to repository-scoped check`); - } catch (e) { - const status = e && typeof e === "object" && "status" in e ? e.status : undefined; - // Some coding-agent bot aliases can return 404 on issue-scoped checks even when - // assignment may still succeed; use repository-scoped endpoint as fallback. - if (status !== 404 && status !== 422) { - core.info(`Issue-scoped assignee check failed for ${assignee} with status ${status ?? "unknown"}: ${getErrorMessage(e)}`); - throw e; - } - core.info(`Issue-scoped assignee check returned ${status} for ${assignee}; falling back to repository-scoped check`); - } - } else if (issueNumber && !hasValidIssueNumber) { - core.info(`Skipping issue-scoped assignee check for ${assignee}: invalid issue number ${String(issueNumber)}`); - } else if (issueNumber && !hasIssueScopedRequest) { - core.info(`Skipping issue-scoped assignee check for ${assignee}: github client does not support request()`); + if (!hasValidIssueNumber) { + throw new Error(`Invalid issue number for assignee check: ${String(issueNumber)}`); + } + if (typeof githubClient?.request !== "function") { + throw new Error("GitHub client does not support request()"); } - core.info(`Checking assignee alias ${assignee} via repository-scoped endpoint for ${owner}/${repo}`); - await githubClient.rest.issues.checkUserCanBeAssigned({ + core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); + await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner, repo, + issue_number: parsedIssueNumber, assignee, }); - core.info(`Assignee alias ${assignee} is assignable via repository-scoped check`); + core.info(`Assignee alias ${assignee} is assignable via issue-scoped check`); } /** @@ -199,7 +174,7 @@ async function getAssignableBots(owner, repo, githubClient = github) { * @param {string} agentName - Agent name (copilot) * @param {number|string|null} [issueNumber] - Optional issue/PR number for issue-scoped assignability check * @param {Object} [githubClient] - Authenticated GitHub client (defaults to global github) - * @returns {Promise} Agent ID or null if not found + * @returns {Promise} Agent login or null if not found */ async function findAgent(owner, repo, agentName, issueNumber = null, githubClient = github) { const loginNames = getAgentLogins(agentName); @@ -233,14 +208,8 @@ async function findAgent(owner, repo, agentName, issueNumber = null, githubClien core.info(`Assignee alias ${loginName} was not assignable: ${errorMessage}`); continue; } - // Alias confirmed assignable — resolve the user ID separately - try { - const { data: agentUser } = await githubClient.rest.users.getByUsername({ username: loginName }); - core.info(`Resolved ${agentName} agent via assignee alias ${loginName}`); - return String(agentUser.id); - } catch (lookupError) { - core.warning(`Alias ${loginName} is assignable but user lookup failed: ${getErrorMessage(lookupError)}`); - } + core.info(`Resolved ${agentName} agent via assignee alias ${loginName}`); + return loginName; } const bots = await getAssignableBots(owner, repo, githubClient); @@ -340,7 +309,7 @@ async function getPullRequestDetails(owner, repo, pullNumber, githubClient = git /** * Start an agent task for issue or pull request context using REST * @param {string} assignableId - Synthetic target ID in format owner/repo#issue:N or owner/repo#pull:N - * @param {string} agentId - Agent login name + * @param {string} agentLogin - Agent login name * @param {Array<{id: string, login: string}>} currentAssignees - List of current assignees with id and login * @param {string} agentName - Agent name for error messages * @param {string[]|null} allowedAgents - Optional list of allowed agent names. If provided, filters out non-allowed agents from current assignees. @@ -355,7 +324,7 @@ async function getPullRequestDetails(owner, repo, pullNumber, githubClient = git */ async function assignAgentToIssue( assignableId, - agentId, + agentLogin, currentAssignees, agentName, allowedAgents = null, @@ -398,104 +367,26 @@ async function assignAgentToIssue( core.error(`Invalid assignment context: ${assignableId}`); return false; } - const sourceOwner = taskContext.owner; - const sourceRepo = taskContext.repo; - const itemType = taskContext.type === "pull" ? "pull request" : "issue"; - const itemNumber = String(taskContext.number); - const sourceUrl = `https://github.com/${sourceOwner}/${sourceRepo}/${itemType === "pull request" ? "pull" : "issues"}/${itemNumber}`; - const targetRepoSlug = pullRequestRepoSlug || `${sourceOwner}/${sourceRepo}`; - const targetParts = targetRepoSlug.split("/"); - if (targetParts.length !== 2) { - core.error(`Invalid target repository slug: ${targetRepoSlug}`); + const targetOwner = taskContext.owner; + const targetRepo = taskContext.repo; + const issueNumber = Number(taskContext.number); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + core.error(`Invalid assignment issue number: ${String(taskContext.number)}`); return false; } - const targetOwner = targetParts[0]; - const targetRepo = targetParts[1]; - const promptParts = [`Start work for ${itemType} ${sourceOwner}/${sourceRepo}#${itemNumber}.`, `Use this as the primary context: ${sourceUrl}`]; - if (targetRepoSlug !== `${sourceOwner}/${sourceRepo}`) promptParts.push(`Create the branch and pull request in ${targetRepoSlug}.`); - if (customAgent) { - core.warning(`customAgent is not a dedicated REST parameter; it will be included as prompt context. If the agent runner does not parse this field, the custom agent selection may be ignored.`); - promptParts.push(`Custom agent: ${customAgent}`); - } - if (customInstructions) promptParts.push(`Additional instructions:\n${customInstructions}`); - const prompt = promptParts.join("\n\n"); try { - core.info("Starting agent task via REST API"); - const response = await githubClient.request("POST /agents/repos/{owner}/{repo}/tasks", { + core.info(`Assigning via issues assignees REST API with login: ${agentLogin}`); + await githubClient.request("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", { owner: targetOwner, repo: targetRepo, - prompt, - create_pull_request: true, - ...(model ? { model } : {}), - ...(baseBranch ? { base_ref: baseBranch } : {}), - headers: { "X-GitHub-Api-Version": "2026-03-10" }, + issue_number: issueNumber, + assignees: [agentLogin], }); - if (response?.data?.id) return true; - core.error("Unexpected response from GitHub API"); - return false; + return true; } catch (error) { const errorMessage = getErrorMessage(error); - const err = /** @type {any} */ error; - const is502Error = err?.response?.status === 502 || errorMessage.includes("502 Bad Gateway"); - - if (is502Error) { - core.warning(`Received 502 error from cloud gateway during agent task creation, but task may have been created`); - core.info(`502 error details logged for troubleshooting`); - - try { - if (error && typeof error === "object") { - const details = { - ...(err.errors && { errors: err.errors }), - ...(err.response && { response: err.response }), - ...(err.data && { data: err.data }), - }; - const serialized = JSON.stringify(details, null, 2); - if (serialized !== "{}") { - core.info("502 error details (for troubleshooting):"); - serialized - .split("\n") - .filter(line => line.trim()) - .forEach(line => core.info(line)); - } - } - } catch (loggingErr) { - const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr); - core.debug(`Failed to serialize 502 error details: ${loggingErrMsg}`); - } - - core.info(`Treating 502 error as success - agent task likely created`); - return true; - } - - // Debug: surface the raw REST error structure for troubleshooting fine-grained permission issues - try { - core.debug(`Raw REST error message: ${errorMessage}`); - if (error && typeof error === "object") { - const details = { - ...(err.errors && { errors: err.errors }), - ...(err.response && { response: err.response }), - ...(err.data && { data: err.data }), - }; - if (Array.isArray(err.errors)) { - details.compactMessages = err.errors.map(e => e.message).filter(Boolean); - } - const serialized = JSON.stringify(details, null, 2); - if (serialized !== "{}") { - core.debug(`Raw REST error details: ${serialized}`); - core.error("Raw REST error details (for troubleshooting):"); - serialized - .split("\n") - .filter(line => line.trim()) - .forEach(line => core.error(line)); - } - } - } catch (loggingErr) { - const loggingErrMsg = loggingErr instanceof Error ? loggingErr.message : String(loggingErr); - core.debug(`Failed to serialize REST error details: ${loggingErrMsg}`); - } - if ( errorMessage.includes("Bad credentials") || errorMessage.includes("Not Authenticated") || @@ -518,25 +409,20 @@ async function assignAgentToIssue( function logPermissionError(agentName) { core.error(`Failed to assign ${agentName}: Insufficient permissions`); core.error(""); - core.error("Assigning Copilot coding agent requires:"); + core.error("Assigning Copilot coding agent requires issues assignment permissions:"); core.error(" 1. Repository permissions:"); - core.error(" - actions: write"); - core.error(" - contents: write"); - core.error(" - agent-tasks: write"); + core.error(" - issues: write"); core.error(""); - core.error(" 2. A fine-grained PAT or GitHub App user token with agent-tasks: write"); - core.error(" (Installation tokens are not supported for agent task creation)"); + core.error(" 2. A token with permission to assign users to issues"); core.error(""); core.error(" 3. Repository settings:"); - core.error(" - Actions must have write permissions"); - core.error(" - Go to: Settings > Actions > General > Workflow permissions"); - core.error(" - Select: 'Read and write permissions'"); + core.error(" - Ensure assignee has access to the repository"); core.error(""); core.error(" 4. Organization/Enterprise settings and Copilot policy:"); core.error(" - Check if your org restricts bot assignments"); core.error(" - Verify Copilot is enabled for your repository"); core.error(""); - core.info("For more information, see: https://docs.github.com/en/rest/agent-tasks/agent-tasks?apiVersion=2026-03-10#start-a-task"); + core.info("For more information, see: https://docs.github.com/en/rest/issues/assignees"); } /** @@ -551,24 +437,21 @@ Assigning Copilot coding agent requires **ALL** of these permissions: \`\`\`yaml permissions: - actions: write - contents: write - agent-tasks: write + issues: write \`\`\` **Token capability note:** -- Current token lacks permission for \`POST /agents/repos/{owner}/{repo}/tasks\`. -- Agent task creation requires a fine-grained PAT or GitHub App user token with **Agent tasks: read and write**. -- GitHub App installation access tokens are not supported for this endpoint. +- Current token lacks permission for \`POST /repos/{owner}/{repo}/issues/{issue_number}/assignees\`. +- Token must be able to assign users to issues in the target repository. **Recommended remediation paths:** -1. Use a fine-grained PAT with repository access and **Agent tasks (read/write)**. -2. Use a GitHub App **user access token** (not installation token) with Agent tasks permission. -3. Verify Copilot coding agent is enabled for the repository and organization policy allows task creation. +1. Use a token with repository **Issues: write** permission. +2. Ensure repository settings allow assignee updates. +3. Verify Copilot coding agent is enabled for the repository and organization policy allows bot assignments. -**Why this failed:** The token could not create an agent task via the REST API. +**Why this failed:** The token could not update issue assignees via the REST API. -📖 Reference: https://docs.github.com/en/rest/agent-tasks/agent-tasks?apiVersion=2026-03-10#start-a-task +📖 Reference: https://docs.github.com/en/rest/issues/assignees `; } @@ -596,7 +479,7 @@ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { if (!agentId) { return { success: false, error: `${agentName} coding agent is not available for this repository` }; } - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); + core.info(`Found ${agentName} coding agent (login: ${agentId})`); // Get issue details and current assignees via REST core.info("Getting issue details..."); @@ -609,7 +492,7 @@ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { // Check if agent is already assigned const knownLogins = getAgentLogins(agentName); - if (issueDetails.currentAssignees.some(a => a.id === agentId || knownLogins.includes(a.login))) { + if (issueDetails.currentAssignees.some(a => a.login === agentId || knownLogins.includes(a.login))) { core.info(`${agentName} is already assigned to issue #${issueNumber}`); return { success: true }; } diff --git a/actions/setup/js/assign_agent_helpers.test.cjs b/actions/setup/js/assign_agent_helpers.test.cjs index ca0229ef42a..54cdfb5a7a3 100644 --- a/actions/setup/js/assign_agent_helpers.test.cjs +++ b/actions/setup/js/assign_agent_helpers.test.cjs @@ -41,7 +41,7 @@ describe("assign_agent_helpers.cjs", () => { describe("AGENT_LOGIN_NAMES", () => { it("should have copilot mapped to known assignee aliases", () => { expect(AGENT_LOGIN_NAMES).toEqual({ - copilot: ["copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], + copilot: ["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], }); }); }); @@ -80,7 +80,7 @@ describe("assign_agent_helpers.cjs", () => { describe("getAgentLogins", () => { it("should return all known copilot aliases", () => { - expect(getAgentLogins("copilot")).toEqual(["copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"]); + expect(getAgentLogins("copilot")).toEqual(["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"]); }); it("should return empty array for unknown agents", () => { @@ -91,66 +91,61 @@ describe("assign_agent_helpers.cjs", () => { describe("getAvailableAgentLogins", () => { it("should return available agent logins when an alias is assignable", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(err404).mockResolvedValueOnce({}).mockRejectedValue(err404); + mockGithub.request.mockRejectedValueOnce(err404).mockResolvedValueOnce({ status: 204 }).mockRejectedValue(err404); - const result = await getAvailableAgentLogins("owner", "repo"); + const result = await getAvailableAgentLogins("owner", "repo", 123); - expect(result).toEqual(["github-copilot-enterprise"]); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledWith({ owner: "owner", repo: "repo", assignee: "copilot-swe-agent" }); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledWith({ owner: "owner", repo: "repo", assignee: "github-copilot-enterprise" }); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(5); + expect(result).toEqual(["copilot-swe-agent"]); + expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { + owner: "owner", + repo: "repo", + issue_number: 123, + assignee: "copilot-swe-agent[bot]", + }); + expect(mockGithub.request).toHaveBeenCalledTimes(6); }); it("should return empty array when no alias is assignable (404)", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err404); + mockGithub.request.mockRejectedValue(err404); - const result = await getAvailableAgentLogins("owner", "repo"); + const result = await getAvailableAgentLogins("owner", "repo", 123); expect(result).toEqual([]); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(5); + expect(mockGithub.request).toHaveBeenCalledTimes(6); }); it("should handle non-404 errors gracefully and return empty array", async () => { const err500 = Object.assign(new Error("Server error"), { status: 500 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err500); + mockGithub.request.mockRejectedValue(err500); - const result = await getAvailableAgentLogins("owner", "repo"); + const result = await getAvailableAgentLogins("owner", "repo", 123); expect(result).toEqual([]); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Failed to check assignability for copilot-swe-agent")); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(5); + expect(mockGithub.request).toHaveBeenCalledTimes(6); }); it("should use issue-scoped assignee checks when issue number is provided", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); mockGithub.request.mockRejectedValueOnce(err404).mockResolvedValueOnce({ status: 204 }).mockRejectedValue(err404); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err404); const result = await getAvailableAgentLogins("owner", "repo", 123); - expect(result).toEqual(["github-copilot-enterprise"]); + expect(result).toEqual(["copilot-swe-agent"]); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", issue_number: 123, - assignee: "copilot-swe-agent", + assignee: "copilot-swe-agent[bot]", }); - expect(mockGithub.request).toHaveBeenCalledTimes(5); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(4); + expect(mockGithub.request).toHaveBeenCalledTimes(6); }); - it("should fall back to repository-scoped checks when issue-scoped check returns 422", async () => { - const err422 = Object.assign(new Error("Validation Failed"), { status: 422 }); - const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.request.mockRejectedValue(err422); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(err404).mockResolvedValueOnce({}).mockRejectedValue(err404); - - const result = await getAvailableAgentLogins("owner", "repo", 123); - - expect(result).toEqual(["github-copilot-enterprise"]); - expect(mockGithub.request).toHaveBeenCalledTimes(5); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(5); + it("should return empty array when issue number is invalid", async () => { + const result = await getAvailableAgentLogins("owner", "repo", "not-a-number"); + expect(result).toEqual([]); + expect(mockGithub.request).not.toHaveBeenCalled(); }); }); @@ -190,18 +185,20 @@ describe("assign_agent_helpers.cjs", () => { }); describe("findAgent", () => { - it("should find copilot agent via a fallback alias and return its ID via REST", async () => { + it("should find copilot agent via a fallback alias and return login via REST", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(err404).mockResolvedValueOnce({}); - mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 12345 } }); + mockGithub.request.mockRejectedValueOnce(err404).mockResolvedValueOnce({ status: 204 }); - const result = await findAgent("owner", "repo", "copilot"); + const result = await findAgent("owner", "repo", "copilot", 123); - expect(result).toBe("12345"); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledWith({ owner: "owner", repo: "repo", assignee: "copilot-swe-agent" }); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledWith({ owner: "owner", repo: "repo", assignee: "github-copilot-enterprise" }); - expect(mockGithub.rest.users.getByUsername).toHaveBeenCalledWith({ username: "github-copilot-enterprise" }); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(2); + expect(result).toBe("copilot-swe-agent"); + expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { + owner: "owner", + repo: "repo", + issue_number: 123, + assignee: "copilot-swe-agent[bot]", + }); + expect(mockGithub.request).toHaveBeenCalledTimes(2); }); it("should return null for unknown agent name", async () => { @@ -213,10 +210,10 @@ describe("assign_agent_helpers.cjs", () => { it("should return null and log bots when no copilot alias is available (404)", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err404); + mockGithub.request.mockRejectedValue(err404); mockGithub.rest.issues.listAssignees.mockResolvedValue({ data: [{ login: "github-actions[bot]", type: "Bot" }] }); - const result = await findAgent("owner", "repo", "copilot"); + const result = await findAgent("owner", "repo", "copilot", 123); expect(result).toBeNull(); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("copilot coding agent aliases are not available")); @@ -225,18 +222,18 @@ describe("assign_agent_helpers.cjs", () => { it("should handle REST errors and re-throw auth errors", async () => { const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(authError); + mockGithub.request.mockRejectedValueOnce(authError); - await expect(findAgent("owner", "repo", "copilot")).rejects.toThrow("Bad credentials"); + await expect(findAgent("owner", "repo", "copilot", 123)).rejects.toThrow("Bad credentials"); }); it("should return null for non-auth errors", async () => { const err = new Error("Something unexpected"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(err); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(Object.assign(new Error("Not Found"), { status: 404 })); + mockGithub.request.mockRejectedValueOnce(err); + mockGithub.request.mockRejectedValue(Object.assign(new Error("Not Found"), { status: 404 })); mockGithub.rest.issues.listAssignees.mockResolvedValue({ data: [] }); - const result = await findAgent("owner", "repo", "copilot"); + const result = await findAgent("owner", "repo", "copilot", 123); expect(result).toBeNull(); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Assignee alias copilot-swe-agent was not assignable")); @@ -245,56 +242,45 @@ describe("assign_agent_helpers.cjs", () => { it("should prefer issue-scoped assignee checks when issue number is provided", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); mockGithub.request.mockRejectedValueOnce(err404).mockResolvedValueOnce({ status: 204 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(err404); - mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 12345 } }); const result = await findAgent("owner", "repo", "copilot", 321); - expect(result).toBe("12345"); + expect(result).toBe("copilot-swe-agent"); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", issue_number: 321, - assignee: "copilot-swe-agent", + assignee: "copilot-swe-agent[bot]", }); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", issue_number: 321, - assignee: "github-copilot-enterprise", + assignee: "copilot-swe-agent", }); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(1); + expect(mockGithub.request).toHaveBeenCalledTimes(2); }); - it("should fall back to repository-scoped checks when issue number is invalid", async () => { - const err404 = Object.assign(new Error("Not Found"), { status: 404 }); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err404); - + it("should return null when issue number is invalid", async () => { const result = await findAgent("owner", "repo", "copilot", "not-a-number"); expect(result).toBeNull(); expect(mockGithub.request).not.toHaveBeenCalled(); - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalled(); }); - it("should fall back to repository-scoped checks when github client has no request method", async () => { - const err404 = Object.assign(new Error("Not Found"), { status: 404 }); + it("should return null when github client has no request method", async () => { const clientWithoutRequest = { rest: { issues: { - checkUserCanBeAssigned: vi.fn().mockRejectedValue(err404), listAssignees: vi.fn().mockResolvedValue({ data: [] }), }, - users: { - getByUsername: vi.fn(), - }, }, }; const result = await findAgent("owner", "repo", "copilot", 123, clientWithoutRequest); expect(result).toBeNull(); - expect(clientWithoutRequest.rest.issues.checkUserCanBeAssigned).toHaveBeenCalled(); + expect(clientWithoutRequest.rest.issues.listAssignees).toHaveBeenCalled(); }); }); @@ -373,60 +359,28 @@ describe("assign_agent_helpers.cjs", () => { describe("assignAgentToIssue", () => { const taskContext = { owner: "myorg", repo: "myrepo", type: "issue", number: 42 }; - it("should start an agent task via REST", async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); + it("should assign via issues assignees REST endpoint", async () => { + const mockRequest = vi.fn().mockResolvedValue({ status: 201 }); const restClient = { request: mockRequest }; - const result = await assignAgentToIssue("ignored-id", "ignored-agent-id", [], "copilot", null, null, null, null, null, restClient, taskContext); + const result = await assignAgentToIssue("ignored-id", "copilot-swe-agent[bot]", [], "copilot", null, null, null, null, null, restClient, taskContext); expect(result).toBe(true); - expect(mockRequest).toHaveBeenCalledWith( - "POST /agents/repos/{owner}/{repo}/tasks", - expect.objectContaining({ - owner: "myorg", - repo: "myrepo", - create_pull_request: true, - prompt: expect.stringContaining("myorg/myrepo#42"), - }) - ); - }); - - it("should include model in REST request when provided", async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); - const restClient = { request: mockRequest }; - - await assignAgentToIssue("id", "agent", [], "copilot", null, "claude-opus-4.6", null, null, null, restClient, taskContext); - - expect(mockRequest).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ model: "claude-opus-4.6" })); - }); - - it("should include base_ref in REST request when baseBranch is provided", async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); - const restClient = { request: mockRequest }; - - await assignAgentToIssue("id", "agent", [], "copilot", null, null, null, null, "develop", restClient, taskContext); - - expect(mockRequest).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ base_ref: "develop" })); - }); - - it("should not include model or base_ref when not provided", async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); - const restClient = { request: mockRequest }; - - await assignAgentToIssue("id", "agent", [], "copilot", null, null, null, null, null, restClient, taskContext); - - const callArgs = mockRequest.mock.calls[0][1]; - expect(callArgs.model).toBeUndefined(); - expect(callArgs.base_ref).toBeUndefined(); + expect(mockRequest).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", { + owner: "myorg", + repo: "myrepo", + issue_number: 42, + assignees: ["copilot-swe-agent[bot]"], + }); }); - it("should use pullRequestRepoSlug as target when provided", async () => { - const mockRequest = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); + it("should use taskContext repository even when pullRequestRepoSlug is provided", async () => { + const mockRequest = vi.fn().mockResolvedValue({ status: 201 }); const restClient = { request: mockRequest }; - await assignAgentToIssue("id", "agent", [], "copilot", null, null, null, null, null, restClient, taskContext, "otherorg/otherrepo"); + await assignAgentToIssue("id", "copilot-swe-agent[bot]", [], "copilot", null, null, null, null, null, restClient, taskContext, "otherorg/otherrepo"); - expect(mockRequest).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ owner: "otherorg", repo: "otherrepo" })); + expect(mockRequest).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "myorg", repo: "myrepo", issue_number: 42 })); }); it("should return false and not call request when taskContext is missing", async () => { @@ -449,15 +403,14 @@ describe("assign_agent_helpers.cjs", () => { expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("does not support REST requests")); }); - it("should treat 502 REST errors as success", async () => { + it("should return false on REST errors", async () => { const err502 = Object.assign(new Error("502 Bad Gateway"), { response: { status: 502 } }); const mockRequest = vi.fn().mockRejectedValue(err502); const restClient = { request: mockRequest }; const result = await assignAgentToIssue("id", "agent", [], "copilot", null, null, null, null, null, restClient, taskContext); - expect(result).toBe(true); - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("502")); + expect(result).toBe(false); }); it("should call logPermissionError for Bad credentials on REST path", async () => { @@ -477,30 +430,27 @@ describe("assign_agent_helpers.cjs", () => { const summary = generatePermissionErrorSummary(); expect(summary).toContain("### ⚠️ Permission Requirements"); - expect(summary).toContain("actions: write"); - expect(summary).toContain("contents: write"); - expect(summary).toContain("agent-tasks: write"); - expect(summary).toContain("POST /agents/repos/{owner}/{repo}/tasks"); + expect(summary).toContain("issues: write"); + expect(summary).toContain("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); }); }); describe("assignAgentToIssueByName", () => { it("should successfully assign copilot agent", async () => { - // findAgent: issue-scoped assignee check + getByUsername + // findAgent: issue-scoped assignee check mockGithub.request.mockResolvedValueOnce({ status: 204 }); - mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 999 } }); // getIssueDetails mockGithub.rest.issues.get.mockResolvedValueOnce({ data: { id: 1111, number: 123, assignees: [], html_url: "", title: "", body: "" }, }); - // assignAgentToIssue (REST) - mockGithub.request.mockResolvedValueOnce({ data: { id: "task-1" } }); + // assignAgentToIssue (REST assignees) + mockGithub.request.mockResolvedValueOnce({ status: 201 }); const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot"); expect(result.success).toBe(true); expect(mockCore.info).toHaveBeenCalledWith("Looking for copilot coding agent..."); - expect(mockCore.info).toHaveBeenCalledWith("Found copilot coding agent (ID: 999)"); + expect(mockCore.info).toHaveBeenCalledWith("Found copilot coding agent (login: copilot-swe-agent[bot])"); }); it("should return error for unsupported agent", async () => { @@ -514,7 +464,6 @@ describe("assign_agent_helpers.cjs", () => { it("should return error when agent is not available", async () => { const err404 = Object.assign(new Error("Not Found"), { status: 404 }); mockGithub.request.mockRejectedValue(err404); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(err404); mockGithub.rest.issues.listAssignees.mockResolvedValue({ data: [] }); const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot"); @@ -526,7 +475,6 @@ describe("assign_agent_helpers.cjs", () => { it("should report already assigned when agent is in assignees", async () => { // findAgent mockGithub.request.mockResolvedValueOnce({ status: 204 }); - mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 999 } }); // getIssueDetails - agent already assigned mockGithub.rest.issues.get.mockResolvedValueOnce({ data: { @@ -546,9 +494,8 @@ describe("assign_agent_helpers.cjs", () => { }); it("should skip assignment when a secondary copilot alias is already assigned", async () => { - // findAgent resolves via primary alias with id 999 + // findAgent resolves via primary alias mockGithub.request.mockResolvedValueOnce({ status: 204 }); - mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 999 } }); // getIssueDetails - a secondary alias is the current assignee (different id, same agent) mockGithub.rest.issues.get.mockResolvedValueOnce({ data: { diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 9ea30500b7d..ff1dff969c9 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, getPullRequestDetails, assignAgentToIssue, generatePermissionErrorSummary } = require("./assign_agent_helpers.cjs"); +const { AGENT_LOGIN_NAMES, getAgentLogins, getAvailableAgentLogins, findAgent, getIssueDetails, getPullRequestDetails, assignAgentToIssue, generatePermissionErrorSummary } = require("./assign_agent_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { resolveTarget, isStagedMode } = require("./safe_output_helpers.cjs"); const { generateStagedPreview } = require("./staged_preview.cjs"); @@ -327,15 +327,15 @@ async function main(config = {}) { try { // Find agent (use cache to avoid repeated lookups) - let agentId = agentCache[agentName]; - if (!agentId) { + let agentLogin = agentCache[agentName]; + if (!agentLogin) { core.info(`Looking for ${agentName} coding agent...`); - agentId = await findAgent(effectiveOwner, effectiveRepo, agentName, issueNumber || pullNumber, githubClient); - if (!agentId) { + agentLogin = await findAgent(effectiveOwner, effectiveRepo, agentName, issueNumber || pullNumber, githubClient); + if (!agentLogin) { throw new Error(`${agentName} coding agent is not available for this repository`); } - agentCache[agentName] = agentId; - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); + agentCache[agentName] = agentLogin; + core.info(`Found ${agentName} coding agent (login: ${agentLogin})`); } // Get issue or PR details @@ -371,7 +371,8 @@ async function main(config = {}) { // Skip if agent is already assigned and no explicit per-item pull_request_repo is specified. // When a different pull_request_repo is provided on the message, allow re-assignment // so Copilot can be triggered for a different target repository on the same issue. - if (currentAssignees.some(a => a.id === agentId) && !shouldAllowReassignment) { + const knownLogins = getAgentLogins(agentName); + if (currentAssignees.some(a => a.login === agentLogin || knownLogins.includes(a.login)) && !shouldAllowReassignment) { core.info(`${agentName} is already assigned to ${type} #${number}`); _allResults.push({ issue_number: issueNumber, pull_number: pullNumber, agent: agentName, owner: effectiveOwner, repo: effectiveRepo, pull_request_repo: effectivePullRequestRepoSlug, success: true }); return { success: true }; @@ -383,7 +384,7 @@ async function main(config = {}) { if (customInstructions) core.info(`Using custom instructions: ${customInstructions.substring(0, 100)}${customInstructions.length > 100 ? "..." : ""}`); if (effectiveBaseBranch) core.info(`Using base branch: ${effectiveBaseBranch}`); - const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, model, customAgent, customInstructions, effectiveBaseBranch, githubClient, taskContext, effectivePullRequestRepoSlug); + const success = await assignAgentToIssue(assignableId, agentLogin, currentAssignees, agentName, allowedAgents, model, customAgent, customInstructions, effectiveBaseBranch, githubClient, taskContext, effectivePullRequestRepoSlug); if (!success) throw new Error(`Failed to assign ${agentName} via REST`); core.info(`Successfully assigned ${agentName} coding agent to ${type} #${number}`); diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 2426d4df9b6..ed51d326b9d 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -432,7 +432,7 @@ describe("assign_to_agent", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Successfully assigned copilot coding agent to issue #42")); expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ owner: "test-owner", repo: "other-platform-repo" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); it("should process multiple assignments for the same temporary issue ID across different pull_request_repo targets", async () => { @@ -485,10 +485,10 @@ describe("assign_to_agent", () => { expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #6587")); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Successfully assigned copilot coding agent to issue #6587")); - const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /agents/repos/{owner}/{repo}/tasks"); + const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); expect(assignmentCalls).toHaveLength(2); - expect(assignmentCalls[0][1]).toMatchObject({ owner: "test-owner", repo: "ios-repo" }); - expect(assignmentCalls[1][1]).toMatchObject({ owner: "test-owner", repo: "android-repo" }); + expect(assignmentCalls[0][1]).toMatchObject({ owner: "test-owner", repo: "test-repo", issue_number: 6587 }); + expect(assignmentCalls[1][1]).toMatchObject({ owner: "test-owner", repo: "test-repo", issue_number: 6587 }); expect(mockSleep).toHaveBeenCalledTimes(1); expect(mockSleep).toHaveBeenCalledWith(10000); @@ -543,7 +543,7 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #6587")); - const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /agents/repos/{owner}/{repo}/tasks"); + const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); expect(assignmentCalls).toHaveLength(1); expect(mockSleep).toHaveBeenCalledTimes(1); expect(mockSleep).toHaveBeenCalledWith(10000); @@ -573,7 +573,7 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #42")); - const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /agents/repos/{owner}/{repo}/tasks"); + const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); expect(assignmentCalls).toHaveLength(0); }); @@ -607,7 +607,7 @@ describe("assign_to_agent", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("copilot is already assigned to issue #42")); // Should NOT have called the assignment mutation (only 3 GraphQL calls: repo lookup, find agent, get issue) expect(mockGithub.rest.repos.get).toHaveBeenCalledTimes(1); // global PR repo lookup - const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /agents/repos/{owner}/{repo}/tasks"); + const assignmentCalls = mockGithub.request.mock.calls.filter(([route]) => route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); expect(assignmentCalls).toHaveLength(0); // no assignment since already assigned expect(mockCore.setFailed).not.toHaveBeenCalled(); }); @@ -633,7 +633,7 @@ describe("assign_to_agent", () => { expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); }); - it("should handle 502 errors as success", async () => { + it("should fail on 502 assignment errors", async () => { setAgentOutput({ items: [ { @@ -653,7 +653,7 @@ describe("assign_to_agent", () => { }); // Assignment fails with 502 mockGithub.request.mockImplementation(route => { - if (route === "POST /agents/repos/{owner}/{repo}/tasks") { + if (route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees") { return Promise.reject({ response: { status: 502, @@ -663,21 +663,15 @@ describe("assign_to_agent", () => { }, }); } - return Promise.resolve({ data: { id: "task-123" } }); + return Promise.resolve({ status: 204 }); }); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); - // Should warn about 502 but treat as success - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Received 502 error from cloud gateway")); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Treating 502 error as success")); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - const summaryCall = mockCore.summary.addRaw.mock.calls[0][0]; - expect(summaryCall).toContain("Successfully assigned 1 agent(s)"); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); }); - it("should handle 502 errors in message as success", async () => { + it("should fail on 502 assignment message errors", async () => { setAgentOutput({ items: [ { @@ -697,21 +691,18 @@ describe("assign_to_agent", () => { }); // Assignment fails with 502 message mockGithub.request.mockImplementation(route => { - if (route === "POST /agents/repos/{owner}/{repo}/tasks") { + if (route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees") { return Promise.reject(new Error("502 Bad Gateway")); } - return Promise.resolve({ data: { id: "task-123" } }); + return Promise.resolve({ status: 204 }); }); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); - // Should warn about 502 but treat as success - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Received 502 error from cloud gateway")); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Treating 502 error as success")); - expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); }); - it("should cache agent IDs for multiple assignments", async () => { + it("should cache agent lookups for multiple assignments", async () => { setAgentOutput({ items: [ { type: "assign_to_agent", issue_number: 1, agent: "copilot" }, @@ -735,7 +726,8 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); // Should only look up agent once (cached for second assignment) - expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(1); + const lookupCalls = mockGithub.request.mock.calls.filter(([route]) => route === "GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}"); + expect(lookupCalls).toHaveLength(1); }, 15000); // Increase timeout to 15 seconds to account for the delay it("should use target repository when configured", async () => { @@ -1041,7 +1033,7 @@ describe("assign_to_agent", () => { // Simulate authentication error - use mockRejectedValueOnce to avoid affecting other tests const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(authError); + mockGithub.request.mockRejectedValueOnce(authError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1079,7 +1071,7 @@ describe("assign_to_agent", () => { // Simulate authentication error const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(authError); + mockGithub.request.mockRejectedValue(authError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1116,7 +1108,7 @@ describe("assign_to_agent", () => { // Simulate permission error const permError = new Error("Resource not accessible by integration"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(permError); + mockGithub.request.mockRejectedValue(permError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1138,12 +1130,10 @@ describe("assign_to_agent", () => { errors: [], }); - // Simulate agent not available (checkUserCanBeAssigned returns 404) + // Simulate agent not available (assignee check returns 404) const notFoundError = new Error("Not Found"); notFoundError.status = 404; - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValueOnce(notFoundError); - // getAvailableAgentLogins also calls checkUserCanBeAssigned - return 404 for that too - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(notFoundError); + mockGithub.request.mockRejectedValue(notFoundError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1174,10 +1164,10 @@ describe("assign_to_agent", () => { // Simulate a different error (not auth-related) during assignment const otherError = new Error("Network timeout"); mockGithub.request.mockImplementation(route => { - if (route === "POST /agents/repos/{owner}/{repo}/tasks") { + if (route === "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees") { return Promise.reject(otherError); } - return Promise.resolve({ data: { id: "task-123" } }); + return Promise.resolve({ status: 204 }); }); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1248,7 +1238,7 @@ describe("assign_to_agent", () => { // Fail all assignments with auth error const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(authError); + mockGithub.request.mockRejectedValue(authError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1273,7 +1263,7 @@ describe("assign_to_agent", () => { // Simulate an error whose message contains an @mention and an HTML comment — // both are potentially dangerous if posted unsanitized. const dangerousError = new Error("@admin triggered error"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(dangerousError); + mockGithub.request.mockRejectedValue(dangerousError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1302,7 +1292,7 @@ describe("assign_to_agent", () => { // Simulate authentication error (will be skipped by ignore-if-error) const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(authError); + mockGithub.request.mockRejectedValue(authError); await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); @@ -1323,7 +1313,7 @@ describe("assign_to_agent", () => { }); const authError = new Error("Bad credentials"); - mockGithub.rest.issues.checkUserCanBeAssigned.mockRejectedValue(authError); + mockGithub.request.mockRejectedValue(authError); // Simulate failure to post comment mockGithub.rest.issues.createComment.mockRejectedValue(new Error("Could not post comment")); @@ -1482,7 +1472,7 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/pull-request-repo")); - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ owner: "test-owner", repo: "pull-request-repo" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); it("should handle per-item pull_request_repo parameter", async () => { @@ -1521,7 +1511,7 @@ describe("assign_to_agent", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using per-item pull request repository: test-owner/item-pull-request-repo")); - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ owner: "test-owner", repo: "item-pull-request-repo" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); it("should reject per-item pull_request_repo not in allowed list", async () => { @@ -1600,10 +1590,8 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); expect(mockCore.setFailed).not.toHaveBeenCalled(); - // Verify the request was called with base_ref set to the explicit base-branch - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ base_ref: "develop" })); - const requestArgs = mockGithub.request.mock.calls[0][1]; - expect(requestArgs.customInstructions).toBeUndefined(); + // Assignment uses issue assignees endpoint and does not include base_ref/custom instructions + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); it("should auto-resolve non-main default branch from pull-request-repo and set as baseRef", async () => { @@ -1624,8 +1612,7 @@ describe("assign_to_agent", () => { expect(mockCore.setFailed).not.toHaveBeenCalled(); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved pull request repository default branch: develop")); - // Verify the request was called with base_ref set to the resolved default branch - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ base_ref: "develop" })); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); it("should set baseRef when pull-request-repo default branch is main (no explicit base-branch)", async () => { @@ -1645,9 +1632,6 @@ describe("assign_to_agent", () => { await eval(`(async () => { ${assignToAgentScript}; ${STANDALONE_RUNNER} })()`); expect(mockCore.setFailed).not.toHaveBeenCalled(); - // Verify the request was called with base_ref set to the repo's default branch - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ base_ref: "main" })); - const requestArgs = mockGithub.request.mock.calls[0][1]; - expect(requestArgs.customInstructions).toBeUndefined(); + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: "test-owner", repo: "test-repo", issue_number: 42 })); }); }); From 0d65b42544f348e173625bcf07cd2715d7a9a206 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:02:05 +0000 Subject: [PATCH 3/8] Refine assign-to-agent REST checks and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_agent_helpers.cjs | 30 ++++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index a43b1d77a0e..c5878852629 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -70,6 +70,20 @@ function getAgentName(assignee) { return AGENT_NAME_BY_LOGIN[normalized] || null; } +/** + * Parse and validate an issue/PR number for assignee REST endpoints. + * @param {number|string|null|undefined} issueNumber + * @param {string} contextLabel + * @returns {number} + */ +function parseIssueNumber(issueNumber, contextLabel) { + const parsedIssueNumber = Number(issueNumber); + if (!Number.isInteger(parsedIssueNumber) || parsedIssueNumber <= 0) { + throw new Error(`Invalid issue number for ${contextLabel}: ${String(issueNumber)} (issue number must be a positive integer)`); + } + return parsedIssueNumber; +} + /** * Return list of coding agent bot login names that are currently available as assignable actors * in this repository, preferring issue-scoped checks when issue/PR context is available @@ -109,13 +123,9 @@ async function getAvailableAgentLogins(owner, repo, issueNumber = null, githubCl * @param {Object} githubClient */ async function validateAssigneeAlias(owner, repo, assignee, issueNumber, githubClient) { - const parsedIssueNumber = Number(issueNumber); - const hasValidIssueNumber = Number.isInteger(parsedIssueNumber) && parsedIssueNumber > 0; - if (!hasValidIssueNumber) { - throw new Error(`Invalid issue number for assignee check: ${String(issueNumber)}`); - } + const parsedIssueNumber = parseIssueNumber(issueNumber, "assignee check"); if (typeof githubClient?.request !== "function") { - throw new Error("GitHub client does not support request()"); + throw new Error("GitHub client does not support request() (REST issue assignee checks require an Octokit client with request())"); } core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { @@ -369,9 +379,11 @@ async function assignAgentToIssue( } const targetOwner = taskContext.owner; const targetRepo = taskContext.repo; - const issueNumber = Number(taskContext.number); - if (!Number.isInteger(issueNumber) || issueNumber <= 0) { - core.error(`Invalid assignment issue number: ${String(taskContext.number)}`); + let issueNumber; + try { + issueNumber = parseIssueNumber(taskContext.number, "assignment"); + } catch (e) { + core.error(getErrorMessage(e)); return false; } From ccf8c5c025c188b5e2478b7515c772d62a1647e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:04:53 +0000 Subject: [PATCH 4/8] Polish assign-to-agent alias and REST messaging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_agent_helpers.cjs | 6 ++++-- actions/setup/js/assign_to_agent.test.cjs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index c5878852629..0ee3aeb9cf8 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -15,6 +15,8 @@ const { getErrorMessage } = require("./error_helpers.cjs"); * @type {Record} */ const AGENT_LOGIN_NAMES = { + // Prefer [bot] aliases first so assignability checks and assignment requests + // use the canonical bot login when both plain and [bot] aliases exist. copilot: ["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], }; @@ -79,7 +81,7 @@ function getAgentName(assignee) { function parseIssueNumber(issueNumber, contextLabel) { const parsedIssueNumber = Number(issueNumber); if (!Number.isInteger(parsedIssueNumber) || parsedIssueNumber <= 0) { - throw new Error(`Invalid issue number for ${contextLabel}: ${String(issueNumber)} (issue number must be a positive integer)`); + throw new Error(`Invalid issue number for ${contextLabel}: received '${String(issueNumber)}', expected a positive integer`); } return parsedIssueNumber; } @@ -125,7 +127,7 @@ async function getAvailableAgentLogins(owner, repo, issueNumber = null, githubCl async function validateAssigneeAlias(owner, repo, assignee, issueNumber, githubClient) { const parsedIssueNumber = parseIssueNumber(issueNumber, "assignee check"); if (typeof githubClient?.request !== "function") { - throw new Error("GitHub client does not support request() (REST issue assignee checks require an Octokit client with request())"); + throw new Error("GitHub client does not support request() method required for REST issue assignee checks"); } core.info(`Checking assignee alias ${assignee} via issue-scoped endpoint for ${owner}/${repo}#${parsedIssueNumber}`); await githubClient.request("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index ed51d326b9d..fd9832bcff6 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -702,7 +702,7 @@ describe("assign_to_agent", () => { expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to assign 1 agent(s)")); }); - it("should cache agent lookups for multiple assignments", async () => { + it("should cache resolved agent logins for multiple assignments", async () => { setAgentOutput({ items: [ { type: "assign_to_agent", issue_number: 1, agent: "copilot" }, From a430a9c7a946b12710f21ff11e575bc5d14e566a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:44:54 +0000 Subject: [PATCH 5/8] Add official docs link and token permission guidance for assign-to-agent Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_agent_helpers.cjs | 40 ++++++++++--------- .../setup/js/assign_agent_helpers.test.cjs | 4 +- .../docs/reference/copilot-cloud-agent.mdx | 14 +++++-- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index 0ee3aeb9cf8..775527a9481 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -236,7 +236,7 @@ async function findAgent(owner, repo, agentName, issueNumber = null, githubClien core.info("No assignable bots found in this repository."); } if (agentName === "copilot") { - core.info("Please visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot"); + core.info("Please visit https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api"); } return null; } @@ -423,20 +423,21 @@ async function assignAgentToIssue( function logPermissionError(agentName) { core.error(`Failed to assign ${agentName}: Insufficient permissions`); core.error(""); - core.error("Assigning Copilot coding agent requires issues assignment permissions:"); - core.error(" 1. Repository permissions:"); - core.error(" - issues: write"); + core.error("Assigning Copilot coding agent requires the following token permissions:"); + core.error(" Fine-grained PAT:"); + core.error(" - Read access to metadata"); + core.error(" - Read and write access to actions, contents, issues, and pull requests"); + core.error(" Classic PAT:"); + core.error(" - repo scope"); core.error(""); - core.error(" 2. A token with permission to assign users to issues"); + core.error(" Repository settings:"); + core.error(" - Ensure assignee has access to the repository"); core.error(""); - core.error(" 3. Repository settings:"); - core.error(" - Ensure assignee has access to the repository"); + core.error(" Organization/Enterprise settings and Copilot policy:"); + core.error(" - Check if your org restricts bot assignments"); + core.error(" - Verify Copilot is enabled for your repository"); core.error(""); - core.error(" 4. Organization/Enterprise settings and Copilot policy:"); - core.error(" - Check if your org restricts bot assignments"); - core.error(" - Verify Copilot is enabled for your repository"); - core.error(""); - core.info("For more information, see: https://docs.github.com/en/rest/issues/assignees"); + core.info("For more information, see: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api"); } /** @@ -447,25 +448,26 @@ function generatePermissionErrorSummary() { return ` ### ⚠️ Permission Requirements -Assigning Copilot coding agent requires **ALL** of these permissions: +Assigning Copilot coding agent requires a token with the correct permissions. See the [official GitHub Copilot cloud agent API documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api) for details. + +**Fine-grained personal access token** — requires these repository permissions: +- Read access to **metadata** +- Read and write access to **actions**, **contents**, **issues**, and **pull requests** -\`\`\`yaml -permissions: - issues: write -\`\`\` +**Classic personal access token** — requires the **\`repo\`** scope. **Token capability note:** - Current token lacks permission for \`POST /repos/{owner}/{repo}/issues/{issue_number}/assignees\`. - Token must be able to assign users to issues in the target repository. **Recommended remediation paths:** -1. Use a token with repository **Issues: write** permission. +1. Use a fine-grained PAT with the permissions listed above, or a classic PAT with the \`repo\` scope. 2. Ensure repository settings allow assignee updates. 3. Verify Copilot coding agent is enabled for the repository and organization policy allows bot assignments. **Why this failed:** The token could not update issue assignees via the REST API. -📖 Reference: https://docs.github.com/en/rest/issues/assignees +📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api `; } diff --git a/actions/setup/js/assign_agent_helpers.test.cjs b/actions/setup/js/assign_agent_helpers.test.cjs index 54cdfb5a7a3..4bc5f270d16 100644 --- a/actions/setup/js/assign_agent_helpers.test.cjs +++ b/actions/setup/js/assign_agent_helpers.test.cjs @@ -430,8 +430,10 @@ describe("assign_agent_helpers.cjs", () => { const summary = generatePermissionErrorSummary(); expect(summary).toContain("### ⚠️ Permission Requirements"); - expect(summary).toContain("issues: write"); + expect(summary).toContain("Fine-grained personal access token"); + expect(summary).toContain("actions**, **contents**, **issues**"); expect(summary).toContain("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"); + expect(summary).toContain("https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api"); }); }); diff --git a/docs/src/content/docs/reference/copilot-cloud-agent.mdx b/docs/src/content/docs/reference/copilot-cloud-agent.mdx index 4d448372bf8..dc4194b379e 100644 --- a/docs/src/content/docs/reference/copilot-cloud-agent.mdx +++ b/docs/src/content/docs/reference/copilot-cloud-agent.mdx @@ -87,14 +87,22 @@ When an `allowed` list is configured, existing agent assignees not in the list a Both safe outputs require a fine-grained PAT. The default `GITHUB_TOKEN` lacks the necessary permissions. +See the [official GitHub Copilot cloud agent API documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api) for the authoritative token requirements. + ### Using a Personal Access Token (PAT) The required token type and permissions depend on whether you own the repository or an organization owns it. -1. **Create the PAT** with **Repository permissions**: Actions, Contents, Agent tasks (read and write). +**Fine-grained PAT** — requires these repository permissions: +- Read access to **metadata** +- Read and write access to **actions**, **contents**, **issues**, and **pull requests** + +**Classic PAT** — requires the `repo` scope. + +1. **Create the fine-grained PAT** with the permissions listed above. - - [User-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&agent_tasks=write): Resource owner = your user account; Repository access = "Public repositories" or specific repos - - [Organization-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&agent_tasks=write): Resource owner = the organization; Repository access = specific repositories that will use the workflow + - [User-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&issues=write&pull_requests=write): Resource owner = your user account; Repository access = "Public repositories" or specific repos + - [Organization-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&issues=write&pull_requests=write): Resource owner = the organization; Repository access = specific repositories that will use the workflow 2. Add to repository secrets: From 5f37c8481b41aa6cc1fa56810db3537e546fa4cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:46:16 +0000 Subject: [PATCH 6/8] Refine docs: update PAT creation step wording and link text Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/copilot-cloud-agent.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/reference/copilot-cloud-agent.mdx b/docs/src/content/docs/reference/copilot-cloud-agent.mdx index dc4194b379e..5a835ee3a5e 100644 --- a/docs/src/content/docs/reference/copilot-cloud-agent.mdx +++ b/docs/src/content/docs/reference/copilot-cloud-agent.mdx @@ -87,7 +87,7 @@ When an `allowed` list is configured, existing agent assignees not in the list a Both safe outputs require a fine-grained PAT. The default `GITHUB_TOKEN` lacks the necessary permissions. -See the [official GitHub Copilot cloud agent API documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api) for the authoritative token requirements. +See the [official GitHub Copilot cloud agent API documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api#using-the-issues-api) for full details on token requirements. ### Using a Personal Access Token (PAT) @@ -99,7 +99,9 @@ The required token type and permissions depend on whether you own the repository **Classic PAT** — requires the `repo` scope. -1. **Create the fine-grained PAT** with the permissions listed above. +1. **Create a PAT** — choose one of: + - **Fine-grained PAT** with the permissions listed above. + - **Classic PAT** with the `repo` scope. - [User-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&issues=write&pull_requests=write): Resource owner = your user account; Repository access = "Public repositories" or specific repos - [Organization-owned repositories](https://github.com/settings/personal-access-tokens/new?name=GH_AW_AGENT_TOKEN&description=GitHub+Agentic+Workflows+-+Agent+assignment&actions=write&contents=write&issues=write&pull_requests=write): Resource owner = the organization; Repository access = specific repositories that will use the workflow From 086b959d85662e606596c75904ecf43f35e1dc6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:54:43 +0000 Subject: [PATCH 7/8] Reorder AGENT_LOGIN_NAMES to put all [bot] aliases first Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/assign_agent_helpers.cjs | 2 +- actions/setup/js/assign_agent_helpers.test.cjs | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index 775527a9481..96d231dae0c 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -17,7 +17,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const AGENT_LOGIN_NAMES = { // Prefer [bot] aliases first so assignability checks and assignment requests // use the canonical bot login when both plain and [bot] aliases exist. - copilot: ["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], + copilot: ["copilot-swe-agent[bot]", "github-copilot-enterprise[bot]", "github-copilot[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot"], }; /** diff --git a/actions/setup/js/assign_agent_helpers.test.cjs b/actions/setup/js/assign_agent_helpers.test.cjs index 4bc5f270d16..d66903bd11c 100644 --- a/actions/setup/js/assign_agent_helpers.test.cjs +++ b/actions/setup/js/assign_agent_helpers.test.cjs @@ -41,7 +41,7 @@ describe("assign_agent_helpers.cjs", () => { describe("AGENT_LOGIN_NAMES", () => { it("should have copilot mapped to known assignee aliases", () => { expect(AGENT_LOGIN_NAMES).toEqual({ - copilot: ["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"], + copilot: ["copilot-swe-agent[bot]", "github-copilot-enterprise[bot]", "github-copilot[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot"], }); }); }); @@ -80,7 +80,7 @@ describe("assign_agent_helpers.cjs", () => { describe("getAgentLogins", () => { it("should return all known copilot aliases", () => { - expect(getAgentLogins("copilot")).toEqual(["copilot-swe-agent[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot-enterprise[bot]", "github-copilot", "github-copilot[bot]"]); + expect(getAgentLogins("copilot")).toEqual(["copilot-swe-agent[bot]", "github-copilot-enterprise[bot]", "github-copilot[bot]", "copilot-swe-agent", "github-copilot-enterprise", "github-copilot"]); }); it("should return empty array for unknown agents", () => { @@ -95,7 +95,7 @@ describe("assign_agent_helpers.cjs", () => { const result = await getAvailableAgentLogins("owner", "repo", 123); - expect(result).toEqual(["copilot-swe-agent"]); + expect(result).toEqual(["github-copilot-enterprise[bot]"]); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", @@ -132,7 +132,7 @@ describe("assign_agent_helpers.cjs", () => { const result = await getAvailableAgentLogins("owner", "repo", 123); - expect(result).toEqual(["copilot-swe-agent"]); + expect(result).toEqual(["github-copilot-enterprise[bot]"]); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", @@ -191,7 +191,7 @@ describe("assign_agent_helpers.cjs", () => { const result = await findAgent("owner", "repo", "copilot", 123); - expect(result).toBe("copilot-swe-agent"); + expect(result).toBe("github-copilot-enterprise[bot]"); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", @@ -245,7 +245,7 @@ describe("assign_agent_helpers.cjs", () => { const result = await findAgent("owner", "repo", "copilot", 321); - expect(result).toBe("copilot-swe-agent"); + expect(result).toBe("github-copilot-enterprise[bot]"); expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", { owner: "owner", repo: "repo", @@ -256,7 +256,7 @@ describe("assign_agent_helpers.cjs", () => { owner: "owner", repo: "repo", issue_number: 321, - assignee: "copilot-swe-agent", + assignee: "github-copilot-enterprise[bot]", }); expect(mockGithub.request).toHaveBeenCalledTimes(2); }); From 3c6c0fc0a2bc6945c080c28baf21dfa4016de203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:46:30 +0000 Subject: [PATCH 8/8] Fix CI test failures: update stale task API assertions to issue assignees REST routes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_issue.test.cjs | 8 +-- actions/setup/js/create_pull_request.test.cjs | 11 ++-- pkg/workflow/schemas/github-workflow.json | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/create_issue.test.cjs b/actions/setup/js/create_issue.test.cjs index e46ebece1d3..2736a4cf3b3 100644 --- a/actions/setup/js/create_issue.test.cjs +++ b/actions/setup/js/create_issue.test.cjs @@ -386,16 +386,16 @@ describe("create_issue", () => { }, }); - // Mock assignAgentToIssue (REST: request) - mockGithub.request = vi.fn().mockResolvedValue({ data: { id: "task-123" } }); + // Mock assignAgentToIssue (REST: request — GET assignability check + POST assignment) + mockGithub.request = vi.fn().mockResolvedValue({ status: 204 }); const handler = await main({ assignees: ["copilot"], }); await handler({ title: "Test" }); - // Verify REST task creation was called - expect(mockGithub.request).toHaveBeenCalledWith("POST /agents/repos/{owner}/{repo}/tasks", expect.objectContaining({ owner: expect.any(String), repo: expect.any(String) })); + // Verify REST issue assignee mutation was called + expect(mockGithub.request).toHaveBeenCalledWith("POST /repos/{owner}/{repo}/issues/{issue_number}/assignees", expect.objectContaining({ owner: expect.any(String), repo: expect.any(String) })); }); }); diff --git a/actions/setup/js/create_pull_request.test.cjs b/actions/setup/js/create_pull_request.test.cjs index 653744838f4..655df481ff2 100644 --- a/actions/setup/js/create_pull_request.test.cjs +++ b/actions/setup/js/create_pull_request.test.cjs @@ -3276,16 +3276,15 @@ describe("create_pull_request - copilot assignee on fallback issues", () => { expect(global.github.request).not.toHaveBeenCalled(); }); - it("should strip copilot from REST assignees for fallback issue but assign via REST task when enabled", async () => { + it("should strip copilot from REST assignees for fallback issue but assign via issue assignees REST when enabled", async () => { process.env.GH_AW_ASSIGN_COPILOT = "true"; // Mock findAgent → getIssueDetails → assignAgentToIssue - global.github.rest.issues.checkUserCanBeAssigned.mockResolvedValueOnce({}); - global.github.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 99999 } }); global.github.rest.issues.get.mockResolvedValueOnce({ data: { id: 12345, number: 99, assignees: [], html_url: "", title: "", body: "" }, }); - global.github.request.mockResolvedValueOnce({ data: { id: "task-123" } }); + // GET assignability check + POST assignment + global.github.request.mockResolvedValueOnce({ status: 204 }).mockResolvedValueOnce({}); const { main } = require("./create_pull_request.cjs"); const handler = await main({ assignees: ["copilot", "user1"], allow_empty: true }); @@ -3296,10 +3295,10 @@ describe("create_pull_request - copilot assignee on fallback issues", () => { expect(issueCall.assignees).not.toContain("copilot"); expect(issueCall.assignees).toContain("user1"); - // One request for issue-scoped alias validation and one for REST task creation + // One request for issue-scoped alias validation and one for REST assignment expect(global.github.request).toHaveBeenCalledTimes(2); const requestedRoutes = global.github.request.mock.calls.map(([route]) => route); - expect(requestedRoutes).toEqual(expect.arrayContaining(["GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", "POST /agents/repos/{owner}/{repo}/tasks"])); + expect(requestedRoutes).toEqual(expect.arrayContaining(["GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", "POST /repos/{owner}/{repo}/issues/{issue_number}/assignees"])); }); it("should use configured fallback_labels for fallback issues instead of PR labels", async () => { diff --git a/pkg/workflow/schemas/github-workflow.json b/pkg/workflow/schemas/github-workflow.json index f670ca49089..9d394757abc 100644 --- a/pkg/workflow/schemas/github-workflow.json +++ b/pkg/workflow/schemas/github-workflow.json @@ -496,6 +496,18 @@ }, { "required": ["run"] + }, + { + "required": ["wait"] + }, + { + "required": ["wait-all"] + }, + { + "required": ["cancel"] + }, + { + "required": ["parallel"] } ], "properties": { @@ -574,6 +586,46 @@ "$ref": "#/definitions/expressionSyntax" } ] + }, + "background": { + "$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsbackground", + "description": "Runs a step asynchronously so the job continues to the next step without waiting for it to finish. You can use background on steps that use run or uses. To reference a background step from wait or cancel, give it an id. A maximum of 10 background steps can run concurrently in a single job.", + "type": "boolean" + }, + "wait": { + "$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswait", + "description": "Pauses the job until one or more background steps complete. Provide a single step id as a string, or multiple step ids as an array. After a wait step completes, the outputs of the referenced background steps become available to subsequent steps.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] + }, + "wait-all": { + "$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswait-all", + "description": "Pauses the job until all active background steps complete. The wait-all keyword takes no arguments.", + "type": ["boolean", "null"] + }, + "cancel": { + "$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscancel", + "description": "Gracefully terminates a running background step. The runner sends the step's process a termination signal (SIGTERM) so it can clean up. The cancel keyword targets a single background step by its id.", + "type": "string" + }, + "parallel": { + "$comment": "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsparallel", + "description": "Runs a group of steps concurrently, then waits for all of them to finish before continuing. Every step in the group runs as a background step, with an implicit wait at the end of the group.", + "type": "array", + "items": { + "$ref": "#/definitions/step" + }, + "minItems": 1 } } },