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
79 changes: 64 additions & 15 deletions actions/setup/js/assign_agent_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,34 +72,86 @@ function getAgentName(assignee) {

/**
* Return list of coding agent bot login names that are currently available as assignable actors
* in this repository, as determined by checkUserCanBeAssigned.
* in this repository, preferring issue-scoped checks when issue/PR context is available
* and falling back to repository-scoped checks.
* @param {string} owner
* @param {string} repo
* @param {number|string|null} [issueNumber]
* @param {Object} [githubClient] - Authenticated GitHub client (defaults to global github)
* @returns {Promise<string[]>}
*/
async function getAvailableAgentLogins(owner, repo, githubClient = github) {
async function getAvailableAgentLogins(owner, repo, issueNumber = null, githubClient = github) {
// Deduplicate defensively so future alias additions across agents do not duplicate REST lookups.
const knownValues = [...new Set(Object.values(AGENT_LOGIN_NAMES).flat())];
const available = [];
for (const login of knownValues) {
try {
await githubClient.rest.issues.checkUserCanBeAssigned({
owner,
repo,
assignee: login,
});
await validateAssigneeAlias(owner, repo, login, issueNumber, githubClient);
available.push(login);
} catch (e) {
const status = e && typeof e === "object" && "status" in e ? e.status : undefined;
if (status !== 404) {
core.debug(`Failed to check assignability for ${login}: ${getErrorMessage(e)}`);
core.info(`Failed to check assignability for ${login}: ${getErrorMessage(e)}`);
}
}
}
return available.sort();
}

/**
* Validate whether an assignee alias can be assigned in the repository context.
* Prefer issue-level assignability checks when issue/PR number is available because
* some agent bots are not surfaced by repository-scoped checks.
* @param {string} owner
* @param {string} repo
* @param {string} assignee
* @param {number|string|null} issueNumber
* @param {Object} githubClient
*/
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`);
}
Comment thread
pelikhan marked this conversation as resolved.
} 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()`);
}
core.info(`Checking assignee alias ${assignee} via repository-scoped endpoint for ${owner}/${repo}`);
await githubClient.rest.issues.checkUserCanBeAssigned({
owner,
repo,
assignee,
});
core.info(`Assignee alias ${assignee} is assignable via repository-scoped check`);
}

/**
* Return assignable bot logins from the repository assignee list.
* @param {string} owner
Expand Down Expand Up @@ -145,10 +197,11 @@ async function getAssignableBots(owner, repo, githubClient = github) {
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @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<string|null>} Agent ID or null if not found
*/
async function findAgent(owner, repo, agentName, githubClient = github) {
async function findAgent(owner, repo, agentName, issueNumber = null, githubClient = github) {
const loginNames = getAgentLogins(agentName);
if (loginNames.length === 0) {
core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
Expand All @@ -161,11 +214,7 @@ async function findAgent(owner, repo, agentName, githubClient = github) {
for (const loginName of loginNames) {
try {
core.info(`Checking assignee alias: ${loginName}`);
await githubClient.rest.issues.checkUserCanBeAssigned({
owner,
repo,
assignee: loginName,
});
await validateAssigneeAlias(owner, repo, loginName, issueNumber, githubClient);
} catch (checkError) {
const errorMessage = getErrorMessage(checkError);
const status = checkError?.status;
Expand Down Expand Up @@ -536,7 +585,7 @@ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) {
try {
// Find agent using the github object authenticated via step-level github-token
core.info(`Looking for ${agentName} coding agent...`);
const agentId = await findAgent(owner, repo, agentName);
const agentId = await findAgent(owner, repo, agentName, issueNumber);
if (!agentId) {
return { success: false, error: `${agentName} coding agent is not available for this repository` };
}
Expand Down
99 changes: 93 additions & 6 deletions actions/setup/js/assign_agent_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,38 @@ describe("assign_agent_helpers.cjs", () => {
const result = await getAvailableAgentLogins("owner", "repo");

expect(result).toEqual([]);
expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Failed to check assignability for copilot-swe-agent"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Failed to check assignability for copilot-swe-agent"));
expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(5);
});

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(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", {
owner: "owner",
repo: "repo",
issue_number: 123,
assignee: "copilot-swe-agent",
});
expect(mockGithub.request).toHaveBeenCalledTimes(5);
expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(4);
});

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);
});
});
Expand Down Expand Up @@ -210,6 +241,61 @@ describe("assign_agent_helpers.cjs", () => {
expect(result).toBeNull();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Assignee alias copilot-swe-agent was not assignable"));
});

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(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", {
owner: "owner",
repo: "repo",
issue_number: 321,
assignee: "copilot-swe-agent",
});
expect(mockGithub.request).toHaveBeenCalledWith("GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee}", {
owner: "owner",
repo: "repo",
issue_number: 321,
assignee: "github-copilot-enterprise",
});
expect(mockGithub.rest.issues.checkUserCanBeAssigned).toHaveBeenCalledTimes(1);
});

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);

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 });
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();
});
});

describe("getIssueDetails", () => {
Expand Down Expand Up @@ -400,8 +486,8 @@ describe("assign_agent_helpers.cjs", () => {

describe("assignAgentToIssueByName", () => {
it("should successfully assign copilot agent", async () => {
// findAgent: checkUserCanBeAssigned + getByUsername
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValueOnce({});
// findAgent: issue-scoped assignee check + getByUsername
mockGithub.request.mockResolvedValueOnce({ status: 204 });
mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 999 } });
// getIssueDetails
mockGithub.rest.issues.get.mockResolvedValueOnce({
Expand All @@ -427,6 +513,7 @@ 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: [] });

Expand All @@ -438,7 +525,7 @@ describe("assign_agent_helpers.cjs", () => {

it("should report already assigned when agent is in assignees", async () => {
// findAgent
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValueOnce({});
mockGithub.request.mockResolvedValueOnce({ status: 204 });
mockGithub.rest.users.getByUsername.mockResolvedValueOnce({ data: { id: 999 } });
// getIssueDetails - agent already assigned
mockGithub.rest.issues.get.mockResolvedValueOnce({
Expand All @@ -460,7 +547,7 @@ 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
mockGithub.rest.issues.checkUserCanBeAssigned.mockResolvedValueOnce({});
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({
Expand All @@ -477,7 +564,7 @@ describe("assign_agent_helpers.cjs", () => {
const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot");

expect(result.success).toBe(true);
expect(mockGithub.request).not.toHaveBeenCalled();
expect(mockGithub.request).toHaveBeenCalledTimes(1);
expect(mockCore.info).toHaveBeenCalledWith("copilot is already assigned to issue #123");
});
});
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/assign_copilot_to_created_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ async function main() {
// Find agent (reuse cached ID for same repo)
if (!agentId) {
core.info(`Looking for ${agentName} coding agent...`);
agentId = await findAgent(owner, repo, agentName);
agentId = await findAgent(owner, repo, agentName, issueNumber);
if (!agentId) {
throw new Error(`${ERR_PERMISSION}: ${agentName} coding agent is not available for this repository`);
}
Expand Down
16 changes: 8 additions & 8 deletions actions/setup/js/assign_copilot_to_created_issues.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const repo = repoParts[1];

if (!agentId) {
agentId = await findAgent(owner, repo, agentName);
agentId = await findAgent(owner, repo, agentName, issueNumber);
}

const issueDetails = await getIssueDetails(owner, repo, issueNumber);
Expand All @@ -109,15 +109,15 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const repo = repoParts[1];

if (!agentId) {
agentId = await findAgent(owner, repo, agentName);
agentId = await findAgent(owner, repo, agentName, issueNumber);
}

const issueDetails = await getIssueDetails(owner, repo, issueNumber);
await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
}
})()`);

expect(findAgent).toHaveBeenCalledWith("owner", "repo", "copilot");
expect(findAgent).toHaveBeenCalledWith("owner", "repo", "copilot", 123);
expect(getIssueDetails).toHaveBeenCalledWith("owner", "repo", 123);
expect(assignAgentToIssue).toHaveBeenCalledWith("ISSUE_123", "AGENT_456", [], "copilot");
});
Expand Down Expand Up @@ -150,7 +150,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const repo = repoParts[1];

if (!agentId) {
agentId = await findAgent(owner, repo, agentName);
agentId = await findAgent(owner, repo, agentName, issueNumber);
}

const issueDetails = await getIssueDetails(owner, repo, issueNumber);
Expand Down Expand Up @@ -216,7 +216,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const owner = repoParts[0];
const repo = repoParts[1];

const agentId = await findAgent(owner, repo, agentName);
const agentId = await findAgent(owner, repo, agentName, issueNumber);
if (!agentId) {
throw new Error(\`\${agentName} coding agent is not available for this repository\`);
}
Expand Down Expand Up @@ -251,7 +251,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const owner = repoParts[0];
const repo = repoParts[1];

const agentId = await findAgent(owner, repo, agentName);
const agentId = await findAgent(owner, repo, agentName, issueNumber);
const issueDetails = await getIssueDetails(owner, repo, issueNumber);

if (issueDetails.currentAssignees.includes(agentId)) {
Expand Down Expand Up @@ -292,7 +292,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const owner = repoParts[0];
const repo = repoParts[1];

const agentId = await findAgent(owner, repo, agentName);
const agentId = await findAgent(owner, repo, agentName, issueNumber);
const issueDetails = await getIssueDetails(owner, repo, issueNumber);
const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);

Expand Down Expand Up @@ -324,7 +324,7 @@ describe("assign_copilot_to_created_issues.cjs", () => {
const owner = repoParts[0];
const repo = repoParts[1];

const agentId = await findAgent(owner, repo, agentName);
const agentId = await findAgent(owner, repo, agentName, issueNumber);
await getIssueDetails(owner, repo, issueNumber);
}
})()`);
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ async function main(config = {}) {
let agentId = agentCache[agentName];
if (!agentId) {
core.info(`Looking for ${agentName} coding agent...`);
agentId = await findAgent(effectiveOwner, effectiveRepo, agentName, githubClient);
agentId = await findAgent(effectiveOwner, effectiveRepo, agentName, issueNumber || pullNumber, githubClient);
if (!agentId) {
throw new Error(`${agentName} coding agent is not available for this repository`);
}
Expand Down Expand Up @@ -405,7 +405,7 @@ async function main(config = {}) {

if (isAvailabilityError) {
try {
const available = await getAvailableAgentLogins(effectiveOwner, effectiveRepo, githubClient);
const available = await getAvailableAgentLogins(effectiveOwner, effectiveRepo, issueNumber || pullNumber, githubClient);
if (available.length > 0) errorMessage += ` (available agents: ${available.join(", ")})`;
} catch (e) {
core.debug("Failed to enrich unavailable agent message with available list");
Expand Down
Loading