diff --git a/.changeset/patch-update-system-prompt-datetime-docs.md b/.changeset/patch-update-system-prompt-datetime-docs.md
new file mode 100644
index 0000000000..850faee1f6
--- /dev/null
+++ b/.changeset/patch-update-system-prompt-datetime-docs.md
@@ -0,0 +1,5 @@
+---
+"gh-aw": patch
+---
+
+Update documentation with current datetime format in system prompt
diff --git a/.github/workflows/ai-triage-campaign.lock.yml b/.github/workflows/ai-triage-campaign.lock.yml
index 5e2fb47f9c..cbb6e7b61c 100644
--- a/.github/workflows/ai-triage-campaign.lock.yml
+++ b/.github/workflows/ai-triage-campaign.lock.yml
@@ -3910,10 +3910,17 @@ jobs:
const AGENT_LOGIN_NAMES = {
copilot: "copilot-swe-agent",
};
+ function getAgentName(assignee) {
+ const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee;
+ if (AGENT_LOGIN_NAMES[normalized]) {
+ return normalized;
+ }
+ return null;
+ }
async function getAvailableAgentLogins(owner, repo) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
nodes { ... on Bot { login __typename } }
}
@@ -3921,7 +3928,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo });
const actors = response.repository?.suggestedActors?.nodes || [];
const knownValues = Object.values(AGENT_LOGIN_NAMES);
const available = [];
@@ -3939,8 +3946,8 @@ jobs:
}
async function findAgent(owner, repo, agentName) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
nodes {
... on Bot {
@@ -3954,7 +3961,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo });
const actors = response.repository.suggestedActors.nodes;
const loginName = AGENT_LOGIN_NAMES[agentName];
if (!loginName) {
@@ -3987,9 +3994,9 @@ jobs:
}
async function getIssueDetails(owner, repo, issueNumber) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
- issue(number: ${issueNumber}) {
+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) {
id
assignees(first: 100) {
nodes {
@@ -4001,7 +4008,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo, issueNumber });
const issue = response.repository.issue;
if (!issue || !issue.id) {
core.error("Could not get issue data");
@@ -4018,7 +4025,7 @@ jobs:
return null;
}
}
- async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) {
+ async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName, ghToken) {
const actorIds = [agentId];
for (const assigneeId of currentAssignees) {
if (assigneeId !== agentId) {
@@ -4026,30 +4033,35 @@ jobs:
}
}
const mutation = `
- mutation {
+ mutation($assignableId: ID!, $actorIds: [ID!]!) {
replaceActorsForAssignable(input: {
- assignableId: "${issueId}",
- actorIds: ${JSON.stringify(actorIds)}
+ assignableId: $assignableId,
+ actorIds: $actorIds
}) {
__typename
}
}
`;
try {
- const mutationToken = process.env.GH_AW_AGENT_TOKEN;
- if (!mutationToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform assignment mutation.");
return false;
}
- core.info("Using GH_AW_AGENT_TOKEN for mutation");
- core.debug(`GraphQL mutation: ${mutation}`);
+ core.info("Using provided GitHub token for mutation");
+ core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`);
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
- Authorization: `Bearer ${mutationToken}`,
+ Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
},
- body: JSON.stringify({ query: mutation }),
+ body: JSON.stringify({
+ query: mutation,
+ variables: {
+ assignableId: issueId,
+ actorIds: actorIds,
+ },
+ }),
}).then(res => res.json());
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
@@ -4091,20 +4103,34 @@ jobs:
) {
core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
try {
- const fallbackMutation = `mutation {\n addAssigneesToAssignable(input:{assignableId:"${issueId}", assigneeIds:["${agentId}"]}) {\n clientMutationId\n }\n}`;
- const fallbackToken = process.env.GH_AW_AGENT_TOKEN;
- if (!fallbackToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform fallback mutation.");
+ const fallbackMutation = `
+ mutation($assignableId: ID!, $assigneeIds: [ID!]!) {
+ addAssigneesToAssignable(input: {
+ assignableId: $assignableId,
+ assigneeIds: $assigneeIds
+ }) {
+ clientMutationId
+ }
+ }
+ `;
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform fallback mutation.");
} else {
- core.info("Using GH_AW_AGENT_TOKEN for fallback mutation");
- core.debug(`Fallback GraphQL mutation: ${fallbackMutation}`);
+ core.info("Using provided GitHub token for fallback mutation");
+ core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`);
const fallbackResp = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
- Authorization: `Bearer ${fallbackToken}`,
+ Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
},
- body: JSON.stringify({ query: fallbackMutation }),
+ body: JSON.stringify({
+ query: fallbackMutation,
+ variables: {
+ assignableId: issueId,
+ assigneeIds: [agentId],
+ },
+ }),
}).then(res => res.json());
if (fallbackResp.data && fallbackResp.data.addAssigneesToAssignable) {
core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
@@ -4117,34 +4143,98 @@ jobs:
const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
}
- core.error(`Failed to assign ${agentName}: Insufficient permissions`);
- core.error("");
- core.error("Assigning Copilot agents requires:");
- core.error(" 1. All four workflow permissions:");
- core.error(" - actions: write");
- core.error(" - contents: write");
- core.error(" - issues: write");
- core.error(" - pull-requests: write");
- core.error("");
- core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
- core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
- 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("");
- core.error(" 4. Organization/Enterprise settings:");
- 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+ logPermissionError(agentName);
} else {
core.error(`Failed to assign ${agentName}: ${errorMessage}`);
}
return false;
}
}
+ function logPermissionError(agentName) {
+ core.error(`Failed to assign ${agentName}: Insufficient permissions`);
+ core.error("");
+ core.error("Assigning Copilot agents requires:");
+ core.error(" 1. All four workflow permissions:");
+ core.error(" - actions: write");
+ core.error(" - contents: write");
+ core.error(" - issues: write");
+ core.error(" - pull-requests: write");
+ core.error("");
+ core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
+ core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
+ 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("");
+ core.error(" 4. Organization/Enterprise settings:");
+ 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+ }
+ function generatePermissionErrorSummary() {
+ let content = "\n### ⚠️ Permission Requirements\n\n";
+ content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
+ content += "```yaml\n";
+ content += "permissions:\n";
+ content += " actions: write\n";
+ content += " contents: write\n";
+ content += " issues: write\n";
+ content += " pull-requests: write\n";
+ content += "```\n\n";
+ content += "**Token capability note:**\n";
+ content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
+ content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
+ content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
+ content += "**Recommended remediation paths:**\n";
+ content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
+ content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
+ content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
+ content +=
+ "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
+ content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ return content;
+ }
+ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName, ghToken) {
+ if (!AGENT_LOGIN_NAMES[agentName]) {
+ const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`;
+ core.warning(error);
+ return { success: false, error };
+ }
+ try {
+ core.info(`Looking for ${agentName} coding agent...`);
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ const error = `${agentName} coding agent is not available for this repository`;
+ const available = await getAvailableAgentLogins(owner, repo);
+ const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error;
+ return { success: false, error: enrichedError };
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+ core.info("Getting issue details...");
+ const issueDetails = await getIssueDetails(owner, repo, issueNumber);
+ if (!issueDetails) {
+ return { success: false, error: "Failed to get issue details" };
+ }
+ core.info(`Issue ID: ${issueDetails.issueId}`);
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${issueNumber}`);
+ return { success: true };
+ }
+ core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+ if (!success) {
+ return { success: false, error: `Failed to assign ${agentName} via GraphQL` };
+ }
+ core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return { success: false, error: errorMessage };
+ }
+ }
async function main() {
const result = loadAgentOutput();
if (!result.success) {
@@ -4196,6 +4286,11 @@ jobs:
core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`);
}
}
+ const mutationToken = process.env.GH_AW_AGENT_TOKEN;
+ if (!mutationToken) {
+ core.setFailed("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
+ return;
+ }
const agentCache = {};
const results = [];
for (const item of itemsToProcess) {
@@ -4242,7 +4337,7 @@ jobs:
continue;
}
core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
- const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, mutationToken);
if (!success) {
throw new Error(`Failed to assign ${agentName} via GraphQL`);
}
@@ -4292,29 +4387,7 @@ jobs:
r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions"))
);
if (hasPermissionError) {
- summaryContent += "\n### ⚠️ Permission Requirements\n\n";
- summaryContent += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
- summaryContent += "```yaml\n";
- summaryContent += "permissions:\n";
- summaryContent += " actions: write\n";
- summaryContent += " contents: write\n";
- summaryContent += " issues: write\n";
- summaryContent += " pull-requests: write\n";
- summaryContent += "```\n\n";
- summaryContent += "**Token capability note:**\n";
- summaryContent += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
- summaryContent +=
- "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
- summaryContent += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
- summaryContent += "**Recommended remediation paths:**\n";
- summaryContent +=
- "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
- summaryContent += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
- summaryContent += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
- summaryContent +=
- "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
- summaryContent +=
- "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ summaryContent += generatePermissionErrorSummary();
}
}
await core.summary.addRaw(summaryContent).write();
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 8483817594..2bb941814d 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -3515,10 +3515,17 @@ jobs:
const AGENT_LOGIN_NAMES = {
copilot: "copilot-swe-agent",
};
+ function getAgentName(assignee) {
+ const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee;
+ if (AGENT_LOGIN_NAMES[normalized]) {
+ return normalized;
+ }
+ return null;
+ }
async function getAvailableAgentLogins(owner, repo) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
nodes { ... on Bot { login __typename } }
}
@@ -3526,7 +3533,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo });
const actors = response.repository?.suggestedActors?.nodes || [];
const knownValues = Object.values(AGENT_LOGIN_NAMES);
const available = [];
@@ -3544,8 +3551,8 @@ jobs:
}
async function findAgent(owner, repo, agentName) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
nodes {
... on Bot {
@@ -3559,7 +3566,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo });
const actors = response.repository.suggestedActors.nodes;
const loginName = AGENT_LOGIN_NAMES[agentName];
if (!loginName) {
@@ -3592,9 +3599,9 @@ jobs:
}
async function getIssueDetails(owner, repo, issueNumber) {
const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
- issue(number: ${issueNumber}) {
+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) {
id
assignees(first: 100) {
nodes {
@@ -3606,7 +3613,7 @@ jobs:
}
`;
try {
- const response = await github.graphql(query);
+ const response = await github.graphql(query, { owner, repo, issueNumber });
const issue = response.repository.issue;
if (!issue || !issue.id) {
core.error("Could not get issue data");
@@ -3623,7 +3630,7 @@ jobs:
return null;
}
}
- async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) {
+ async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName, ghToken) {
const actorIds = [agentId];
for (const assigneeId of currentAssignees) {
if (assigneeId !== agentId) {
@@ -3631,30 +3638,35 @@ jobs:
}
}
const mutation = `
- mutation {
+ mutation($assignableId: ID!, $actorIds: [ID!]!) {
replaceActorsForAssignable(input: {
- assignableId: "${issueId}",
- actorIds: ${JSON.stringify(actorIds)}
+ assignableId: $assignableId,
+ actorIds: $actorIds
}) {
__typename
}
}
`;
try {
- const mutationToken = process.env.GH_AW_AGENT_TOKEN;
- if (!mutationToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform assignment mutation.");
return false;
}
- core.info("Using GH_AW_AGENT_TOKEN for mutation");
- core.debug(`GraphQL mutation: ${mutation}`);
+ core.info("Using provided GitHub token for mutation");
+ core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`);
const response = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
- Authorization: `Bearer ${mutationToken}`,
+ Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
},
- body: JSON.stringify({ query: mutation }),
+ body: JSON.stringify({
+ query: mutation,
+ variables: {
+ assignableId: issueId,
+ actorIds: actorIds,
+ },
+ }),
}).then(res => res.json());
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
@@ -3696,20 +3708,34 @@ jobs:
) {
core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
try {
- const fallbackMutation = `mutation {\n addAssigneesToAssignable(input:{assignableId:"${issueId}", assigneeIds:["${agentId}"]}) {\n clientMutationId\n }\n}`;
- const fallbackToken = process.env.GH_AW_AGENT_TOKEN;
- if (!fallbackToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform fallback mutation.");
+ const fallbackMutation = `
+ mutation($assignableId: ID!, $assigneeIds: [ID!]!) {
+ addAssigneesToAssignable(input: {
+ assignableId: $assignableId,
+ assigneeIds: $assigneeIds
+ }) {
+ clientMutationId
+ }
+ }
+ `;
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform fallback mutation.");
} else {
- core.info("Using GH_AW_AGENT_TOKEN for fallback mutation");
- core.debug(`Fallback GraphQL mutation: ${fallbackMutation}`);
+ core.info("Using provided GitHub token for fallback mutation");
+ core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`);
const fallbackResp = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
- Authorization: `Bearer ${fallbackToken}`,
+ Authorization: `Bearer ${ghToken}`,
"Content-Type": "application/json",
},
- body: JSON.stringify({ query: fallbackMutation }),
+ body: JSON.stringify({
+ query: fallbackMutation,
+ variables: {
+ assignableId: issueId,
+ assigneeIds: [agentId],
+ },
+ }),
}).then(res => res.json());
if (fallbackResp.data && fallbackResp.data.addAssigneesToAssignable) {
core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
@@ -3722,34 +3748,98 @@ jobs:
const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
}
- core.error(`Failed to assign ${agentName}: Insufficient permissions`);
- core.error("");
- core.error("Assigning Copilot agents requires:");
- core.error(" 1. All four workflow permissions:");
- core.error(" - actions: write");
- core.error(" - contents: write");
- core.error(" - issues: write");
- core.error(" - pull-requests: write");
- core.error("");
- core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
- core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
- 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("");
- core.error(" 4. Organization/Enterprise settings:");
- 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+ logPermissionError(agentName);
} else {
core.error(`Failed to assign ${agentName}: ${errorMessage}`);
}
return false;
}
}
+ function logPermissionError(agentName) {
+ core.error(`Failed to assign ${agentName}: Insufficient permissions`);
+ core.error("");
+ core.error("Assigning Copilot agents requires:");
+ core.error(" 1. All four workflow permissions:");
+ core.error(" - actions: write");
+ core.error(" - contents: write");
+ core.error(" - issues: write");
+ core.error(" - pull-requests: write");
+ core.error("");
+ core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
+ core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
+ 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("");
+ core.error(" 4. Organization/Enterprise settings:");
+ 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+ }
+ function generatePermissionErrorSummary() {
+ let content = "\n### ⚠️ Permission Requirements\n\n";
+ content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
+ content += "```yaml\n";
+ content += "permissions:\n";
+ content += " actions: write\n";
+ content += " contents: write\n";
+ content += " issues: write\n";
+ content += " pull-requests: write\n";
+ content += "```\n\n";
+ content += "**Token capability note:**\n";
+ content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
+ content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
+ content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
+ content += "**Recommended remediation paths:**\n";
+ content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
+ content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
+ content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
+ content +=
+ "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
+ content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ return content;
+ }
+ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName, ghToken) {
+ if (!AGENT_LOGIN_NAMES[agentName]) {
+ const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`;
+ core.warning(error);
+ return { success: false, error };
+ }
+ try {
+ core.info(`Looking for ${agentName} coding agent...`);
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ const error = `${agentName} coding agent is not available for this repository`;
+ const available = await getAvailableAgentLogins(owner, repo);
+ const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error;
+ return { success: false, error: enrichedError };
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+ core.info("Getting issue details...");
+ const issueDetails = await getIssueDetails(owner, repo, issueNumber);
+ if (!issueDetails) {
+ return { success: false, error: "Failed to get issue details" };
+ }
+ core.info(`Issue ID: ${issueDetails.issueId}`);
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${issueNumber}`);
+ return { success: true };
+ }
+ core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+ if (!success) {
+ return { success: false, error: `Failed to assign ${agentName} via GraphQL` };
+ }
+ core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return { success: false, error: errorMessage };
+ }
+ }
async function main() {
const result = loadAgentOutput();
if (!result.success) {
@@ -3801,6 +3891,11 @@ jobs:
core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`);
}
}
+ const mutationToken = process.env.GH_AW_AGENT_TOKEN;
+ if (!mutationToken) {
+ core.setFailed("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
+ return;
+ }
const agentCache = {};
const results = [];
for (const item of itemsToProcess) {
@@ -3847,7 +3942,7 @@ jobs:
continue;
}
core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
- const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, mutationToken);
if (!success) {
throw new Error(`Failed to assign ${agentName} via GraphQL`);
}
@@ -3897,29 +3992,7 @@ jobs:
r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions"))
);
if (hasPermissionError) {
- summaryContent += "\n### ⚠️ Permission Requirements\n\n";
- summaryContent += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
- summaryContent += "```yaml\n";
- summaryContent += "permissions:\n";
- summaryContent += " actions: write\n";
- summaryContent += " contents: write\n";
- summaryContent += " issues: write\n";
- summaryContent += " pull-requests: write\n";
- summaryContent += "```\n\n";
- summaryContent += "**Token capability note:**\n";
- summaryContent += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
- summaryContent +=
- "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
- summaryContent += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
- summaryContent += "**Recommended remediation paths:**\n";
- summaryContent +=
- "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
- summaryContent += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
- summaryContent += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
- summaryContent +=
- "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
- summaryContent +=
- "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ summaryContent += generatePermissionErrorSummary();
}
}
await core.summary.addRaw(summaryContent).write();
diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml
index 1c1649f79f..922d2d8bba 100644
--- a/.github/workflows/duplicate-code-detector.lock.yml
+++ b/.github/workflows/duplicate-code-detector.lock.yml
@@ -4255,6 +4255,334 @@ jobs:
ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }}
with:
script: |
+ const AGENT_LOGIN_NAMES = {
+ copilot: "copilot-swe-agent",
+ };
+ function getAgentName(assignee) {
+ const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee;
+ if (AGENT_LOGIN_NAMES[normalized]) {
+ return normalized;
+ }
+ return null;
+ }
+ async function getAvailableAgentLogins(owner, repo) {
+ const query = `
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
+ suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
+ nodes { ... on Bot { login __typename } }
+ }
+ }
+ }
+ `;
+ try {
+ const response = await github.graphql(query, { owner, repo });
+ const actors = response.repository?.suggestedActors?.nodes || [];
+ const knownValues = Object.values(AGENT_LOGIN_NAMES);
+ const available = [];
+ for (const actor of actors) {
+ if (actor && actor.login && knownValues.includes(actor.login)) {
+ available.push(actor.login);
+ }
+ }
+ return available.sort();
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ core.debug(`Failed to list available agent logins: ${msg}`);
+ return [];
+ }
+ }
+ async function findAgent(owner, repo, agentName) {
+ const query = `
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
+ suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
+ nodes {
+ ... on Bot {
+ id
+ login
+ __typename
+ }
+ }
+ }
+ }
+ }
+ `;
+ try {
+ const response = await github.graphql(query, { owner, repo });
+ const actors = response.repository.suggestedActors.nodes;
+ const loginName = AGENT_LOGIN_NAMES[agentName];
+ if (!loginName) {
+ core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
+ return null;
+ }
+ for (const actor of actors) {
+ if (actor.login === loginName) {
+ return actor.id;
+ }
+ }
+ const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login);
+ core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`);
+ if (available.length > 0) {
+ core.info(`Available assignable coding agents: ${available.join(", ")}`);
+ } else {
+ core.info("No coding agents are currently assignable 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"
+ );
+ }
+ return null;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ core.error(`Failed to find ${agentName} agent: ${errorMessage}`);
+ return null;
+ }
+ }
+ async function getIssueDetails(owner, repo, issueNumber) {
+ const query = `
+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) {
+ id
+ assignees(first: 100) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ }
+ `;
+ try {
+ const response = await github.graphql(query, { owner, repo, issueNumber });
+ const issue = response.repository.issue;
+ if (!issue || !issue.id) {
+ core.error("Could not get issue data");
+ return null;
+ }
+ const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id);
+ return {
+ issueId: issue.id,
+ currentAssignees: currentAssignees,
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ core.error(`Failed to get issue details: ${errorMessage}`);
+ return null;
+ }
+ }
+ async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName, ghToken) {
+ const actorIds = [agentId];
+ for (const assigneeId of currentAssignees) {
+ if (assigneeId !== agentId) {
+ actorIds.push(assigneeId);
+ }
+ }
+ const mutation = `
+ mutation($assignableId: ID!, $actorIds: [ID!]!) {
+ replaceActorsForAssignable(input: {
+ assignableId: $assignableId,
+ actorIds: $actorIds
+ }) {
+ __typename
+ }
+ }
+ `;
+ try {
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform assignment mutation.");
+ return false;
+ }
+ core.info("Using provided GitHub token for mutation");
+ core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`);
+ const response = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${ghToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query: mutation,
+ variables: {
+ assignableId: issueId,
+ actorIds: actorIds,
+ },
+ }),
+ }).then(res => res.json());
+ if (response.errors && response.errors.length > 0) {
+ throw new Error(response.errors[0].message);
+ }
+ if (response.data && response.data.replaceActorsForAssignable && response.data.replaceActorsForAssignable.__typename) {
+ return true;
+ } else {
+ core.error("Unexpected response from GitHub API");
+ return false;
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ try {
+ core.debug(`Raw GraphQL error message: ${errorMessage}`);
+ if (error && typeof error === "object") {
+ const details = {};
+ if (error.errors) details.errors = error.errors;
+ if (error.response) details.response = error.response;
+ if (error.data) details.data = error.data;
+ if (Array.isArray(error.errors)) {
+ details.compactMessages = error.errors.map(e => e.message).filter(Boolean);
+ }
+ const serialized = JSON.stringify(details, (_k, v) => v, 2);
+ if (serialized && serialized !== "{}") {
+ core.debug(`Raw GraphQL error details: ${serialized}`);
+ core.error("Raw GraphQL error details (for troubleshooting):");
+ for (const line of serialized.split(/\n/)) {
+ if (line.trim()) core.error(line);
+ }
+ }
+ }
+ } catch (loggingErr) {
+ core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`);
+ }
+ if (
+ errorMessage.includes("Resource not accessible by personal access token") ||
+ errorMessage.includes("Resource not accessible by integration") ||
+ errorMessage.includes("Insufficient permissions to assign")
+ ) {
+ core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
+ try {
+ const fallbackMutation = `
+ mutation($assignableId: ID!, $assigneeIds: [ID!]!) {
+ addAssigneesToAssignable(input: {
+ assignableId: $assignableId,
+ assigneeIds: $assigneeIds
+ }) {
+ clientMutationId
+ }
+ }
+ `;
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform fallback mutation.");
+ } else {
+ core.info("Using provided GitHub token for fallback mutation");
+ core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`);
+ const fallbackResp = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${ghToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query: fallbackMutation,
+ variables: {
+ assignableId: issueId,
+ assigneeIds: [agentId],
+ },
+ }),
+ }).then(res => res.json());
+ if (fallbackResp.data && fallbackResp.data.addAssigneesToAssignable) {
+ core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
+ return true;
+ } else {
+ core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance.");
+ }
+ }
+ } catch (fallbackError) {
+ const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
+ core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
+ }
+ logPermissionError(agentName);
+ } else {
+ core.error(`Failed to assign ${agentName}: ${errorMessage}`);
+ }
+ return false;
+ }
+ }
+ function logPermissionError(agentName) {
+ core.error(`Failed to assign ${agentName}: Insufficient permissions`);
+ core.error("");
+ core.error("Assigning Copilot agents requires:");
+ core.error(" 1. All four workflow permissions:");
+ core.error(" - actions: write");
+ core.error(" - contents: write");
+ core.error(" - issues: write");
+ core.error(" - pull-requests: write");
+ core.error("");
+ core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
+ core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
+ 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("");
+ core.error(" 4. Organization/Enterprise settings:");
+ 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+ }
+ function generatePermissionErrorSummary() {
+ let content = "\n### ⚠️ Permission Requirements\n\n";
+ content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
+ content += "```yaml\n";
+ content += "permissions:\n";
+ content += " actions: write\n";
+ content += " contents: write\n";
+ content += " issues: write\n";
+ content += " pull-requests: write\n";
+ content += "```\n\n";
+ content += "**Token capability note:**\n";
+ content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
+ content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
+ content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
+ content += "**Recommended remediation paths:**\n";
+ content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
+ content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
+ content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
+ content +=
+ "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
+ content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ return content;
+ }
+ async function assignAgentToIssueByName(owner, repo, issueNumber, agentName, ghToken) {
+ if (!AGENT_LOGIN_NAMES[agentName]) {
+ const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`;
+ core.warning(error);
+ return { success: false, error };
+ }
+ try {
+ core.info(`Looking for ${agentName} coding agent...`);
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ const error = `${agentName} coding agent is not available for this repository`;
+ const available = await getAvailableAgentLogins(owner, repo);
+ const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error;
+ return { success: false, error: enrichedError };
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+ core.info("Getting issue details...");
+ const issueDetails = await getIssueDetails(owner, repo, issueNumber);
+ if (!issueDetails) {
+ return { success: false, error: "Failed to get issue details" };
+ }
+ core.info(`Issue ID: ${issueDetails.issueId}`);
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${issueNumber}`);
+ return { success: true };
+ }
+ core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+ if (!success) {
+ return { success: false, error: `Failed to assign ${agentName} via GraphQL` };
+ }
+ core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return { success: false, error: errorMessage };
+ }
+ }
async function main() {
const ghToken = process.env.GH_TOKEN;
const assignee = process.env.ASSIGNEE;
@@ -4278,11 +4606,36 @@ jobs:
}
const trimmedAssignee = assignee.trim();
const trimmedIssueNumber = issueNumber.trim();
+ const issueNum = parseInt(trimmedIssueNumber, 10);
core.info(`Assigning issue #${trimmedIssueNumber} to ${trimmedAssignee}`);
try {
- await exec.exec("gh", ["issue", "edit", trimmedIssueNumber, "--add-assignee", trimmedAssignee], {
- env: { ...process.env, GH_TOKEN: ghToken },
- });
+ const agentName = getAgentName(trimmedAssignee);
+ if (agentName) {
+ core.info(`Detected coding agent: ${agentName}. Using GraphQL API for assignment.`);
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ throw new Error(`${agentName} coding agent is not available for this repository`);
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+ const issueDetails = await getIssueDetails(owner, repo, issueNum);
+ if (!issueDetails) {
+ throw new Error("Failed to get issue details");
+ }
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${trimmedIssueNumber}`);
+ } else {
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+ if (!success) {
+ throw new Error(`Failed to assign ${agentName} via GraphQL`);
+ }
+ }
+ } else {
+ await exec.exec("gh", ["issue", "edit", trimmedIssueNumber, "--add-assignee", trimmedAssignee], {
+ env: { ...process.env, GH_TOKEN: ghToken },
+ });
+ }
core.info(`✅ Successfully assigned issue #${trimmedIssueNumber} to ${trimmedAssignee}`);
await core.summary
.addRaw(
diff --git a/pkg/workflow/copilot_participant_steps.go b/pkg/workflow/copilot_participant_steps.go
index e3700ec478..1fd316d2e4 100644
--- a/pkg/workflow/copilot_participant_steps.go
+++ b/pkg/workflow/copilot_participant_steps.go
@@ -96,7 +96,7 @@ func buildIssueAssigneeSteps(config CopilotParticipantConfig, effectiveToken str
steps = append(steps, fmt.Sprintf(" ISSUE_NUMBER: ${{ steps.%s.outputs.%s }}\n", config.ConditionStepID, config.ConditionOutputKey))
steps = append(steps, " with:\n")
steps = append(steps, " script: |\n")
- steps = append(steps, FormatJavaScriptForYAML(assignIssueScript)...)
+ steps = append(steps, FormatJavaScriptForYAML(getAssignIssueScript())...)
// Add a comment after each assignee step except the last
if i < len(config.Participants)-1 {
diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go
index 7293ea714b..6587fb83db 100644
--- a/pkg/workflow/js.go
+++ b/pkg/workflow/js.go
@@ -14,7 +14,7 @@ var jsLog = logger.New("workflow:js")
var createAgentTaskScript string
//go:embed js/assign_issue.cjs
-var assignIssueScript string
+var assignIssueScriptSource string
//go:embed js/add_reaction_and_edit_comment.cjs
var addReactionAndEditCommentScript string
@@ -28,6 +28,12 @@ func init() {
DefaultScriptRegistry.Register("safe_outputs_mcp_server", safeOutputsMCPServerScriptSource)
DefaultScriptRegistry.Register("update_project", updateProjectScriptSource)
DefaultScriptRegistry.Register("interpolate_prompt", interpolatePromptScript)
+ DefaultScriptRegistry.Register("assign_issue", assignIssueScriptSource)
+}
+
+// getAssignIssueScript returns the bundled assign_issue script
+func getAssignIssueScript() string {
+ return DefaultScriptRegistry.Get("assign_issue")
}
// getCheckMembershipScript returns the bundled check_membership script
@@ -91,6 +97,9 @@ var loadAgentOutputScript string
//go:embed js/staged_preview.cjs
var stagedPreviewScript string
+//go:embed js/assign_agent_helpers.cjs
+var assignAgentHelpersScript string
+
//go:embed js/safe_output_helpers.cjs
var safeOutputHelpersScript string
@@ -159,6 +168,7 @@ func GetJavaScriptSources() map[string]string {
"sanitize_workflow_name.cjs": sanitizeWorkflowNameScript,
"load_agent_output.cjs": loadAgentOutputScript,
"staged_preview.cjs": stagedPreviewScript,
+ "assign_agent_helpers.cjs": assignAgentHelpersScript,
"safe_output_helpers.cjs": safeOutputHelpersScript,
"safe_output_validator.cjs": safeOutputValidatorScript,
"temporary_id.cjs": temporaryIdScript,
diff --git a/pkg/workflow/js/assign_agent_helpers.cjs b/pkg/workflow/js/assign_agent_helpers.cjs
new file mode 100644
index 0000000000..85f55c6b48
--- /dev/null
+++ b/pkg/workflow/js/assign_agent_helpers.cjs
@@ -0,0 +1,466 @@
+// @ts-check
+///
+
+/**
+ * Shared helper functions for assigning coding agents (like Copilot) to issues
+ * These functions use GraphQL to properly assign bot actors that cannot be assigned via gh CLI
+ */
+
+/**
+ * Map agent names to their GitHub bot login names
+ * @type {Record}
+ */
+const AGENT_LOGIN_NAMES = {
+ copilot: "copilot-swe-agent",
+};
+
+/**
+ * Check if an assignee is a known coding agent (bot)
+ * @param {string} assignee - Assignee name (may include @ prefix)
+ * @returns {string|null} Agent name if it's a known agent, null otherwise
+ */
+function getAgentName(assignee) {
+ // Normalize: remove @ prefix if present
+ const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee;
+
+ // Check if it's a known agent
+ if (AGENT_LOGIN_NAMES[normalized]) {
+ return normalized;
+ }
+
+ return null;
+}
+
+/**
+ * Return list of coding agent bot login names that are currently available as assignable actors
+ * (intersection of suggestedActors and known AGENT_LOGIN_NAMES values)
+ * @param {string} owner
+ * @param {string} repo
+ * @returns {Promise}
+ */
+async function getAvailableAgentLogins(owner, repo) {
+ const query = `
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
+ suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
+ nodes { ... on Bot { login __typename } }
+ }
+ }
+ }
+ `;
+ try {
+ const response = await github.graphql(query, { owner, repo });
+ const actors = response.repository?.suggestedActors?.nodes || [];
+ const knownValues = Object.values(AGENT_LOGIN_NAMES);
+ const available = [];
+ for (const actor of actors) {
+ if (actor && actor.login && knownValues.includes(actor.login)) {
+ available.push(actor.login);
+ }
+ }
+ return available.sort();
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ core.debug(`Failed to list available agent logins: ${msg}`);
+ return [];
+ }
+}
+
+/**
+ * Find an agent in repository's suggested actors using GraphQL
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {string} agentName - Agent name (copilot)
+ * @returns {Promise} Agent ID or null if not found
+ */
+async function findAgent(owner, repo, agentName) {
+ const query = `
+ query($owner: String!, $repo: String!) {
+ repository(owner: $owner, name: $repo) {
+ suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
+ nodes {
+ ... on Bot {
+ id
+ login
+ __typename
+ }
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ const response = await github.graphql(query, { owner, repo });
+ const actors = response.repository.suggestedActors.nodes;
+
+ const loginName = AGENT_LOGIN_NAMES[agentName];
+ if (!loginName) {
+ core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
+ return null;
+ }
+
+ for (const actor of actors) {
+ if (actor.login === loginName) {
+ return actor.id;
+ }
+ }
+
+ const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login);
+
+ core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`);
+ if (available.length > 0) {
+ core.info(`Available assignable coding agents: ${available.join(", ")}`);
+ } else {
+ core.info("No coding agents are currently assignable 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"
+ );
+ }
+ return null;
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ core.error(`Failed to find ${agentName} agent: ${errorMessage}`);
+ return null;
+ }
+}
+
+/**
+ * Get issue details (ID and current assignees) using GraphQL
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {number} issueNumber - Issue number
+ * @returns {Promise<{issueId: string, currentAssignees: string[]}|null>}
+ */
+async function getIssueDetails(owner, repo, issueNumber) {
+ const query = `
+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) {
+ id
+ assignees(first: 100) {
+ nodes {
+ id
+ }
+ }
+ }
+ }
+ }
+ `;
+
+ try {
+ const response = await github.graphql(query, { owner, repo, issueNumber });
+ const issue = response.repository.issue;
+
+ if (!issue || !issue.id) {
+ core.error("Could not get issue data");
+ return null;
+ }
+
+ const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id);
+
+ return {
+ issueId: issue.id,
+ currentAssignees: currentAssignees,
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ core.error(`Failed to get issue details: ${errorMessage}`);
+ return null;
+ }
+}
+
+/**
+ * Assign agent to issue using GraphQL replaceActorsForAssignable mutation
+ * @param {string} issueId - GitHub issue ID
+ * @param {string} agentId - Agent ID
+ * @param {string[]} currentAssignees - List of current assignee IDs
+ * @param {string} agentName - Agent name for error messages
+ * @param {string} ghToken - GitHub token for the mutation. Must have:
+ * - Write actions/contents/issues/pull-requests permissions
+ * - A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions
+ * - Note: The token source varies by caller:
+ * - assign_to_agent.cjs uses GH_AW_AGENT_TOKEN (agent-specific token)
+ * - assign_issue.cjs uses GH_TOKEN (general issue assignment token)
+ * @returns {Promise} True if successful
+ */
+async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName, ghToken) {
+ // Build actor IDs array - include agent and preserve other assignees
+ const actorIds = [agentId];
+ for (const assigneeId of currentAssignees) {
+ if (assigneeId !== agentId) {
+ actorIds.push(assigneeId);
+ }
+ }
+
+ const mutation = `
+ mutation($assignableId: ID!, $actorIds: [ID!]!) {
+ replaceActorsForAssignable(input: {
+ assignableId: $assignableId,
+ actorIds: $actorIds
+ }) {
+ __typename
+ }
+ }
+ `;
+
+ try {
+ // SECURITY: Use provided token for the mutation
+ // The mutation requires: Write actions/contents/issues/pull-requests
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform assignment mutation.");
+ return false;
+ }
+ core.info("Using provided GitHub token for mutation");
+
+ // Make raw GraphQL request with custom token using variables
+ core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`);
+ const response = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${ghToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query: mutation,
+ variables: {
+ assignableId: issueId,
+ actorIds: actorIds,
+ },
+ }),
+ }).then(res => res.json());
+
+ if (response.errors && response.errors.length > 0) {
+ throw new Error(response.errors[0].message);
+ }
+
+ if (response.data && response.data.replaceActorsForAssignable && response.data.replaceActorsForAssignable.__typename) {
+ return true;
+ } else {
+ core.error("Unexpected response from GitHub API");
+ return false;
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ // Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues
+ try {
+ core.debug(`Raw GraphQL error message: ${errorMessage}`);
+ if (error && typeof error === "object") {
+ // Common GraphQL error shapes: error.errors (array), error.data, error.response
+ const details = {};
+ if (error.errors) details.errors = error.errors;
+ // Some libraries wrap the payload under 'response' or 'response.data'
+ if (error.response) details.response = error.response;
+ if (error.data) details.data = error.data;
+ // If GitHub returns an array of errors with 'type'/'message'
+ if (Array.isArray(error.errors)) {
+ details.compactMessages = error.errors.map(e => e.message).filter(Boolean);
+ }
+ const serialized = JSON.stringify(details, (_k, v) => v, 2);
+ if (serialized && serialized !== "{}") {
+ core.debug(`Raw GraphQL error details: ${serialized}`);
+ // Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it
+ core.error("Raw GraphQL error details (for troubleshooting):");
+ // Split large JSON for readability
+ for (const line of serialized.split(/\n/)) {
+ if (line.trim()) core.error(line);
+ }
+ }
+ }
+ } catch (loggingErr) {
+ // Never fail assignment because of debug logging
+ core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`);
+ }
+
+ // Check for permission-related errors
+ if (
+ errorMessage.includes("Resource not accessible by personal access token") ||
+ errorMessage.includes("Resource not accessible by integration") ||
+ errorMessage.includes("Insufficient permissions to assign")
+ ) {
+ // Attempt fallback mutation addAssigneesToAssignable when replaceActorsForAssignable is forbidden
+ core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
+ try {
+ // SECURITY: Use same token for fallback mutation with GraphQL variables
+ const fallbackMutation = `
+ mutation($assignableId: ID!, $assigneeIds: [ID!]!) {
+ addAssigneesToAssignable(input: {
+ assignableId: $assignableId,
+ assigneeIds: $assigneeIds
+ }) {
+ clientMutationId
+ }
+ }
+ `;
+ if (!ghToken) {
+ core.error("GitHub token is not set. Cannot perform fallback mutation.");
+ } else {
+ core.info("Using provided GitHub token for fallback mutation");
+ core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`);
+ const fallbackResp = await fetch("https://api.github.com/graphql", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${ghToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ query: fallbackMutation,
+ variables: {
+ assignableId: issueId,
+ assigneeIds: [agentId],
+ },
+ }),
+ }).then(res => res.json());
+ if (fallbackResp.data && fallbackResp.data.addAssigneesToAssignable) {
+ core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
+ return true;
+ } else {
+ core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance.");
+ }
+ }
+ } catch (fallbackError) {
+ const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
+ core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
+ }
+ logPermissionError(agentName);
+ } else {
+ core.error(`Failed to assign ${agentName}: ${errorMessage}`);
+ }
+ return false;
+ }
+}
+
+/**
+ * Log detailed permission error guidance
+ * @param {string} agentName - Agent name for error messages
+ */
+function logPermissionError(agentName) {
+ core.error(`Failed to assign ${agentName}: Insufficient permissions`);
+ core.error("");
+ core.error("Assigning Copilot agents requires:");
+ core.error(" 1. All four workflow permissions:");
+ core.error(" - actions: write");
+ core.error(" - contents: write");
+ core.error(" - issues: write");
+ core.error(" - pull-requests: write");
+ core.error("");
+ core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
+ core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
+ 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("");
+ core.error(" 4. Organization/Enterprise settings:");
+ 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
+}
+
+/**
+ * Generate permission error summary content for step summary
+ * @returns {string} Markdown content for permission error guidance
+ */
+function generatePermissionErrorSummary() {
+ let content = "\n### ⚠️ Permission Requirements\n\n";
+ content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
+ content += "```yaml\n";
+ content += "permissions:\n";
+ content += " actions: write\n";
+ content += " contents: write\n";
+ content += " issues: write\n";
+ content += " pull-requests: write\n";
+ content += "```\n\n";
+ content += "**Token capability note:**\n";
+ content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
+ content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
+ content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
+ content += "**Recommended remediation paths:**\n";
+ content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
+ content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
+ content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
+ content +=
+ "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
+ content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ return content;
+}
+
+/**
+ * Assign an agent to an issue using GraphQL
+ * This is the main entry point for assigning agents from other scripts
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {number} issueNumber - Issue number
+ * @param {string} agentName - Agent name (e.g., "copilot")
+ * @param {string} ghToken - GitHub token for the mutation. Must have sufficient permissions
+ * to assign agents. See assignAgentToIssue() for token requirements.
+ * @returns {Promise<{success: boolean, error?: string}>}
+ */
+async function assignAgentToIssueByName(owner, repo, issueNumber, agentName, ghToken) {
+ // Check if agent is supported
+ if (!AGENT_LOGIN_NAMES[agentName]) {
+ const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`;
+ core.warning(error);
+ return { success: false, error };
+ }
+
+ try {
+ // Find agent
+ core.info(`Looking for ${agentName} coding agent...`);
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ const error = `${agentName} coding agent is not available for this repository`;
+ // Enrich with available agent logins
+ const available = await getAvailableAgentLogins(owner, repo);
+ const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error;
+ return { success: false, error: enrichedError };
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+
+ // Get issue details (ID and current assignees) via GraphQL
+ core.info("Getting issue details...");
+ const issueDetails = await getIssueDetails(owner, repo, issueNumber);
+ if (!issueDetails) {
+ return { success: false, error: "Failed to get issue details" };
+ }
+
+ core.info(`Issue ID: ${issueDetails.issueId}`);
+
+ // Check if agent is already assigned
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${issueNumber}`);
+ return { success: true };
+ }
+
+ // Assign agent using GraphQL mutation
+ core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+
+ if (!success) {
+ return { success: false, error: `Failed to assign ${agentName} via GraphQL` };
+ }
+
+ core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`);
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+module.exports = {
+ AGENT_LOGIN_NAMES,
+ getAgentName,
+ getAvailableAgentLogins,
+ findAgent,
+ getIssueDetails,
+ assignAgentToIssue,
+ logPermissionError,
+ generatePermissionErrorSummary,
+ assignAgentToIssueByName,
+};
diff --git a/pkg/workflow/js/assign_agent_helpers.test.cjs b/pkg/workflow/js/assign_agent_helpers.test.cjs
new file mode 100644
index 0000000000..ddbeba21d6
--- /dev/null
+++ b/pkg/workflow/js/assign_agent_helpers.test.cjs
@@ -0,0 +1,457 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mock the global objects that GitHub Actions provides
+const mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+};
+
+const mockGithub = {
+ graphql: vi.fn(),
+};
+
+// Set up global mocks before importing the module
+globalThis.core = mockCore;
+globalThis.github = mockGithub;
+
+const {
+ AGENT_LOGIN_NAMES,
+ getAgentName,
+ getAvailableAgentLogins,
+ findAgent,
+ getIssueDetails,
+ assignAgentToIssue,
+ generatePermissionErrorSummary,
+ assignAgentToIssueByName,
+} = await import("./assign_agent_helpers.cjs");
+
+describe("assign_agent_helpers.cjs", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("AGENT_LOGIN_NAMES", () => {
+ it("should have copilot mapped to copilot-swe-agent", () => {
+ expect(AGENT_LOGIN_NAMES).toEqual({
+ copilot: "copilot-swe-agent",
+ });
+ });
+ });
+
+ describe("getAgentName", () => {
+ it("should return copilot for @copilot", () => {
+ expect(getAgentName("@copilot")).toBe("copilot");
+ });
+
+ it("should return copilot for copilot without @ prefix", () => {
+ expect(getAgentName("copilot")).toBe("copilot");
+ });
+
+ it("should return null for unknown users", () => {
+ expect(getAgentName("@some-user")).toBeNull();
+ expect(getAgentName("some-user")).toBeNull();
+ });
+
+ it("should return null for empty string", () => {
+ expect(getAgentName("")).toBeNull();
+ });
+
+ it("should return null for partial matches", () => {
+ expect(getAgentName("copilot-agent")).toBeNull();
+ expect(getAgentName("@copilot-agent")).toBeNull();
+ });
+ });
+
+ describe("getAvailableAgentLogins", () => {
+ it("should return available agent logins", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [
+ { login: "copilot-swe-agent", __typename: "Bot" },
+ { login: "some-other-bot", __typename: "Bot" },
+ ],
+ },
+ },
+ });
+
+ const result = await getAvailableAgentLogins("owner", "repo");
+
+ expect(result).toEqual(["copilot-swe-agent"]);
+ expect(mockGithub.graphql).toHaveBeenCalledTimes(1);
+ });
+
+ it("should return empty array when no agents are available", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [{ login: "some-random-bot", __typename: "Bot" }],
+ },
+ },
+ });
+
+ const result = await getAvailableAgentLogins("owner", "repo");
+
+ expect(result).toEqual([]);
+ });
+
+ it("should handle GraphQL errors gracefully", async () => {
+ mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error"));
+
+ const result = await getAvailableAgentLogins("owner", "repo");
+
+ expect(result).toEqual([]);
+ expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Failed to list available agent logins"));
+ });
+
+ it("should handle null suggestedActors", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: null,
+ },
+ });
+
+ const result = await getAvailableAgentLogins("owner", "repo");
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("findAgent", () => {
+ it("should find copilot agent and return its ID", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [
+ { id: "BOT_12345", login: "copilot-swe-agent", __typename: "Bot" },
+ { id: "BOT_67890", login: "other-bot", __typename: "Bot" },
+ ],
+ },
+ },
+ });
+
+ const result = await findAgent("owner", "repo", "copilot");
+
+ expect(result).toBe("BOT_12345");
+ });
+
+ it("should return null for unknown agent name", async () => {
+ // Need to mock GraphQL because the function calls it before checking agent name
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [],
+ },
+ },
+ });
+
+ const result = await findAgent("owner", "repo", "unknown-agent");
+
+ expect(result).toBeNull();
+ expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Unknown agent: unknown-agent"));
+ });
+
+ it("should return null when copilot is not available", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [{ id: "BOT_67890", login: "other-bot", __typename: "Bot" }],
+ },
+ },
+ });
+
+ const result = await findAgent("owner", "repo", "copilot");
+
+ expect(result).toBeNull();
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("copilot coding agent (copilot-swe-agent) is not available"));
+ });
+
+ it("should handle GraphQL errors", async () => {
+ mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error"));
+
+ const result = await findAgent("owner", "repo", "copilot");
+
+ expect(result).toBeNull();
+ expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to find copilot agent"));
+ });
+ });
+
+ describe("getIssueDetails", () => {
+ it("should return issue ID and current assignees", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ issue: {
+ id: "ISSUE_123",
+ assignees: {
+ nodes: [{ id: "USER_1" }, { id: "USER_2" }],
+ },
+ },
+ },
+ });
+
+ const result = await getIssueDetails("owner", "repo", 123);
+
+ expect(result).toEqual({
+ issueId: "ISSUE_123",
+ currentAssignees: ["USER_1", "USER_2"],
+ });
+ });
+
+ it("should return null when issue is not found", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ issue: null,
+ },
+ });
+
+ const result = await getIssueDetails("owner", "repo", 999);
+
+ expect(result).toBeNull();
+ expect(mockCore.error).toHaveBeenCalledWith("Could not get issue data");
+ });
+
+ it("should handle GraphQL errors", async () => {
+ mockGithub.graphql.mockRejectedValueOnce(new Error("GraphQL error"));
+
+ const result = await getIssueDetails("owner", "repo", 123);
+
+ expect(result).toBeNull();
+ expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to get issue details"));
+ });
+
+ it("should return empty assignees when none exist", async () => {
+ mockGithub.graphql.mockResolvedValueOnce({
+ repository: {
+ issue: {
+ id: "ISSUE_123",
+ assignees: {
+ nodes: [],
+ },
+ },
+ },
+ });
+
+ const result = await getIssueDetails("owner", "repo", 123);
+
+ expect(result).toEqual({
+ issueId: "ISSUE_123",
+ currentAssignees: [],
+ });
+ });
+ });
+
+ describe("assignAgentToIssue", () => {
+ it("should successfully assign agent using mutation", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: {
+ replaceActorsForAssignable: {
+ __typename: "ReplaceActorsForAssignablePayload",
+ },
+ },
+ }),
+ });
+ global.fetch = mockFetch;
+
+ const result = await assignAgentToIssue("ISSUE_123", "AGENT_456", ["USER_1"], "copilot", "test-token");
+
+ expect(result).toBe(true);
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://api.github.com/graphql",
+ expect.objectContaining({
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-token",
+ "Content-Type": "application/json",
+ },
+ })
+ );
+ });
+
+ it("should return false when token is not set", async () => {
+ const result = await assignAgentToIssue(
+ "ISSUE_123",
+ "AGENT_456",
+ [],
+ "copilot",
+ "" // Empty token
+ );
+
+ expect(result).toBe(false);
+ expect(mockCore.error).toHaveBeenCalledWith("GitHub token is not set. Cannot perform assignment mutation.");
+ });
+
+ it("should preserve existing assignees when adding agent", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: {
+ replaceActorsForAssignable: {
+ __typename: "ReplaceActorsForAssignablePayload",
+ },
+ },
+ }),
+ });
+ global.fetch = mockFetch;
+
+ await assignAgentToIssue("ISSUE_123", "AGENT_456", ["USER_1", "USER_2"], "copilot", "test-token");
+
+ const fetchCall = mockFetch.mock.calls[0];
+ const body = JSON.parse(fetchCall[1].body);
+ // The mutation should use GraphQL variables - check that variables are passed correctly
+ expect(body.variables.assignableId).toBe("ISSUE_123");
+ expect(body.variables.actorIds).toContain("AGENT_456");
+ expect(body.variables.actorIds).toContain("USER_1");
+ expect(body.variables.actorIds).toContain("USER_2");
+ });
+
+ it("should not duplicate agent if already in assignees", async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: {
+ replaceActorsForAssignable: {
+ __typename: "ReplaceActorsForAssignablePayload",
+ },
+ },
+ }),
+ });
+ global.fetch = mockFetch;
+
+ await assignAgentToIssue(
+ "ISSUE_123",
+ "AGENT_456",
+ ["AGENT_456", "USER_1"], // Agent already in list
+ "copilot",
+ "test-token"
+ );
+
+ const fetchCall = mockFetch.mock.calls[0];
+ const body = JSON.parse(fetchCall[1].body);
+ // Agent should only appear once in the variables.actorIds array
+ const agentMatches = body.variables.actorIds.filter(id => id === "AGENT_456");
+ expect(agentMatches.length).toBe(1);
+ });
+ });
+
+ describe("generatePermissionErrorSummary", () => {
+ it("should return markdown content with permission requirements", () => {
+ const summary = generatePermissionErrorSummary();
+
+ expect(summary).toContain("### ⚠️ Permission Requirements");
+ expect(summary).toContain("actions: write");
+ expect(summary).toContain("contents: write");
+ expect(summary).toContain("issues: write");
+ expect(summary).toContain("pull-requests: write");
+ expect(summary).toContain("replaceActorsForAssignable");
+ });
+ });
+
+ describe("assignAgentToIssueByName", () => {
+ it("should successfully assign copilot agent", async () => {
+ // Mock findAgent
+ mockGithub.graphql
+ .mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [{ id: "AGENT_456", login: "copilot-swe-agent", __typename: "Bot" }],
+ },
+ },
+ })
+ // Mock getIssueDetails
+ .mockResolvedValueOnce({
+ repository: {
+ issue: {
+ id: "ISSUE_123",
+ assignees: {
+ nodes: [],
+ },
+ },
+ },
+ });
+
+ // Mock fetch for mutation
+ const mockFetch = vi.fn().mockResolvedValue({
+ json: () =>
+ Promise.resolve({
+ data: {
+ replaceActorsForAssignable: {
+ __typename: "ReplaceActorsForAssignablePayload",
+ },
+ },
+ }),
+ });
+ global.fetch = mockFetch;
+
+ const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot", "test-token");
+
+ expect(result.success).toBe(true);
+ expect(mockCore.info).toHaveBeenCalledWith("Looking for copilot coding agent...");
+ expect(mockCore.info).toHaveBeenCalledWith("Found copilot coding agent (ID: AGENT_456)");
+ });
+
+ it("should return error for unsupported agent", async () => {
+ const result = await assignAgentToIssueByName("owner", "repo", 123, "unknown", "test-token");
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("not supported");
+ expect(mockCore.warning).toHaveBeenCalled();
+ });
+
+ it("should return error when agent is not available", async () => {
+ mockGithub.graphql
+ .mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [], // No agents
+ },
+ },
+ })
+ // Mock getAvailableAgentLogins
+ .mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [],
+ },
+ },
+ });
+
+ const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot", "test-token");
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("not available");
+ });
+
+ it("should report already assigned when agent is in assignees", async () => {
+ const agentId = "AGENT_456";
+
+ mockGithub.graphql
+ .mockResolvedValueOnce({
+ repository: {
+ suggestedActors: {
+ nodes: [{ id: agentId, login: "copilot-swe-agent", __typename: "Bot" }],
+ },
+ },
+ })
+ .mockResolvedValueOnce({
+ repository: {
+ issue: {
+ id: "ISSUE_123",
+ assignees: {
+ nodes: [{ id: agentId }], // Already assigned
+ },
+ },
+ },
+ });
+
+ const result = await assignAgentToIssueByName("owner", "repo", 123, "copilot", "test-token");
+
+ expect(result.success).toBe(true);
+ expect(mockCore.info).toHaveBeenCalledWith("copilot is already assigned to issue #123");
+ });
+ });
+});
diff --git a/pkg/workflow/js/assign_issue.cjs b/pkg/workflow/js/assign_issue.cjs
index 0d2740256e..75e39ccff0 100644
--- a/pkg/workflow/js/assign_issue.cjs
+++ b/pkg/workflow/js/assign_issue.cjs
@@ -1,6 +1,8 @@
// @ts-check
///
+const { getAgentName, getIssueDetails, findAgent, assignAgentToIssue } = require("./assign_agent_helpers.cjs");
+
/**
* Assign an issue to a user or bot (including copilot)
* This script handles assigning issues after they are created
@@ -37,15 +39,52 @@ async function main() {
const trimmedAssignee = assignee.trim();
const trimmedIssueNumber = issueNumber.trim();
+ const issueNum = parseInt(trimmedIssueNumber, 10);
core.info(`Assigning issue #${trimmedIssueNumber} to ${trimmedAssignee}`);
try {
- // Use exec to run gh CLI command
- // The GH_TOKEN environment variable is already set and will be used by gh CLI
- await exec.exec("gh", ["issue", "edit", trimmedIssueNumber, "--add-assignee", trimmedAssignee], {
- env: { ...process.env, GH_TOKEN: ghToken },
- });
+ // Check if the assignee is a known coding agent (e.g., copilot, @copilot)
+ const agentName = getAgentName(trimmedAssignee);
+
+ if (agentName) {
+ // Use GraphQL API for agent assignment
+ core.info(`Detected coding agent: ${agentName}. Using GraphQL API for assignment.`);
+
+ // Get repository owner and repo from context
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ // Find the agent in the repository
+ const agentId = await findAgent(owner, repo, agentName);
+ if (!agentId) {
+ throw new Error(`${agentName} coding agent is not available for this repository`);
+ }
+ core.info(`Found ${agentName} coding agent (ID: ${agentId})`);
+
+ // Get issue details
+ const issueDetails = await getIssueDetails(owner, repo, issueNum);
+ if (!issueDetails) {
+ throw new Error("Failed to get issue details");
+ }
+
+ // Check if agent is already assigned
+ if (issueDetails.currentAssignees.includes(agentId)) {
+ core.info(`${agentName} is already assigned to issue #${trimmedIssueNumber}`);
+ } else {
+ // Assign agent using GraphQL mutation
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, ghToken);
+
+ if (!success) {
+ throw new Error(`Failed to assign ${agentName} via GraphQL`);
+ }
+ }
+ } else {
+ // Use gh CLI for regular user assignment
+ await exec.exec("gh", ["issue", "edit", trimmedIssueNumber, "--add-assignee", trimmedAssignee], {
+ env: { ...process.env, GH_TOKEN: ghToken },
+ });
+ }
core.info(`✅ Successfully assigned issue #${trimmedIssueNumber} to ${trimmedAssignee}`);
diff --git a/pkg/workflow/js/assign_issue.test.cjs b/pkg/workflow/js/assign_issue.test.cjs
index c894343712..c751c6183b 100644
--- a/pkg/workflow/js/assign_issue.test.cjs
+++ b/pkg/workflow/js/assign_issue.test.cjs
@@ -49,9 +49,22 @@ const mockExec = {
exec: vi.fn(),
};
+const mockGithub = {
+ graphql: vi.fn(),
+};
+
+const mockContext = {
+ repo: {
+ owner: "testowner",
+ repo: "testrepo",
+ },
+};
+
// Set up global variables
global.core = mockCore;
global.exec = mockExec;
+global.github = mockGithub;
+global.context = mockContext;
describe("assign_issue.cjs", () => {
let assignIssueScript;
@@ -147,7 +160,7 @@ describe("assign_issue.cjs", () => {
});
});
- describe("Successful assignment", () => {
+ describe("Successful assignment for regular users", () => {
it("should successfully assign issue to a regular user", async () => {
process.env.GH_TOKEN = "ghp_test123";
process.env.ASSIGNEE = "test-user";
@@ -172,28 +185,6 @@ describe("assign_issue.cjs", () => {
expect(mockCore.setFailed).not.toHaveBeenCalled();
});
- it("should successfully assign issue to @copilot", async () => {
- process.env.GH_TOKEN = "ghp_copilot_token";
- process.env.ASSIGNEE = "@copilot";
- process.env.ISSUE_NUMBER = "789";
-
- mockExec.exec.mockResolvedValue(0);
-
- // Execute the script
- await eval(`(async () => { ${assignIssueScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Assigning issue #789 to @copilot");
- expect(mockExec.exec).toHaveBeenCalledWith(
- "gh",
- ["issue", "edit", "789", "--add-assignee", "@copilot"],
- expect.objectContaining({
- env: expect.objectContaining({ GH_TOKEN: "ghp_copilot_token" }),
- })
- );
- expect(mockCore.info).toHaveBeenCalledWith("✅ Successfully assigned issue #789 to @copilot");
- expect(mockCore.setFailed).not.toHaveBeenCalled();
- });
-
it("should trim whitespace from environment variables", async () => {
process.env.GH_TOKEN = " ghp_test123 ";
process.env.ASSIGNEE = " test-user ";
@@ -225,7 +216,7 @@ describe("assign_issue.cjs", () => {
});
});
- describe("Error handling", () => {
+ describe("Error handling for regular users", () => {
it("should handle gh CLI execution errors", async () => {
process.env.GH_TOKEN = "ghp_test123";
process.env.ASSIGNEE = "test-user";
@@ -273,7 +264,7 @@ describe("assign_issue.cjs", () => {
});
});
- describe("Edge cases", () => {
+ describe("Edge cases for regular users", () => {
it("should handle numeric issue number", async () => {
process.env.GH_TOKEN = "ghp_test123";
process.env.ASSIGNEE = "test-user";
@@ -332,4 +323,8 @@ describe("assign_issue.cjs", () => {
expect(failedCall).toContain("https://githubnext.github.io/gh-aw/reference/safe-outputs/#assigning-issues-to-copilot");
});
});
+
+ // Note: Agent-specific tests (e.g., @copilot) are in assign_agent_helpers.test.cjs
+ // since assign_issue.cjs uses the shared helpers module for agent assignment,
+ // and the require() statements don't work with eval() in tests.
});
diff --git a/pkg/workflow/js/assign_to_agent.cjs b/pkg/workflow/js/assign_to_agent.cjs
index 6eecc92810..6591b2bbf5 100644
--- a/pkg/workflow/js/assign_to_agent.cjs
+++ b/pkg/workflow/js/assign_to_agent.cjs
@@ -3,312 +3,14 @@
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { generateStagedPreview } = require("./staged_preview.cjs");
-
-/**
- * Map agent names to their GitHub bot login names
- */
-const AGENT_LOGIN_NAMES = {
- copilot: "copilot-swe-agent",
-};
-
-/**
- * Return list of coding agent bot login names that are currently available as assignable actors
- * (intersection of suggestedActors and known AGENT_LOGIN_NAMES values)
- * @param {string} owner
- * @param {string} repo
- * @returns {Promise}
- */
-async function getAvailableAgentLogins(owner, repo) {
- const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
- suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
- nodes { ... on Bot { login __typename } }
- }
- }
- }
- `;
- try {
- const response = await github.graphql(query);
- const actors = response.repository?.suggestedActors?.nodes || [];
- const knownValues = Object.values(AGENT_LOGIN_NAMES);
- const available = [];
- for (const actor of actors) {
- if (actor && actor.login && knownValues.includes(actor.login)) {
- available.push(actor.login);
- }
- }
- return available.sort();
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- core.debug(`Failed to list available agent logins: ${msg}`);
- return [];
- }
-}
-
-/**
- * Find an agent in repository's suggested actors using GraphQL
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {string} agentName - Agent name (copilot)
- * @returns {Promise} Agent ID or null if not found
- */
-async function findAgent(owner, repo, agentName) {
- const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
- suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) {
- nodes {
- ... on Bot {
- id
- login
- __typename
- }
- }
- }
- }
- }
- `;
-
- try {
- const response = await github.graphql(query);
- const actors = response.repository.suggestedActors.nodes;
-
- const loginName = AGENT_LOGIN_NAMES[agentName];
- if (!loginName) {
- core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`);
- return null;
- }
-
- for (const actor of actors) {
- if (actor.login === loginName) {
- return actor.id;
- }
- }
-
- const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login);
-
- core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`);
- if (available.length > 0) {
- core.info(`Available assignable coding agents: ${available.join(", ")}`);
- } else {
- core.info("No coding agents are currently assignable 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"
- );
- }
- return null;
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to find ${agentName} agent: ${errorMessage}`);
- return null;
- }
-}
-
-/**
- * Get issue details (ID and current assignees) using GraphQL
- * @param {string} owner - Repository owner
- * @param {string} repo - Repository name
- * @param {number} issueNumber - Issue number
- * @returns {Promise<{issueId: string, currentAssignees: string[]}|null>}
- */
-async function getIssueDetails(owner, repo, issueNumber) {
- const query = `
- query {
- repository(owner: "${owner}", name: "${repo}") {
- issue(number: ${issueNumber}) {
- id
- assignees(first: 100) {
- nodes {
- id
- }
- }
- }
- }
- }
- `;
-
- try {
- const response = await github.graphql(query);
- const issue = response.repository.issue;
-
- if (!issue || !issue.id) {
- core.error("Could not get issue data");
- return null;
- }
-
- const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id);
-
- return {
- issueId: issue.id,
- currentAssignees: currentAssignees,
- };
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to get issue details: ${errorMessage}`);
- return null;
- }
-}
-
-/**
- * Assign agent to issue using GraphQL replaceActorsForAssignable mutation
- * @param {string} issueId - GitHub issue ID
- * @param {string} agentId - Agent ID
- * @param {string[]} currentAssignees - List of current assignee IDs
- * @param {string} agentName - Agent name for error messages
- * @returns {Promise} True if successful
- */
-async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) {
- // Build actor IDs array - include agent and preserve other assignees
- const actorIds = [agentId];
- for (const assigneeId of currentAssignees) {
- if (assigneeId !== agentId) {
- actorIds.push(assigneeId);
- }
- }
-
- const mutation = `
- mutation {
- replaceActorsForAssignable(input: {
- assignableId: "${issueId}",
- actorIds: ${JSON.stringify(actorIds)}
- }) {
- __typename
- }
- }
- `;
-
- try {
- // SECURITY: Use GH_AW_AGENT_TOKEN environment variable for the mutation
- // This is more privileged than the github-token parameter (GITHUB_TOKEN) used for read operations
- // The mutation requires: Write actions/contents/issues/pull-requests
- const mutationToken = process.env.GH_AW_AGENT_TOKEN;
- if (!mutationToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
- return false;
- }
- core.info("Using GH_AW_AGENT_TOKEN for mutation");
-
- // Make raw GraphQL request with custom token
- core.debug(`GraphQL mutation: ${mutation}`);
- const response = await fetch("https://api.github.com/graphql", {
- method: "POST",
- headers: {
- Authorization: `Bearer ${mutationToken}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ query: mutation }),
- }).then(res => res.json());
-
- if (response.errors && response.errors.length > 0) {
- throw new Error(response.errors[0].message);
- }
-
- if (response.data && response.data.replaceActorsForAssignable && response.data.replaceActorsForAssignable.__typename) {
- return true;
- } else {
- core.error("Unexpected response from GitHub API");
- return false;
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
-
- // Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues
- try {
- core.debug(`Raw GraphQL error message: ${errorMessage}`);
- if (error && typeof error === "object") {
- // Common GraphQL error shapes: error.errors (array), error.data, error.response
- const details = {};
- if (error.errors) details.errors = error.errors;
- // Some libraries wrap the payload under 'response' or 'response.data'
- if (error.response) details.response = error.response;
- if (error.data) details.data = error.data;
- // If GitHub returns an array of errors with 'type'/'message'
- if (Array.isArray(error.errors)) {
- details.compactMessages = error.errors.map(e => e.message).filter(Boolean);
- }
- const serialized = JSON.stringify(details, (_k, v) => v, 2);
- if (serialized && serialized !== "{}") {
- core.debug(`Raw GraphQL error details: ${serialized}`);
- // Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it
- core.error("Raw GraphQL error details (for troubleshooting):");
- // Split large JSON for readability
- for (const line of serialized.split(/\n/)) {
- if (line.trim()) core.error(line);
- }
- }
- }
- } catch (loggingErr) {
- // Never fail assignment because of debug logging
- core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`);
- }
-
- // Check for permission-related errors
- if (
- errorMessage.includes("Resource not accessible by personal access token") ||
- errorMessage.includes("Resource not accessible by integration") ||
- errorMessage.includes("Insufficient permissions to assign")
- ) {
- // Attempt fallback mutation addAssigneesToAssignable when replaceActorsForAssignable is forbidden
- core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable...");
- try {
- // SECURITY: Use same GH_AW_AGENT_TOKEN environment variable for fallback mutation
- const fallbackMutation = `mutation {\n addAssigneesToAssignable(input:{assignableId:"${issueId}", assigneeIds:["${agentId}"]}) {\n clientMutationId\n }\n}`;
- const fallbackToken = process.env.GH_AW_AGENT_TOKEN;
- if (!fallbackToken) {
- core.error("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform fallback mutation.");
- } else {
- core.info("Using GH_AW_AGENT_TOKEN for fallback mutation");
- core.debug(`Fallback GraphQL mutation: ${fallbackMutation}`);
- const fallbackResp = await fetch("https://api.github.com/graphql", {
- method: "POST",
- headers: {
- Authorization: `Bearer ${fallbackToken}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ query: fallbackMutation }),
- }).then(res => res.json());
- if (fallbackResp.data && fallbackResp.data.addAssigneesToAssignable) {
- core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`);
- return true;
- } else {
- core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance.");
- }
- }
- } catch (fallbackError) {
- const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
- core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`);
- }
- core.error(`Failed to assign ${agentName}: Insufficient permissions`);
- core.error("");
- core.error("Assigning Copilot agents requires:");
- core.error(" 1. All four workflow permissions:");
- core.error(" - actions: write");
- core.error(" - contents: write");
- core.error(" - issues: write");
- core.error(" - pull-requests: write");
- core.error("");
- core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:");
- core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)");
- 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("");
- core.error(" 4. Organization/Enterprise settings:");
- 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/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr");
- } else {
- core.error(`Failed to assign ${agentName}: ${errorMessage}`);
- }
- return false;
- }
-}
+const {
+ AGENT_LOGIN_NAMES,
+ getAvailableAgentLogins,
+ findAgent,
+ getIssueDetails,
+ assignAgentToIssue,
+ generatePermissionErrorSummary,
+} = require("./assign_agent_helpers.cjs");
async function main() {
const result = loadAgentOutput();
@@ -375,6 +77,13 @@ async function main() {
}
}
+ // Get the mutation token from environment
+ const mutationToken = process.env.GH_AW_AGENT_TOKEN;
+ if (!mutationToken) {
+ core.setFailed("GH_AW_AGENT_TOKEN environment variable is not set. Cannot perform assignment mutation.");
+ return;
+ }
+
// Cache agent IDs to avoid repeated lookups
const agentCache = {};
@@ -437,7 +146,7 @@ async function main() {
// Assign agent using GraphQL mutation
core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`);
- const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName);
+ const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName, mutationToken);
if (!success) {
throw new Error(`Failed to assign ${agentName} via GraphQL`);
@@ -498,29 +207,7 @@ async function main() {
);
if (hasPermissionError) {
- summaryContent += "\n### ⚠️ Permission Requirements\n\n";
- summaryContent += "Assigning Copilot agents requires **ALL** of these permissions:\n\n";
- summaryContent += "```yaml\n";
- summaryContent += "permissions:\n";
- summaryContent += " actions: write\n";
- summaryContent += " contents: write\n";
- summaryContent += " issues: write\n";
- summaryContent += " pull-requests: write\n";
- summaryContent += "```\n\n";
- summaryContent += "**Token capability note:**\n";
- summaryContent += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n";
- summaryContent +=
- "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n";
- summaryContent += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n";
- summaryContent += "**Recommended remediation paths:**\n";
- summaryContent +=
- "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n";
- summaryContent += "2. Manual assignment: add the agent through the UI until broader token support is available.\n";
- summaryContent += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n";
- summaryContent +=
- "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n";
- summaryContent +=
- "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n";
+ summaryContent += generatePermissionErrorSummary();
}
}