diff --git a/.changeset/patch-add-assign-to-user-safe-output.md b/.changeset/patch-add-assign-to-user-safe-output.md new file mode 100644 index 0000000000..070bd51b12 --- /dev/null +++ b/.changeset/patch-add-assign-to-user-safe-output.md @@ -0,0 +1,8 @@ +--- +"gh-aw": patch +--- + +Add `assign-to-user` safe output type and supporting files (schemas, Go structs, JS implementation, tests, and docs). + +This change adds a new safe output `assign-to-user` analogous to `assign-to-agent`, including parser schema, job builder, JavaScript runner script, and tests. It is an internal addition and does not change public CLI APIs. + diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index 3e357edc72..9438843340 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -193,8 +193,8 @@ # https://github.com/actions/download-artifact/commit/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # - actions/github-script@v8 (ed597411d8f924073f98dfc5c65a23a2325f34cd) # https://github.com/actions/github-script/commit/ed597411d8f924073f98dfc5c65a23a2325f34cd -# - actions/setup-node@v6 (2028fbc5c25fe9cf00d9f06a71cc4710d4507903) -# https://github.com/actions/setup-node/commit/2028fbc5c25fe9cf00d9f06a71cc4710d4507903 +# - actions/setup-node@v6 (395ad3262231945c25e8478fd5baf05154b1d79f) +# https://github.com/actions/setup-node/commit/395ad3262231945c25e8478fd5baf05154b1d79f # - actions/upload-artifact@v5 (330a01c490aca151604b8cf639adc76d48f6c5d4) # https://github.com/actions/upload-artifact/commit/330a01c490aca151604b8cf639adc76d48f6c5d4 @@ -405,7 +405,7 @@ jobs: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_CLI_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: node-version: '24' package-manager-cache: false @@ -5873,7 +5873,7 @@ jobs: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} COPILOT_CLI_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 with: node-version: '24' package-manager-cache: false diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 490a6a2acd..cbc3193181 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -18,26 +18,27 @@ # gh aw compile # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # -# Find issues with "[deps]" in title and assign to Copilot agent +# Find an open issue and assign it to mrjf # # Original Frontmatter: # ```yaml # on: # workflow_dispatch: # name: Dev -# description: Find issues with "[deps]" in title and assign to Copilot agent +# description: Find an open issue and assign it to mrjf # timeout-minutes: 5 # strict: false # engine: claude # permissions: # contents: read -# issues: read +# issues: write # tools: # github: # toolsets: [repos, issues] # safe-outputs: -# assign-to-agent: -# name: copilot +# assign-to-user: +# allowed: [mrjf] +# target: "*" # ``` # # Job Dependency Graph: @@ -45,45 +46,47 @@ # graph LR # activation["activation"] # agent["agent"] -# assign_to_agent["assign_to_agent"] +# assign_to_user["assign_to_user"] # conclusion["conclusion"] # detection["detection"] # activation --> agent -# agent --> assign_to_agent -# detection --> assign_to_agent +# agent --> assign_to_user +# detection --> assign_to_user # agent --> conclusion # activation --> conclusion -# assign_to_agent --> conclusion +# assign_to_user --> conclusion # agent --> detection # ``` # # Original Prompt: # ```markdown -# # Dependency Issue Assignment +# # Issue Assignment # -# Find an open issue in this repository with "[deps]" in the title and assign it to the Copilot agent for resolution. +# Find an open issue in this repository and assign it to mrjf for resolution. # # ## Task # -# 1. **Search for issues**: Use GitHub search to find open issues with "[deps]" in the title: +# 1. **Search for issues**: Use GitHub search to find open issues in this repository: # ``` -# is:issue is:open "[deps]" in:title repo:${{ github.repository }} +# is:issue is:open repo:${{ github.repository }} # ``` # -# 2. **Filter out assigned issues**: Skip any issues that already have Copilot as an assignee. +# 2. **Filter out assigned issues**: Skip any issues that already have mrjf as an assignee. # -# 3. **Assign to Copilot**: For the first suitable issue found, use the `assign_to_agent` tool to assign it to the Copilot agent. +# 3. **Pick an issue**: Select the first suitable unassigned issue found. +# +# 4. **Assign to mrjf**: Use the `assign_to_user` tool to assign the selected issue to mrjf. # # **Agent Output Format:** # ```json # { -# "type": "assign_to_agent", +# "type": "assign_to_user", # "issue_number": , -# "agent": "copilot" +# "assignee": "mrjf" # } # ``` # -# If no suitable issues are found, output a message indicating that no "[deps]" issues are available for assignment. +# If no suitable issues are found, output a noop message indicating that no unassigned issues are available. # ``` # # Pinned GitHub Actions: @@ -104,7 +107,7 @@ name: "Dev" permissions: contents: read - issues: read + issues: write concurrency: group: "gh-aw-${{ github.workflow }}" @@ -213,7 +216,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - issues: read + issues: write concurrency: group: "gh-aw-claude-${{ github.workflow }}" env: @@ -422,21 +425,28 @@ jobs: run: | mkdir -p /tmp/gh-aw/safeoutputs cat > /tmp/gh-aw/safeoutputs/config.json << 'EOF' - {"assign_to_agent":{"default_agent":"copilot"},"missing_tool":{"max":0},"noop":{"max":1}} + {"assign_to_user":{"allowed":["mrjf"],"max":1},"missing_tool":{"max":0},"noop":{"max":1}} EOF cat > /tmp/gh-aw/safeoutputs/tools.json << 'EOF' [ { - "description": "Assign the GitHub Copilot coding agent to work on an issue. The agent will analyze the issue and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot.", + "description": "Assign one or more GitHub users to an issue. Use this to delegate work to specific team members. Users must have access to the repository.", "inputSchema": { "additionalProperties": false, "properties": { - "agent": { - "description": "Agent identifier to assign. Defaults to 'copilot' (the Copilot coding agent) if not specified.", + "assignee": { + "description": "Single GitHub username to assign. Use 'assignees' array for multiple users.", "type": "string" }, + "assignees": { + "description": "GitHub usernames to assign to the issue (e.g., ['octocat', 'mona']). Users must have access to the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, "issue_number": { - "description": "Issue number to assign the Copilot agent to. The issue should contain clear, actionable requirements.", + "description": "Issue number to assign users to. If omitted, assigns to the issue that triggered this workflow.", "type": [ "number", "string" @@ -448,7 +458,7 @@ jobs: ], "type": "object" }, - "name": "assign_to_agent" + "name": "assign_to_user" }, { "description": "Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", @@ -497,17 +507,21 @@ jobs: EOF cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' { - "assign_to_agent": { + "assign_to_user": { "defaultMax": 1, "fields": { - "agent": { + "assignee": { "type": "string", "sanitize": true, - "maxLength": 128 + "maxLength": 39 + }, + "assignees": { + "type": "[]string", + "sanitize": true, + "maxLength": 39 }, "issue_number": { - "required": true, - "positiveInteger": true + "issueOrPRNumber": true } } }, @@ -1638,31 +1652,33 @@ jobs: PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" mkdir -p "$PROMPT_DIR" cat << 'PROMPT_EOF' | envsubst > "$GH_AW_PROMPT" - # Dependency Issue Assignment + # Issue Assignment - Find an open issue in this repository with "[deps]" in the title and assign it to the Copilot agent for resolution. + Find an open issue in this repository and assign it to mrjf for resolution. ## Task - 1. **Search for issues**: Use GitHub search to find open issues with "[deps]" in the title: + 1. **Search for issues**: Use GitHub search to find open issues in this repository: ``` - is:issue is:open "[deps]" in:title repo:${GH_AW_GITHUB_REPOSITORY} + is:issue is:open repo:${GH_AW_GITHUB_REPOSITORY} ``` - 2. **Filter out assigned issues**: Skip any issues that already have Copilot as an assignee. + 2. **Filter out assigned issues**: Skip any issues that already have mrjf as an assignee. + + 3. **Pick an issue**: Select the first suitable unassigned issue found. - 3. **Assign to Copilot**: For the first suitable issue found, use the `assign_to_agent` tool to assign it to the Copilot agent. + 4. **Assign to mrjf**: Use the `assign_to_user` tool to assign the selected issue to mrjf. **Agent Output Format:** ```json { - "type": "assign_to_agent", + "type": "assign_to_user", "issue_number": , - "agent": "copilot" + "assignee": "mrjf" } ``` - If no suitable issues are found, output a message indicating that no "[deps]" issues are available for assignment. + If no suitable issues are found, output a noop message indicating that no unassigned issues are available. PROMPT_EOF - name: Append XPIA security instructions to prompt @@ -4079,22 +4095,20 @@ jobs: main(); } - assign_to_agent: + assign_to_user: needs: - agent - detection if: > - (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent'))) && + (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_user'))) && (needs.detection.outputs.success == 'true') runs-on: ubuntu-slim permissions: - actions: write - contents: write + contents: read issues: write - pull-requests: write timeout-minutes: 10 outputs: - assigned_agents: ${{ steps.assign_to_agent.outputs.assigned_agents }} + assigned_users: ${{ steps.assign_to_user.outputs.assigned_users }} steps: - name: Download agent output artifact continue-on-error: true @@ -4107,17 +4121,18 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Assign to Agent - id: assign_to_agent + - name: Assign to User + id: assign_to_user uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_AGENT_DEFAULT: "copilot" - GH_AW_AGENT_MAX_COUNT: 1 + GH_AW_ASSIGNEES_ALLOWED: "mrjf" + GH_AW_ASSIGNEES_MAX_COUNT: 1 + GH_AW_ASSIGNEES_TARGET: "*" GH_AW_WORKFLOW_NAME: "Dev" GH_AW_ENGINE_ID: "claude" with: - github-token: ${{ secrets.GH_AW_AGENT_TOKEN }} + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const fs = require("fs"); const MAX_LOG_CONTENT_LENGTH = 10000; @@ -4179,472 +4194,474 @@ jobs: core.setFailed(error instanceof Error ? error : String(error)); } } - 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; + function parseAllowedItems(envValue) { + const trimmed = envValue?.trim(); + if (!trimmed) { + return undefined; } - return null; + return trimmed + .split(",") + .map(item => item.trim()) + .filter(item => item); } - 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 } } - } + function parseMaxCount(envValue, defaultValue = 3) { + if (!envValue) { + return { valid: true, value: defaultValue }; + } + const parsed = parseInt(envValue, 10); + if (isNaN(parsed) || parsed < 1) { + return { + valid: false, + error: `Invalid max value: ${envValue}. Must be a positive integer`, + }; + } + return { valid: true, value: parsed }; + } + function resolveTarget(params) { + const { targetConfig, item, context, itemType, supportsPR = false } = params; + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + const target = targetConfig || "triggering"; + if (target === "triggering") { + if (supportsPR) { + if (!isIssueContext && !isPRContext) { + return { + success: false, + error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, + shouldFail: false, + }; } - } - `; - 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); + } else { + if (!isPRContext) { + return { + success: false, + error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, + shouldFail: false, + }; } } - 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; + let itemNumber; + let contextType; + if (target === "*") { + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; + if (numberField) { + itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); + if (isNaN(itemNumber) || itemNumber <= 0) { + return { + success: false, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + shouldFail: true, + }; } - } - 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(", ")}`); + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { - core.info("No coding agents are currently assignable in this repository."); + return { + success: false, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, + shouldFail: true, + }; } - 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" - ); + } else if (target !== "triggering") { + itemNumber = parseInt(target, 10); + if (isNaN(itemNumber) || itemNumber <= 0) { + return { + success: false, + error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, + shouldFail: true, + }; } - 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 - } - } - } + contextType = supportsPR ? "issue" : "pull request"; + } else { + if (isIssueContext) { + if (context.payload.issue) { + itemNumber = context.payload.issue.number; + contextType = "issue"; + } else { + return { + success: false, + error: "Issue context detected but no issue found in payload", + shouldFail: true, + }; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + itemNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + return { + success: false, + error: "Pull request context detected but no pull request found in payload", + shouldFail: true, + }; } } - `; - 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); + } + if (!itemNumber) { return { - issueId: issue.id, - currentAssignees: currentAssignees, + success: false, + error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, + shouldFail: true, }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to get issue details: ${errorMessage}`); - return null; } + return { + success: true, + number: itemNumber, + contextType: contextType || (supportsPR ? "issue" : "pull request"), + }; } - async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) { - const actorIds = [agentId]; - for (const assigneeId of currentAssignees) { - if (assigneeId !== agentId) { - actorIds.push(assigneeId); - } + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; } - const mutation = ` - mutation($assignableId: ID!, $actorIds: [ID!]!) { - replaceActorsForAssignable(input: { - assignableId: $assignableId, - actorIds: $actorIds - }) { - __typename - } - } - `; + let sanitized = content.trim(); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + function loadSafeOutputsConfig() { + const configPath = "/tmp/gh-aw/safeoutputs/config.json"; try { - core.info("Using built-in github object for mutation"); - core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`); - const response = await github.graphql(mutation, { - assignableId: issueId, - actorIds: actorIds, - }); - if (response && response.replaceActorsForAssignable && response.replaceActorsForAssignable.__typename) { - return true; - } else { - core.error("Unexpected response from GitHub API"); - return false; + if (!fs.existsSync(configPath)) { + core.warning(`Config file not found at ${configPath}, using defaults`); + return {}; } + const configContent = fs.readFileSync(configPath, "utf8"); + return JSON.parse(configContent); } 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 - } - } - `; - core.info("Using built-in github object for fallback mutation"); - core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`); - const fallbackResp = await github.graphql(fallbackMutation, { - assignableId: issueId, - assigneeIds: [agentId], - }); - if (fallbackResp && fallbackResp.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}`); + core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); + return {}; + } + } + function getSafeOutputConfig(outputType) { + const config = loadSafeOutputsConfig(); + return config[outputType] || {}; + } + function validateTitle(title, fieldName = "title") { + if (title === undefined || title === null) { + return { valid: false, error: `${fieldName} is required` }; + } + if (typeof title !== "string") { + return { valid: false, error: `${fieldName} must be a string` }; + } + const trimmed = title.trim(); + if (trimmed.length === 0) { + return { valid: false, error: `${fieldName} cannot be empty` }; + } + return { valid: true, value: trimmed }; + } + function validateBody(body, fieldName = "body", required = false) { + if (body === undefined || body === null) { + if (required) { + return { valid: false, error: `${fieldName} is required` }; } - return false; + return { valid: true, value: "" }; } + if (typeof body !== "string") { + return { valid: false, error: `${fieldName} must be a string` }; + } + return { valid: true, value: body }; } - 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) { - 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 }; + function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { + if (!labels || !Array.isArray(labels)) { + return { valid: false, error: "labels must be an array" }; } - 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); - 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 }; + for (const label of labels) { + if (label && typeof label === "string" && label.startsWith("-")) { + return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; + } + } + let validLabels = labels; + if (allowedLabels && allowedLabels.length > 0) { + validLabels = labels.filter(label => allowedLabels.includes(label)); } + const uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); + return { valid: true, value: uniqueLabels.slice(0, maxCount) }; + } + if (uniqueLabels.length === 0) { + return { valid: false, error: "No valid labels found after sanitization" }; + } + return { valid: true, value: uniqueLabels }; } - async function main() { + function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { + const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; + if (!envValue) { + return { valid: true, value: defaultValue }; + } + const parsed = parseInt(envValue, 10); + if (isNaN(parsed) || parsed < 1) { + return { + valid: false, + error: `Invalid max value: ${envValue}. Must be a positive integer`, + }; + } + return { valid: true, value: parsed }; + } + async function processSafeOutput(config, stagedPreviewOptions) { + const { + itemType, + configKey, + displayName, + itemTypeName, + supportsPR = false, + supportsIssue = false, + findMultiple = false, + envVars, + } = config; const result = loadAgentOutput(); if (!result.success) { - return; + return { success: false, reason: "Agent output not available" }; } - const assignItems = result.items.filter(item => item.type === "assign_to_agent"); - if (assignItems.length === 0) { - core.info("No assign_to_agent items found in agent output"); - return; + let items; + if (findMultiple) { + items = result.items.filter(item => item.type === itemType); + if (items.length === 0) { + core.info(`No ${itemType} items found in agent output`); + return { success: false, reason: `No ${itemType} items found` }; + } + core.info(`Found ${items.length} ${itemType} item(s)`); + } else { + const item = result.items.find(item => item.type === itemType); + if (!item) { + core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); + return { success: false, reason: `No ${itemType} item found` }; + } + items = [item]; + const itemDetails = getItemDetails(item); + if (itemDetails) { + core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); + } } - core.info(`Found ${assignItems.length} assign_to_agent item(s)`); if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { await generateStagedPreview({ - title: "Assign to Agent", - description: "The following agent assignments would be made if staged mode was disabled:", - items: assignItems, - renderItem: item => { - let content = `**Issue:** #${item.issue_number}\n`; - content += `**Agent:** ${item.agent || "copilot"}\n`; - content += "\n"; - return content; - }, + title: stagedPreviewOptions.title, + description: stagedPreviewOptions.description, + items: items, + renderItem: stagedPreviewOptions.renderItem, }); - return; + return { success: false, reason: "Staged mode - preview generated" }; } - const defaultAgent = process.env.GH_AW_AGENT_DEFAULT?.trim() || "copilot"; - core.info(`Default agent: ${defaultAgent}`); - const maxCountEnv = process.env.GH_AW_AGENT_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 1; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; + const safeOutputConfig = getSafeOutputConfig(configKey); + const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; + const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; + if (allowed) { + core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); + } else { + core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); + } + const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; + const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); + if (!maxCountResult.valid) { + core.setFailed(maxCountResult.error); + return { success: false, reason: "Invalid max count configuration" }; } + const maxCount = maxCountResult.value; core.info(`Max count: ${maxCount}`); - const itemsToProcess = assignItems.slice(0, maxCount); - if (assignItems.length > maxCount) { - core.warning(`Found ${assignItems.length} agent assignments, but max is ${maxCount}. Processing first ${maxCount}.`); - } - const targetRepoEnv = process.env.GH_AW_TARGET_REPO?.trim(); - let targetOwner = context.repo.owner; - let targetRepo = context.repo.repo; - if (targetRepoEnv) { - const parts = targetRepoEnv.split("/"); - if (parts.length === 2) { - targetOwner = parts[0]; - targetRepo = parts[1]; - core.info(`Using target repository: ${targetOwner}/${targetRepo}`); + const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; + core.info(`${displayName} target configuration: ${target}`); + if (findMultiple) { + return { + success: true, + items: items, + config: { + allowed, + maxCount, + target, + }, + }; + } + const item = items[0]; + const targetResult = resolveTarget({ + targetConfig: target, + item: item, + context, + itemType: itemTypeName, + supportsPR: supportsPR || supportsIssue, + }); + if (!targetResult.success) { + if (targetResult.shouldFail) { + core.setFailed(targetResult.error); } else { - core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`); + core.info(targetResult.error); } + return { success: false, reason: targetResult.error }; } - const agentCache = {}; - const results = []; - for (const item of itemsToProcess) { - const issueNumber = typeof item.issue_number === "number" ? item.issue_number : parseInt(String(item.issue_number), 10); - const agentName = item.agent || defaultAgent; - if (isNaN(issueNumber) || issueNumber <= 0) { - core.error(`Invalid issue_number: ${item.issue_number}`); - continue; - } - if (!AGENT_LOGIN_NAMES[agentName]) { - core.warning(`Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: `Unsupported agent: ${agentName}`, - }); - continue; - } - try { - let agentId = agentCache[agentName]; - if (!agentId) { - core.info(`Looking for ${agentName} coding agent...`); - agentId = await findAgent(targetOwner, targetRepo, agentName); - if (!agentId) { - throw new Error(`${agentName} coding agent is not available for this repository`); + return { + success: true, + item: item, + config: { + allowed, + maxCount, + target, + }, + targetResult: { + number: targetResult.number, + contextType: targetResult.contextType, + }, + }; + } + function getItemDetails(item) { + if (item.labels && Array.isArray(item.labels)) { + return `${item.labels.length} labels`; + } + if (item.reviewers && Array.isArray(item.reviewers)) { + return `${item.reviewers.length} reviewers`; + } + return null; + } + function sanitizeItems(items) { + return items + .filter(item => item != null && item !== false && item !== 0) + .map(item => String(item).trim()) + .filter(item => item) + .filter((item, index, arr) => arr.indexOf(item) === index); + } + function filterByAllowed(items, allowed) { + if (!allowed || allowed.length === 0) { + return items; + } + return items.filter(item => allowed.includes(item)); + } + function limitToMaxCount(items, maxCount) { + if (items.length > maxCount) { + core.info(`Too many items (${items.length}), limiting to ${maxCount}`); + return items.slice(0, maxCount); + } + return items; + } + function processItems(rawItems, allowed, maxCount) { + const filtered = filterByAllowed(rawItems, allowed); + const sanitized = sanitizeItems(filtered); + return limitToMaxCount(sanitized, maxCount); + } + async function main() { + const result = await processSafeOutput( + { + itemType: "assign_to_user", + configKey: "assign_to_user", + displayName: "Assignees", + itemTypeName: "user assignment", + supportsPR: false, + supportsIssue: true, + envVars: { + allowed: "GH_AW_ASSIGNEES_ALLOWED", + maxCount: "GH_AW_ASSIGNEES_MAX_COUNT", + target: "GH_AW_ASSIGNEES_TARGET", + }, + }, + { + title: "Assign to User", + description: "The following user assignments would be made if staged mode was disabled:", + renderItem: item => { + let content = ""; + if (item.issue_number) { + content += `**Target Issue:** #${item.issue_number}\n\n`; + } else { + content += `**Target:** Current issue\n\n`; } - agentCache[agentName] = agentId; - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - } - core.info("Getting issue details..."); - const issueDetails = await getIssueDetails(targetOwner, targetRepo, issueNumber); - if (!issueDetails) { - throw new 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}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - continue; - } - core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`); - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName); - if (!success) { - throw new Error(`Failed to assign ${agentName} via GraphQL`); - } - core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - } catch (error) { - let errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("coding agent is not available for this repository")) { - try { - const available = await getAvailableAgentLogins(targetOwner, targetRepo); - if (available.length > 0) { - errorMessage += ` (available agents: ${available.join(", ")})`; - } - } catch (e) { - core.debug("Failed to enrich unavailable agent message with available list"); + if (item.assignees && item.assignees.length > 0) { + content += `**Users to assign:** ${item.assignees.join(", ")}\n\n`; + } else if (item.assignee) { + content += `**User to assign:** ${item.assignee}\n\n`; } - } - core.error(`Failed to assign agent "${agentName}" to issue #${issueNumber}: ${errorMessage}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: errorMessage, - }); + return content; + }, } + ); + if (!result.success) { + return; } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Agent Assignment\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully assigned ${successCount} agent(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}\n`; - } - summaryContent += "\n"; + const { item: assignItem, config, targetResult } = result; + if (!config || !targetResult || targetResult.number === undefined) { + core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); + return; } - if (failureCount > 0) { - summaryContent += `❌ Failed to assign ${failureCount} agent(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}: ${result.error}\n`; - } - const hasPermissionError = results.some( - r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions")) - ); - if (hasPermissionError) { - summaryContent += generatePermissionErrorSummary(); + const { allowed: allowedAssignees, maxCount } = config; + const issueNumber = targetResult.number; + let requestedAssignees = []; + if (assignItem.assignees && Array.isArray(assignItem.assignees)) { + requestedAssignees = assignItem.assignees; + } else if (assignItem.assignee) { + requestedAssignees = [assignItem.assignee]; + } + core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`); + const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount); + if (uniqueAssignees.length === 0) { + core.info("No assignees to add"); + core.setOutput("assigned_users", ""); + await core.summary + .addRaw( + ` + ## User Assignment + No users were assigned (no valid assignees found in agent output). + ` + ) + .write(); + return; + } + core.info(`Assigning ${uniqueAssignees.length} users to issue #${issueNumber}: ${JSON.stringify(uniqueAssignees)}`); + try { + const targetRepoEnv = process.env.GH_AW_TARGET_REPO_SLUG?.trim(); + let targetOwner = context.repo.owner; + let targetRepo = context.repo.repo; + if (targetRepoEnv) { + const parts = targetRepoEnv.split("/"); + if (parts.length === 2) { + targetOwner = parts[0]; + targetRepo = parts[1]; + core.info(`Using target repository: ${targetOwner}/${targetRepo}`); + } } + await github.rest.issues.addAssignees({ + owner: targetOwner, + repo: targetRepo, + issue_number: issueNumber, + assignees: uniqueAssignees, + }); + core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}`); + core.setOutput("assigned_users", uniqueAssignees.join("\n")); + const assigneesListMarkdown = uniqueAssignees.map(assignee => `- \`${assignee}\``).join("\n"); + await core.summary + .addRaw( + ` + ## User Assignment + Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}: + ${assigneesListMarkdown} + ` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to assign users: ${errorMessage}`); + core.setFailed(`Failed to assign users: ${errorMessage}`); } - await core.summary.addRaw(summaryContent).write(); - const assignedAgents = results - .filter(r => r.success) - .map(r => `${r.issue_number}:${r.agent}`) - .join("\n"); - core.setOutput("assigned_agents", assignedAgents); - if (failureCount > 0) { - core.setFailed(`Failed to assign ${failureCount} agent(s)`); - } - } - (async () => { - await main(); - })(); + } + await main(); conclusion: needs: - agent - activation - - assign_to_agent + - assign_to_user if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -5147,7 +5164,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: WORKFLOW_NAME: "Dev" - WORKFLOW_DESCRIPTION: "Find issues with \"[deps]\" in title and assign to Copilot agent" + WORKFLOW_DESCRIPTION: "Find an open issue and assign it to mrjf" with: script: | const fs = require('fs'); diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 1abbcb4269..33c95998c9 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -2,42 +2,45 @@ on: workflow_dispatch: name: Dev -description: Find issues with "[deps]" in title and assign to Copilot agent +description: Find an open issue and assign it to mrjf timeout-minutes: 5 strict: false engine: claude permissions: contents: read - issues: read + issues: write tools: github: toolsets: [repos, issues] safe-outputs: - assign-to-agent: - name: copilot + assign-to-user: + allowed: [mrjf] + target: "*" --- -# Dependency Issue Assignment +# Issue Assignment -Find an open issue in this repository with "[deps]" in the title and assign it to the Copilot agent for resolution. +Find an open issue in this repository and assign it to mrjf for resolution. ## Task -1. **Search for issues**: Use GitHub search to find open issues with "[deps]" in the title: +1. **Search for issues**: Use GitHub search to find open issues in this repository: ``` - is:issue is:open "[deps]" in:title repo:${{ github.repository }} + is:issue is:open repo:${{ github.repository }} ``` -2. **Filter out assigned issues**: Skip any issues that already have Copilot as an assignee. +2. **Filter out assigned issues**: Skip any issues that already have mrjf as an assignee. -3. **Assign to Copilot**: For the first suitable issue found, use the `assign_to_agent` tool to assign it to the Copilot agent. +3. **Pick an issue**: Select the first suitable unassigned issue found. + +4. **Assign to mrjf**: Use the `assign_to_user` tool to assign the selected issue to mrjf. **Agent Output Format:** ```json { - "type": "assign_to_agent", + "type": "assign_to_user", "issue_number": , - "agent": "copilot" + "assignee": "mrjf" } ``` -If no suitable issues are found, output a message indicating that no "[deps]" issues are available for assignment. \ No newline at end of file +If no suitable issues are found, output a noop message indicating that no unassigned issues are available. \ No newline at end of file diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index f805582507..a6f19a3183 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -1145,21 +1145,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index d4d54eab86..98f343d255 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -333,21 +333,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 7e247f15ab..56dc60c758 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1949,21 +1949,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index c74142e733..5469123439 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1536,21 +1536,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index 91dddbd64d..47e9ff6d4b 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -1415,21 +1415,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index 0750b069cf..e180f80f52 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -1427,21 +1427,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index ce037b28fa..aceaa04ff4 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -1475,21 +1475,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index db4c897345..31f929a8b3 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1406,21 +1406,23 @@ jobs: let itemNumber; let contextType; if (target === "*") { - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + const numberField = supportsPR + ? item.item_number || item.issue_number || item.pull_request_number + : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 5b40714aa7..f50c3c4563 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -42,6 +42,7 @@ This declares that the workflow should create at most one new issue. | [**Close Discussion**](#close-discussion-close-discussion) | `close-discussion:` | Close discussions with comment and resolution | 1 | ✅ | | [**Create Agent Task**](#agent-task-creation-create-agent-task) | `create-agent-task:` | Create Copilot agent tasks | 1 | ✅ | | [**Assign to Agent**](#assign-to-agent-assign-to-agent) | `assign-to-agent:` | Assign Copilot agents to issues | 1 | ✅ | +| [**Assign to User**](#assign-to-user-assign-to-user) | `assign-to-user:` | Assign users to issues | 1 | ✅ | | [**Push to PR Branch**](#push-to-pr-branch-push-to-pull-request-branch) | `push-to-pull-request-branch:` | Push changes to PR branch | 1 | ❌ | | [**Update Release**](#release-updates-update-release) | `update-release:` | Update GitHub release descriptions | 1 | ✅ | | [**Code Scanning Alerts**](#code-scanning-alerts-create-code-scanning-alert) | `create-code-scanning-alert:` | Generate SARIF security advisories | unlimited | ❌ | @@ -462,6 +463,39 @@ Ensure Copilot is enabled for your repository. Check organization settings if bo Reference: [GitHub Copilot agent documentation](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/) +### Assign to User (`assign-to-user:`) + +Assigns GitHub users to issues. Specify `allowed` to restrict which users can be assigned. + +```yaml wrap +safe-outputs: + assign-to-user: + allowed: [user1, user2] # restrict to specific users + max: 3 # max assignments (default: 1) + target: "*" # "triggering" (default), "*", or number + target-repo: "owner/repo" # cross-repository +``` + +**Target**: `"triggering"` (requires issue event), `"*"` (any issue), or number (specific issue). + +**Agent Output Format:** +```json +{ + "type": "assign_to_user", + "issue_number": 123, + "assignees": ["octocat", "mona"] +} +``` + +Single user assignment is also supported: +```json +{ + "type": "assign_to_user", + "issue_number": 123, + "assignee": "octocat" +} +``` + ## Cross-Repository Operations Many safe outputs support `target-repo` for cross-repository operations. Requires a PAT (via `github-token` or `GH_AW_GITHUB_TOKEN`) with access to target repositories. The default `GITHUB_TOKEN` only has permissions for the current repository. diff --git a/pkg/cli/workflows/test-assign-to-user.md b/pkg/cli/workflows/test-assign-to-user.md new file mode 100644 index 0000000000..2773cca9af --- /dev/null +++ b/pkg/cli/workflows/test-assign-to-user.md @@ -0,0 +1,41 @@ +--- +name: Test Assign to User +description: Test workflow for assign_to_user safe output feature +on: + issues: + types: [labeled] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to test with' + required: true + type: string + assignee: + description: 'GitHub username to assign' + required: true + type: string + +permissions: + actions: write + contents: write + issues: write + +engine: copilot +timeout-minutes: 5 + +safe-outputs: + assign-to-user: + max: 5 +strict: false +--- + +# Assign to User Test Workflow + +This workflow tests the `assign_to_user` safe output feature, which allows AI agents to assign GitHub users to issues. + +## Task + +**For workflow_dispatch:** +Assign user `${{ github.event.inputs.assignee }}` to issue #${{ github.event.inputs.issue_number }} using the `assign_to_user` tool from the `safeoutputs` MCP server. + +Do not use GitHub tools. The assign_to_user tool will handle the actual assignment. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 913c454ec1..fda866fcb8 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3494,6 +3494,45 @@ } ] }, + "assign-to-user": { + "oneOf": [ + { + "type": "null", + "description": "Enable user assignment with default configuration" + }, + { + "type": "object", + "description": "Configuration for assigning users to issues from agentic workflow output", + "properties": { + "allowed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of allowed usernames. If specified, only these users can be assigned." + }, + "max": { + "type": "integer", + "description": "Optional maximum number of user assignments (default: 1)", + "minimum": 1 + }, + "target": { + "type": ["string", "number"], + "description": "Target issue to assign users to. Use 'triggering' (default) for the triggering issue, '*' to allow any issue, or a specific issue number." + }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository user assignment. Takes precedence over trial target repo settings." + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + } + ] + }, "link-sub-issue": { "oneOf": [ { diff --git a/pkg/workflow/assign_to_user.go b/pkg/workflow/assign_to_user.go new file mode 100644 index 0000000000..990c5baee9 --- /dev/null +++ b/pkg/workflow/assign_to_user.go @@ -0,0 +1,89 @@ +package workflow + +import ( + "fmt" +) + +// AssignToUserConfig holds configuration for assigning users to issues from agent output +type AssignToUserConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed usernames. If omitted, any users are allowed. +} + +// buildAssignToUserJob creates the assign_to_user job +func (c *Compiler) buildAssignToUserJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.AssignToUser == nil { + return nil, fmt.Errorf("safe-outputs.assign-to-user configuration is required") + } + + cfg := data.SafeOutputs.AssignToUser + + // Handle max count with default of 1 + maxCount := 1 + if cfg.Max > 0 { + maxCount = cfg.Max + } + + // Build custom environment variables using shared helpers + listJobConfig := ListJobConfig{ + SafeOutputTargetConfig: cfg.SafeOutputTargetConfig, + Allowed: cfg.Allowed, + } + customEnvVars := BuildListJobEnvVars("GH_AW_ASSIGNEES", listJobConfig, maxCount) + + // Add standard environment variables (metadata + staged/target repo) + customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, cfg.TargetRepoSlug)...) + + // Create outputs for the job + outputs := map[string]string{ + "assigned_users": "${{ steps.assign_to_user.outputs.assigned_users }}", + } + + var jobCondition = BuildSafeOutputType("assign_to_user") + if cfg.Target == "" { + // Only run if in issue context when target is not specified + issueCondition := BuildPropertyAccess("github.event.issue.number") + jobCondition = buildAnd(jobCondition, issueCondition) + } + + // Use the shared builder function to create the job + return c.buildSafeOutputJob(data, SafeOutputJobConfig{ + JobName: "assign_to_user", + StepName: "Assign to User", + StepID: "assign_to_user", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: getAssignToUserScript(), + Permissions: NewPermissionsContentsReadIssuesWrite(), + Outputs: outputs, + Condition: jobCondition, + Token: cfg.GitHubToken, + TargetRepoSlug: cfg.TargetRepoSlug, + }) +} + +// parseAssignToUserConfig handles assign-to-user configuration +func (c *Compiler) parseAssignToUserConfig(outputMap map[string]any) *AssignToUserConfig { + if configData, exists := outputMap["assign-to-user"]; exists { + assignToUserConfig := &AssignToUserConfig{} + + if configMap, ok := configData.(map[string]any); ok { + // Parse list job config (target, target-repo, allowed) + listJobConfig, _ := ParseListJobConfig(configMap, "allowed") + assignToUserConfig.SafeOutputTargetConfig = listJobConfig.SafeOutputTargetConfig + assignToUserConfig.Allowed = listJobConfig.Allowed + + // Parse common base fields (github-token, max) with default max of 1 + c.parseBaseSafeOutputConfig(configMap, &assignToUserConfig.BaseSafeOutputConfig, 1) + } else { + // If configData is nil or not a map (e.g., "assign-to-user:" with no value), + // use defaults + assignToUserConfig.Max = 1 + } + + return assignToUserConfig + } + + return nil +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 64fa2b2f4c..35192557d6 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -275,6 +275,7 @@ type SafeOutputsConfig struct { AddReviewer *AddReviewerConfig `yaml:"add-reviewer,omitempty"` AssignMilestone *AssignMilestoneConfig `yaml:"assign-milestone,omitempty"` AssignToAgent *AssignToAgentConfig `yaml:"assign-to-agent,omitempty"` + AssignToUser *AssignToUserConfig `yaml:"assign-to-user,omitempty"` // Assign users to issues UpdateIssues *UpdateIssuesConfig `yaml:"update-issues,omitempty"` UpdatePullRequests *UpdatePullRequestsConfig `yaml:"update-pull-request,omitempty"` // Update GitHub pull request title/body PushToPullRequestBranch *PushToPullRequestBranchConfig `yaml:"push-to-pull-request-branch,omitempty"` diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 29e925a95f..647852c07f 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -474,6 +474,24 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat safeOutputJobNames = append(safeOutputJobNames, assignToAgentJob.Name) } + // Build assign_to_user job if output.assign-to-user is configured + if data.SafeOutputs.AssignToUser != nil { + assignToUserJob, err := c.buildAssignToUserJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build assign_to_user job: %w", err) + } + // Safe-output jobs should depend on agent job (always) AND detection job (if enabled) + if threatDetectionEnabled { + assignToUserJob.Needs = append(assignToUserJob.Needs, constants.DetectionJobName) + // Add detection success check to the job condition + assignToUserJob.If = AddDetectionSuccessCheck(assignToUserJob.If) + } + if err := c.jobManager.AddJob(assignToUserJob); err != nil { + return fmt.Errorf("failed to add assign_to_user job: %w", err) + } + safeOutputJobNames = append(safeOutputJobNames, assignToUserJob.Name) + } + // Build update_issue job if output.update-issue is configured if data.SafeOutputs.UpdateIssues != nil { updateIssueJob, err := c.buildCreateOutputUpdateIssueJob(data, jobName) diff --git a/pkg/workflow/js/assign_to_user.cjs b/pkg/workflow/js/assign_to_user.cjs new file mode 100644 index 0000000000..62f1520538 --- /dev/null +++ b/pkg/workflow/js/assign_to_user.cjs @@ -0,0 +1,131 @@ +// @ts-check +/// + +const { processSafeOutput, processItems } = require("./safe_output_processor.cjs"); + +async function main() { + // Use shared processor for common steps + const result = await processSafeOutput( + { + itemType: "assign_to_user", + configKey: "assign_to_user", + displayName: "Assignees", + itemTypeName: "user assignment", + supportsPR: false, // Issue-only: not relevant for PRs + supportsIssue: true, + envVars: { + allowed: "GH_AW_ASSIGNEES_ALLOWED", + maxCount: "GH_AW_ASSIGNEES_MAX_COUNT", + target: "GH_AW_ASSIGNEES_TARGET", + }, + }, + { + title: "Assign to User", + description: "The following user assignments would be made if staged mode was disabled:", + renderItem: item => { + let content = ""; + if (item.issue_number) { + content += `**Target Issue:** #${item.issue_number}\n\n`; + } else { + content += `**Target:** Current issue\n\n`; + } + if (item.assignees && item.assignees.length > 0) { + content += `**Users to assign:** ${item.assignees.join(", ")}\n\n`; + } else if (item.assignee) { + content += `**User to assign:** ${item.assignee}\n\n`; + } + return content; + }, + } + ); + + if (!result.success) { + return; + } + + // @ts-ignore - TypeScript doesn't narrow properly after success check + const { item: assignItem, config, targetResult } = result; + if (!config || !targetResult || targetResult.number === undefined) { + core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); + return; + } + const { allowed: allowedAssignees, maxCount } = config; + const issueNumber = targetResult.number; + + // Support both singular "assignee" and plural "assignees" for flexibility + let requestedAssignees = []; + if (assignItem.assignees && Array.isArray(assignItem.assignees)) { + requestedAssignees = assignItem.assignees; + } else if (assignItem.assignee) { + requestedAssignees = [assignItem.assignee]; + } + + core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`); + + // Use shared helper to filter, sanitize, dedupe, and limit + const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount); + + if (uniqueAssignees.length === 0) { + core.info("No assignees to add"); + core.setOutput("assigned_users", ""); + await core.summary + .addRaw( + ` +## User Assignment + +No users were assigned (no valid assignees found in agent output). +` + ) + .write(); + return; + } + + core.info(`Assigning ${uniqueAssignees.length} users to issue #${issueNumber}: ${JSON.stringify(uniqueAssignees)}`); + + try { + // Get target repository from environment or use current + const targetRepoEnv = process.env.GH_AW_TARGET_REPO_SLUG?.trim(); + let targetOwner = context.repo.owner; + let targetRepo = context.repo.repo; + + if (targetRepoEnv) { + const parts = targetRepoEnv.split("/"); + if (parts.length === 2) { + targetOwner = parts[0]; + targetRepo = parts[1]; + core.info(`Using target repository: ${targetOwner}/${targetRepo}`); + } + } + + // Add assignees to the issue + await github.rest.issues.addAssignees({ + owner: targetOwner, + repo: targetRepo, + issue_number: issueNumber, + assignees: uniqueAssignees, + }); + + core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}`); + + core.setOutput("assigned_users", uniqueAssignees.join("\n")); + + const assigneesListMarkdown = uniqueAssignees.map(assignee => `- \`${assignee}\``).join("\n"); + await core.summary + .addRaw( + ` +## User Assignment + +Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}: + +${assigneesListMarkdown} +` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to assign users: ${errorMessage}`); + core.setFailed(`Failed to assign users: ${errorMessage}`); + } +} + +await main(); diff --git a/pkg/workflow/js/safe_output_helpers.cjs b/pkg/workflow/js/safe_output_helpers.cjs index 7da6cc7d59..6d3fbc94c4 100644 --- a/pkg/workflow/js/safe_output_helpers.cjs +++ b/pkg/workflow/js/safe_output_helpers.cjs @@ -93,23 +93,23 @@ function resolveTarget(params) { let contextType; if (target === "*") { - // Use item_number or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.pull_request_number : item.pull_request_number; + // Use item_number, issue_number, or pull_request_number from item + const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; if (numberField) { itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); if (isNaN(itemNumber) || itemNumber <= 0) { return { success: false, - error: `Invalid ${supportsPR ? "item_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, + error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, shouldFail: true, }; } - contextType = supportsPR && item.item_number ? "issue" : "pull request"; + contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; } else { return { success: false, - error: `Target is "*" but no ${supportsPR ? "item_number" : "pull_request_number"} specified in ${itemType} item`, + error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, shouldFail: true, }; } diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 4d5ced058f..89069a558a 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -332,6 +332,32 @@ "additionalProperties": false } }, + { + "name": "assign_to_user", + "description": "Assign one or more GitHub users to an issue. Use this to delegate work to specific team members. Users must have access to the repository.", + "inputSchema": { + "type": "object", + "required": ["issue_number"], + "properties": { + "issue_number": { + "type": ["number", "string"], + "description": "Issue number to assign users to. If omitted, assigns to the issue that triggered this workflow." + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub usernames to assign to the issue (e.g., ['octocat', 'mona']). Users must have access to the repository." + }, + "assignee": { + "type": "string", + "description": "Single GitHub username to assign. Use 'assignees' array for multiple users." + } + }, + "additionalProperties": false + } + }, { "name": "update_issue", "description": "Update an existing GitHub issue's status, title, or body. Use this to modify issue properties after creation. Only the fields you specify will be updated; other fields remain unchanged.", diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 98969ac470..3cf4d7d688 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -100,6 +100,14 @@ var ValidationConfig = map[string]TypeValidationConfig{ "agent": {Type: "string", Sanitize: true, MaxLength: 128}, }, }, + "assign_to_user": { + DefaultMax: 1, + Fields: map[string]FieldValidation{ + "issue_number": {IssueOrPRNumber: true}, + "assignees": {Type: "[]string", Sanitize: true, MaxLength: 39}, // GitHub username max length is 39 + "assignee": {Type: "string", Sanitize: true, MaxLength: 39}, // Single assignee alternative + }, + }, "update_issue": { DefaultMax: 1, CustomValidation: "requiresOneOf:status,title,body", diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index f0939a6fef..4335116eb6 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -29,6 +29,7 @@ func TestGetValidationConfigJSON(t *testing.T) { "add_reviewer", "assign_milestone", "assign_to_agent", + "assign_to_user", "update_issue", "update_pull_request", "push_to_pull_request_branch", diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index f0faacef7a..ffe8056a98 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -42,6 +42,7 @@ func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { safeOutputs.AddReviewer != nil || safeOutputs.AssignMilestone != nil || safeOutputs.AssignToAgent != nil || + safeOutputs.AssignToUser != nil || safeOutputs.UpdateIssues != nil || safeOutputs.UpdatePullRequests != nil || safeOutputs.PushToPullRequestBranch != nil || @@ -223,6 +224,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Handle assign-to-user + assignToUserConfig := c.parseAssignToUserConfig(outputMap) + if assignToUserConfig != nil { + config.AssignToUser = assignToUserConfig + } + // Handle update-issue updateIssuesConfig := c.parseUpdateIssuesConfig(outputMap) if updateIssuesConfig != nil { @@ -895,6 +902,19 @@ func generateSafeOutputsConfig(data *WorkflowData) string { } safeOutputsConfig["assign_to_agent"] = assignToAgentConfig } + if data.SafeOutputs.AssignToUser != nil { + assignToUserConfig := map[string]any{} + // Always include max (use configured value or default) + maxValue := 1 // default + if data.SafeOutputs.AssignToUser.Max > 0 { + maxValue = data.SafeOutputs.AssignToUser.Max + } + assignToUserConfig["max"] = maxValue + if len(data.SafeOutputs.AssignToUser.Allowed) > 0 { + assignToUserConfig["allowed"] = data.SafeOutputs.AssignToUser.Allowed + } + safeOutputsConfig["assign_to_user"] = assignToUserConfig + } if data.SafeOutputs.UpdateIssues != nil { updateConfig := map[string]any{} // Always include max (use configured value or default) @@ -1097,6 +1117,9 @@ func generateFilteredToolsJSON(data *WorkflowData) (string, error) { if data.SafeOutputs.AssignToAgent != nil { enabledTools["assign_to_agent"] = true } + if data.SafeOutputs.AssignToUser != nil { + enabledTools["assign_to_user"] = true + } if data.SafeOutputs.UpdateIssues != nil { enabledTools["update_issue"] = true } diff --git a/pkg/workflow/safe_outputs_tools_test.go b/pkg/workflow/safe_outputs_tools_test.go index ab21391822..08f5358ab0 100644 --- a/pkg/workflow/safe_outputs_tools_test.go +++ b/pkg/workflow/safe_outputs_tools_test.go @@ -282,6 +282,7 @@ func TestGetSafeOutputsToolsJSON(t *testing.T) { "add_reviewer", "assign_milestone", "assign_to_agent", + "assign_to_user", "update_issue", "update_pull_request", "push_to_pull_request_branch", diff --git a/pkg/workflow/scripts.go b/pkg/workflow/scripts.go index 7cb53dbdb2..3f3ca39df4 100644 --- a/pkg/workflow/scripts.go +++ b/pkg/workflow/scripts.go @@ -39,6 +39,9 @@ var assignMilestoneScriptSource string //go:embed js/assign_to_agent.cjs var assignToAgentScriptSource string +//go:embed js/assign_to_user.cjs +var assignToUserScriptSource string + //go:embed js/assign_copilot_to_created_issues.cjs var assignCopilotToCreatedIssuesScriptSource string @@ -116,6 +119,7 @@ func init() { DefaultScriptRegistry.Register("add_reviewer", addReviewerScriptSource) DefaultScriptRegistry.Register("assign_milestone", assignMilestoneScriptSource) DefaultScriptRegistry.Register("assign_to_agent", assignToAgentScriptSource) + DefaultScriptRegistry.Register("assign_to_user", assignToUserScriptSource) DefaultScriptRegistry.Register("assign_copilot_to_created_issues", assignCopilotToCreatedIssuesScriptSource) DefaultScriptRegistry.Register("link_sub_issue", linkSubIssueScriptSource) DefaultScriptRegistry.Register("create_discussion", createDiscussionScriptSource) @@ -184,6 +188,11 @@ func getAssignToAgentScript() string { return DefaultScriptRegistry.Get("assign_to_agent") } +// getAssignToUserScript returns the bundled assign_to_user script +func getAssignToUserScript() string { + return DefaultScriptRegistry.Get("assign_to_user") +} + // getAssignCopilotToCreatedIssuesScript returns the bundled assign_copilot_to_created_issues script func getAssignCopilotToCreatedIssuesScript() string { return DefaultScriptRegistry.Get("assign_copilot_to_created_issues")