Skip to content
Merged
239 changes: 69 additions & 170 deletions actions/setup/js/assign_agent_helpers.cjs

Large diffs are not rendered by default.

199 changes: 74 additions & 125 deletions actions/setup/js/assign_agent_helpers.test.cjs

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand All @@ -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}`);
Expand Down
86 changes: 35 additions & 51 deletions actions/setup/js/assign_to_agent.test.cjs

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) }));
});
});

Expand Down
11 changes: 5 additions & 6 deletions actions/setup/js/create_pull_request.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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 () => {
Expand Down
16 changes: 13 additions & 3 deletions docs/src/content/docs/reference/copilot-cloud-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,24 @@ 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 full details on 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 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&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:

Expand Down
52 changes: 52 additions & 0 deletions pkg/workflow/schemas/github-workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,18 @@
},
{
"required": ["run"]
},
{
"required": ["wait"]
},
{
"required": ["wait-all"]
},
{
"required": ["cancel"]
},
{
"required": ["parallel"]
}
],
"properties": {
Expand Down Expand Up @@ -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
}
}
},
Expand Down
Loading