Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 44 additions & 13 deletions actions/setup/js/assign_agent_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,10 @@ async function getPullRequestDetails(owner, repo, pullNumber) {
* @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.
* @param {string|null} pullRequestRepoId - Optional pull request repository ID for specifying where the PR should be created (GitHub agentAssignment.targetRepositoryId)
* @returns {Promise<boolean>} True if successful
*/
async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null) {
async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null, pullRequestRepoId = null) {
// Filter current assignees based on allowed list (if configured)
let filteredAssignees = currentAssignees;
if (allowedAgents && allowedAgents.length > 0) {
Expand All @@ -271,24 +272,54 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent
// Build actor IDs array - include new agent and preserve filtered assignees
const actorIds = [agentId, ...filteredAssignees.map(a => a.id).filter(id => id !== agentId)];

const mutation = `
mutation($assignableId: ID!, $actorIds: [ID!]!) {
replaceActorsForAssignable(input: {
assignableId: $assignableId,
actorIds: $actorIds
}) {
__typename
// Build the mutation - conditionally include agentAssignment if pullRequestRepoId is provided
let mutation;
let variables;

if (pullRequestRepoId) {
// Include agentAssignment with targetRepositoryId for cross-repo PR creation
mutation = `
mutation($assignableId: ID!, $actorIds: [ID!]!, $targetRepoId: ID!) {
replaceActorsForAssignable(input: {
assignableId: $assignableId,
actorIds: $actorIds,
agentAssignment: {
targetRepositoryId: $targetRepoId
}
}) {
__typename
}
}
}
`;
`;
variables = {
assignableId: assignableId,
actorIds,
targetRepoId: pullRequestRepoId,
};
} else {
// Standard mutation without agentAssignment
mutation = `
mutation($assignableId: ID!, $actorIds: [ID!]!) {
replaceActorsForAssignable(input: {
assignableId: $assignableId,
actorIds: $actorIds
}) {
__typename
}
}
`;
variables = {
assignableId: assignableId,
actorIds,
};
}

try {
core.info("Using built-in github object for mutation");

core.debug(`GraphQL mutation with variables: assignableId=${assignableId}, actorIds=${JSON.stringify(actorIds)}`);
core.debug(`GraphQL mutation with variables: assignableId=${assignableId}, actorIds=${JSON.stringify(actorIds)}${pullRequestRepoId ? `, targetRepoId=${pullRequestRepoId}` : ""}`);
const response = await github.graphql(mutation, {
assignableId: assignableId,
actorIds,
...variables,
headers: {
"GraphQL-Features": "issues_copilot_assignment_api_support",
},
Expand Down
102 changes: 101 additions & 1 deletion actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,53 @@ async function main() {
// The github-token is set at the step level, so the built-in github object is authenticated
// with the correct token (GH_AW_AGENT_TOKEN by default)

// Get PR repository configuration (where the PR should be created, may differ from issue repo)
const pullRequestRepoEnv = process.env.GH_AW_AGENT_PULL_REQUEST_REPO?.trim();
let pullRequestOwner = null;
let pullRequestRepo = null;
let pullRequestRepoId = null;

// Get allowed PR repos configuration for cross-repo validation
const allowedPullRequestReposEnv = process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS?.trim();
const allowedPullRequestRepos = parseAllowedRepos(allowedPullRequestReposEnv);

if (pullRequestRepoEnv) {
const parts = pullRequestRepoEnv.split("/");
if (parts.length === 2) {
// Validate PR repository against allowlist
// The configured pull-request-repo is treated as the default (always allowed)
// allowed-pull-request-repos contains additional repositories beyond pull-request-repo
const repoValidation = validateRepo(pullRequestRepoEnv, pullRequestRepoEnv, allowedPullRequestRepos);
if (!repoValidation.valid) {
core.setFailed(`E004: ${repoValidation.error}`);
return;
}

pullRequestOwner = parts[0];
pullRequestRepo = parts[1];
core.info(`Using pull request repository: ${pullRequestOwner}/${pullRequestRepo}`);

// Fetch the repository ID for the PR repo (needed for GraphQL agentAssignment)
try {
const pullRequestRepoQuery = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
}
}
`;
const pullRequestRepoResponse = await github.graphql(pullRequestRepoQuery, { owner: pullRequestOwner, name: pullRequestRepo });
pullRequestRepoId = pullRequestRepoResponse.repository.id;
core.info(`Pull request repository ID: ${pullRequestRepoId}`);
} catch (error) {
core.setFailed(`Failed to fetch pull request repository ID for ${pullRequestOwner}/${pullRequestRepo}: ${getErrorMessage(error)}`);
return;
}
} else {
core.warning(`Invalid pull-request-repo format: ${pullRequestRepoEnv}. Expected owner/repo. PRs will be created in issue repository.`);
}
}

// Cache agent IDs to avoid repeated lookups
const agentCache = {};

Expand Down Expand Up @@ -181,6 +228,58 @@ async function main() {
const hasExplicitTarget = itemForTarget.issue_number != null || itemForTarget.pull_number != null;
const effectiveTarget = hasExplicitTarget ? "*" : targetConfig;

// Handle per-item pull_request_repo parameter (where the PR should be created)
// This overrides the global pull-request-repo configuration if specified
let effectivePullRequestRepoId = pullRequestRepoId;
if (item.pull_request_repo) {
const itemPullRequestRepo = item.pull_request_repo.trim();
const pullRequestRepoParts = itemPullRequestRepo.split("/");
if (pullRequestRepoParts.length === 2) {
// Validate PR repository against allowlist
// The global pull-request-repo (if set) is treated as the default (always allowed)
// allowed-pull-request-repos contains additional allowed repositories
const defaultPullRequestRepo = pullRequestRepoEnv || defaultRepo;
const pullRequestRepoValidation = validateRepo(itemPullRequestRepo, defaultPullRequestRepo, allowedPullRequestRepos);
if (!pullRequestRepoValidation.valid) {
core.error(`E004: ${pullRequestRepoValidation.error}`);
results.push({
issue_number: item.issue_number || null,
pull_number: item.pull_number || null,
agent: agentName,
success: false,
error: pullRequestRepoValidation.error,
});
continue;
}

// Fetch the repository ID for the item's PR repo
try {
const itemPullRequestRepoQuery = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
}
}
`;
const itemPullRequestRepoResponse = await github.graphql(itemPullRequestRepoQuery, { owner: pullRequestRepoParts[0], name: pullRequestRepoParts[1] });
effectivePullRequestRepoId = itemPullRequestRepoResponse.repository.id;
core.info(`Using per-item pull request repository: ${itemPullRequestRepo} (ID: ${effectivePullRequestRepoId})`);
} catch (error) {
core.error(`Failed to fetch pull request repository ID for ${itemPullRequestRepo}: ${getErrorMessage(error)}`);
results.push({
issue_number: item.issue_number || null,
pull_number: item.pull_number || null,
agent: agentName,
success: false,
error: `Failed to fetch pull request repository ID for ${itemPullRequestRepo}`,
});
continue;
}
} else {
core.warning(`Invalid pull_request_repo format: ${itemPullRequestRepo}. Expected owner/repo. Using global pull-request-repo if configured.`);
}
}

// Resolve target number using the same logic as other safe outputs
// This allows automatic resolution from workflow context when issue_number/pull_number is not explicitly provided
const targetResult = resolveTarget({
Expand Down Expand Up @@ -306,8 +405,9 @@ async function main() {

// Assign agent using GraphQL mutation - uses built-in github object authenticated via github-token
// Pass the allowed list so existing assignees are filtered before calling replaceActorsForAssignable
// Pass the PR repo ID if configured (to specify where the PR should be created)
core.info(`Assigning ${agentName} coding agent to ${type} #${number}...`);
const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents);
const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, effectivePullRequestRepoId);

if (!success) {
throw new Error(`Failed to assign ${agentName} via GraphQL`);
Expand Down
178 changes: 178 additions & 0 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ describe("assign_to_agent", () => {
delete process.env.GH_AW_TARGET_REPO;
delete process.env.GH_AW_AGENT_IGNORE_IF_ERROR;
delete process.env.GH_AW_TEMPORARY_ID_MAP;
delete process.env.GH_AW_AGENT_PULL_REQUEST_REPO;
delete process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS;

// Reset context to default
mockContext.eventName = "issues";
Expand Down Expand Up @@ -1144,4 +1146,180 @@ describe("assign_to_agent", () => {
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Looking for copilot coding agent"));
}, 20000);
});

it("should handle pull-request-repo configuration correctly", async () => {
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/pull-request-repo";
// Note: pull-request-repo is automatically allowed, no need to set allowed list
setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: 42,
agent: "copilot",
},
],
errors: [],
});

// Mock GraphQL responses
mockGithub.graphql
// Get PR repository ID
.mockResolvedValueOnce({
repository: {
id: "pull-request-repo-id",
},
})
// Find agent
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "agent-id" }],
},
},
})
// Get issue details
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: { nodes: [] },
},
},
})
// Assign agent with agentAssignment
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/pull-request-repo"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Pull request repository ID: pull-request-repo-id"));

// Verify the mutation was called with agentAssignment
const lastGraphQLCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1];
expect(lastGraphQLCall[0]).toContain("agentAssignment");
expect(lastGraphQLCall[0]).toContain("targetRepositoryId");
expect(lastGraphQLCall[1].targetRepoId).toBe("pull-request-repo-id");
});
Comment on lines +1150 to +1206
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test sets both GH_AW_AGENT_PULL_REQUEST_REPO and GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS to the same repository value (line 1151-1152). This is inconsistent with the test pattern on lines 1105-1147 which verifies that the default repository is allowed even without an allowlist.

If pull-request-repo is meant to follow the same pattern as target-repo (where the configured value is automatically allowed), then this test should verify that pull-request-repo works WITHOUT needing to be listed in allowed-pull-request-repos. The test should only set GH_AW_AGENT_PULL_REQUEST_REPO and not set GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS.

Alternatively, if the intention is that pull-request-repo must be explicitly listed in the allowlist, then:

  1. The comments/documentation claiming "in addition to pull-request-repo" are incorrect
  2. A test should be added to verify that pull-request-repo fails validation if not in the allowlist

This test needs to be updated to match the intended behavior and establish the correct pattern.

Copilot uses AI. Check for mistakes.

it("should handle per-item pull_request_repo parameter", async () => {
// Set global pull-request-repo which will be automatically allowed
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/default-pr-repo";
// Set allowed list for additional repos
process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS = "test-owner/item-pull-request-repo";
setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: 42,
agent: "copilot",
pull_request_repo: "test-owner/item-pull-request-repo",
},
],
errors: [],
});

// Mock GraphQL responses
mockGithub.graphql
// Get global PR repository ID (for default-pr-repo)
.mockResolvedValueOnce({
repository: {
id: "default-pr-repo-id",
},
})
// Get item PR repository ID
.mockResolvedValueOnce({
repository: {
id: "item-pull-request-repo-id",
},
})
// Find agent
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "agent-id" }],
},
},
})
// Get issue details
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: { nodes: [] },
},
},
})
// Assign agent with agentAssignment
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using per-item pull request repository: test-owner/item-pull-request-repo"));

// Verify the mutation was called with per-item PR repo ID
const lastGraphQLCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1];
expect(lastGraphQLCall[1].targetRepoId).toBe("item-pull-request-repo-id");
});

it("should allow pull-request-repo without it being in allowed-pull-request-repos", async () => {
// Set pull-request-repo but DO NOT set allowed-pull-request-repos
// This tests that pull-request-repo is automatically allowed (like target-repo behavior)
process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/auto-allowed-repo";
setAgentOutput({
items: [
{
type: "assign_to_agent",
issue_number: 42,
agent: "copilot",
},
],
errors: [],
});

// Mock GraphQL responses
mockGithub.graphql
// Get PR repository ID
.mockResolvedValueOnce({
repository: {
id: "auto-allowed-repo-id",
},
})
// Find agent
.mockResolvedValueOnce({
repository: {
suggestedActors: {
nodes: [{ login: "copilot-swe-agent", id: "agent-id" }],
},
},
})
// Get issue details
.mockResolvedValueOnce({
repository: {
issue: {
id: "issue-id",
assignees: { nodes: [] },
},
},
})
// Assign agent with agentAssignment
.mockResolvedValueOnce({
replaceActorsForAssignable: {
__typename: "ReplaceActorsForAssignablePayload",
},
});

await eval(`(async () => { ${assignToAgentScript}; await main(); })()`);

// Should succeed - pull-request-repo is automatically allowed
expect(mockCore.setFailed).not.toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/auto-allowed-repo"));
});
});
Loading
Loading