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