diff --git a/.github/workflows/daily-mcp-concurrency-analysis.lock.yml b/.github/workflows/daily-mcp-concurrency-analysis.lock.yml index 7f5c6f6d7b8..efd8f5fc388 100644 --- a/.github/workflows/daily-mcp-concurrency-analysis.lock.yml +++ b/.github/workflows/daily-mcp-concurrency-analysis.lock.yml @@ -31,7 +31,7 @@ # - shared/safe-output-app.md # # Secrets used: -# - COPILOT_GITHUB_TOKEN +# - GH_AW_AGENT_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GH_AW_OTEL_ENDPOINT @@ -1277,8 +1277,8 @@ jobs: outputs: code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - create_agent_session_session_number: ${{ steps.create_agent_session.outputs.session_number }} - create_agent_session_session_url: ${{ steps.create_agent_session.outputs.session_url }} + create_agent_session_session_number: ${{ steps.process_safe_outputs.outputs.session_number }} + create_agent_session_session_url: ${{ steps.process_safe_outputs.outputs.session_url }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} @@ -1334,6 +1334,7 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_agent_session\":{\"max\":3},\"create_issue\":{\"expires\":168,\"labels\":[\"bug\",\"concurrency\",\"thread-safety\",\"automated-analysis\",\"cookie\"],\"max\":5,\"title_prefix\":\"[concurrency] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_AGENT_SESSION_TOKEN: ${{ secrets.GH_AW_AGENT_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1341,18 +1342,6 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Create Agent Session - id: create_agent_session - if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_agent_session') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} - with: - github-token: ${{ secrets.COPILOT_GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/create_agent_session.cjs'); await main(); - name: Upload Safe Output Items if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 9bb5b33e486..ee7f944f7ef 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -29,6 +29,7 @@ # # Secrets used: # - COPILOT_GITHUB_TOKEN +# - GH_AW_AGENT_TOKEN # - GH_AW_GITHUB_MCP_SERVER_TOKEN # - GH_AW_GITHUB_TOKEN # - GITHUB_TOKEN @@ -1613,8 +1614,8 @@ jobs: code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} - create_agent_session_session_number: ${{ steps.create_agent_session.outputs.session_number }} - create_agent_session_session_url: ${{ steps.create_agent_session.outputs.session_url }} + create_agent_session_session_number: ${{ steps.process_safe_outputs.outputs.session_number }} + create_agent_session_session_url: ${{ steps.process_safe_outputs.outputs.session_url }} create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} @@ -1673,6 +1674,7 @@ jobs: GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":3,\"target\":\"*\"},\"add_labels\":{\"allowed\":[\"poetry\",\"creative\",\"automation\",\"ai-generated\",\"epic\",\"haiku\",\"sonnet\",\"limerick\"],\"max\":5},\"close_pull_request\":{\"max\":2,\"required_labels\":[\"poetry\",\"automation\"],\"required_title_prefix\":\"[🎨 POETRY]\",\"target\":\"*\"},\"create_agent_session\":{\"base\":\"main\",\"max\":1},\"create_discussion\":{\"category\":\"audits\",\"close_older_discussions\":true,\"expires\":24,\"fallback_to_issue\":true,\"labels\":[\"poetry\",\"automation\",\"ai-generated\"],\"max\":2,\"title_prefix\":\"[📜 POETRY] \"},\"create_issue\":{\"expires\":48,\"group\":true,\"labels\":[\"poetry\",\"automation\",\"ai-generated\"],\"max\":2,\"title_prefix\":\"[🎭 POEM-BOT] \"},\"create_pull_request\":{\"draft\":false,\"expires\":48,\"labels\":[\"poetry\",\"automation\",\"creative-writing\"],\"max\":1,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"],\"reviewers\":[\"copilot\"],\"title_prefix\":\"[🎨 POETRY] \"},\"create_pull_request_review_comment\":{\"max\":2,\"side\":\"RIGHT\"},\"create_report_incomplete_issue\":{},\"link_sub_issue\":{\"max\":3,\"parent_required_labels\":[\"poetry\",\"epic\"],\"parent_title_prefix\":\"[🎭 POEM-BOT]\",\"sub_required_labels\":[\"poetry\"],\"sub_title_prefix\":\"[🎭 POEM-BOT]\"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_path_prefixes\":[\".github/\",\".agents/\"]},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"allow_status\":true,\"allow_title\":true,\"max\":2,\"target\":\"*\"},\"upload_asset\":{\"allowed-exts\":[\".png\",\".jpg\",\".jpeg\"],\"branch\":\"assets/${{ github.workflow }}\",\"max-size\":10240}}" GH_AW_SAFE_OUTPUTS_STAGED: "true" + GH_AW_AGENT_SESSION_TOKEN: ${{ secrets.GH_AW_AGENT_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -1680,19 +1682,6 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - - name: Create Agent Session - id: create_agent_session - if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_agent_session') - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_STAGED: "true" - with: - github-token: ${{ secrets.COPILOT_GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/create_agent_session.cjs'); await main(); update_cache_memory: needs: diff --git a/actions/setup/js/create_agent_session.cjs b/actions/setup/js/create_agent_session.cjs index 887f60b0bb6..d161d23b6d7 100644 --- a/actions/setup/js/create_agent_session.cjs +++ b/actions/setup/js/create_agent_session.cjs @@ -4,126 +4,87 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { getBaseBranch } = require("./get_base_branch.cjs"); +const { isStagedMode } = require("./safe_output_helpers.cjs"); +const { generateStagedPreview } = require("./staged_preview.cjs"); const fs = require("fs"); const path = require("path"); -async function main() { - // Initialize outputs to empty strings to ensure they're always set - core.setOutput("session_number", ""); - core.setOutput("session_url", ""); - - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - // A missing agent_output.json is expected when the engine fails before writing any safe outputs. - // Log at info level (not a failure of this step) and exit gracefully. - core.info(`Agent output file not available: ${getErrorMessage(error)}`); - return; - } - - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${getErrorMessage(error)}`); - return; - } - - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - - const createAgentSessionItems = validatedOutput.items.filter(item => item.type === "create_agent_session"); - if (createAgentSessionItems.length === 0) { - core.info("No create-agent-session items found in agent output"); - return; - } - - core.info(`Found ${createAgentSessionItems.length} create-agent-session item(s)`); - - // Get default target repository and allowed repos using standardized helpers - const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig({ - allowed_repos: process.env.GH_AW_ALLOWED_REPOS, - }); - - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Agent Sessions Preview\n\n"; - summaryContent += "The following agent sessions would be created if staged mode was disabled:\n\n"; - - for (const [index, item] of createAgentSessionItems.entries()) { - // Resolve and validate target repository for this item using the standardized helper - const repoResult = resolveAndValidateRepo(item, defaultTargetRepo, allowedRepos, "agent session"); - if (!repoResult.success) { - summaryContent += `### Task ${index + 1}\n\n`; - summaryContent += `**Error:** ${repoResult.error}\n\n`; - summaryContent += "---\n\n"; - continue; - } - const { repo: effectiveRepo, repoParts } = repoResult; - - // Resolve base branch: use custom config if set, otherwise resolve dynamically - // Pass target repo for cross-repo scenarios - const baseBranch = process.env.GITHUB_AW_AGENT_SESSION_BASE || (await getBaseBranch(repoParts)); - - summaryContent += `### Task ${index + 1}\n\n`; - summaryContent += `**Description:**\n${item.body || "No description provided"}\n\n`; - - summaryContent += `**Base Branch:** ${baseBranch}\n\n`; - - summaryContent += `**Target Repository:** ${effectiveRepo}\n\n`; - - summaryContent += "---\n\n"; - } - - core.info(summaryContent); - core.summary.addRaw(summaryContent); - await core.summary.write(); - return; - } - - // Process all agent session items - const createdTasks = []; - let summaryContent = "## ✅ Agent Sessions Created\n\n"; - - for (const [index, taskItem] of createAgentSessionItems.entries()) { - const taskDescription = taskItem.body; +/** + * Module-level state — populated by handleMessage(), read by the exported getters below. + * Using module-level variables (rather than closure-only state) allows the handler + * manager to read final output values after all messages have been processed. + * @type {Array<{number: string, url: string, success: boolean, error?: string}>} + */ +let _allResults = []; + +/** + * Handler factory for create-agent-session safe output. + * + * Replaces the standalone create_agent_session step. This function is called once by the + * safe output handler manager with the handler's configuration. It returns a message + * processor function that is invoked for each create_agent_session message in the agent output. + * + * @param {Object} config - Handler configuration from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG + * @returns {Promise} Message processor function + */ +async function main(config = {}) { + // Reset module-level state for this run + _allResults = []; + + // Parse configuration + const configuredBaseBranch = config.base ? String(config.base).trim() : null; + const isStaged = isStagedMode(config); + const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); + + if (configuredBaseBranch) core.info(`Configured base branch: ${configuredBaseBranch}`); + core.info(`Default target repo: ${defaultTargetRepo}`); + if (allowedRepos.size > 0) core.info(`Allowed repos: ${[...allowedRepos].join(", ")}`); + + /** + * Process a single create_agent_session message. + * @param {Object} message - The agent output message + * @returns {Promise<{success: boolean, number?: string, url?: string, error?: string}>} + */ + return async function handleMessage(message) { + const taskDescription = message.body; if (!taskDescription || taskDescription.trim() === "") { - core.warning(`Task ${index + 1}: Agent task description is empty, skipping`); - continue; + core.warning("Agent task description is empty, skipping"); + _allResults.push({ number: "", url: "", success: false, error: "Empty task description" }); + return { success: false, error: "Empty task description" }; } - // Resolve and validate target repository for this item using the standardized helper. - // taskItem.repo field (if present) overrides the default target repo. - const repoResult = resolveAndValidateRepo(taskItem, defaultTargetRepo, allowedRepos, "agent session"); + // Resolve and validate target repository for this message + const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "agent session"); if (!repoResult.success) { - core.error(`E004: ${repoResult.error}`); - continue; + const errorMsg = `E004: ${repoResult.error}`; + core.error(errorMsg); + _allResults.push({ number: "", url: "", success: false, error: repoResult.error }); + return { success: false, error: repoResult.error }; } const { repo: effectiveRepo, repoParts } = repoResult; - // Resolve base branch: use custom config if set, otherwise resolve dynamically + // Resolve base branch: use custom config if set, otherwise resolve dynamically. // Dynamic resolution is needed for issue_comment events on PRs where the base branch - // is not available in GitHub Actions expressions and requires an API call - // Pass target repo for cross-repo scenarios - const baseBranch = process.env.GITHUB_AW_AGENT_SESSION_BASE || (await getBaseBranch(repoParts)); + // is not available in GitHub Actions expressions and requires an API call. + const baseBranch = configuredBaseBranch || (await getBaseBranch(repoParts)); + + if (isStaged) { + await generateStagedPreview({ + title: "Create Agent Session", + description: "The following agent sessions would be created if staged mode was disabled:", + items: [message], + renderItem: item => { + const parts = []; + parts.push(`**Description:**\n${item.body}`); + parts.push(`**Base Branch:** ${baseBranch}`); + parts.push(`**Target Repository:** ${effectiveRepo}`); + return parts.join("\n\n") + "\n\n"; + }, + }); + return { success: true, skipped: true }; + } try { // Write task description to a temporary file @@ -132,9 +93,10 @@ async function main() { fs.mkdirSync(tmpDir, { recursive: true }); } - const taskFile = path.join(tmpDir, `agent-task-description-${index + 1}.md`); + const taskIndex = _allResults.length + 1; + const taskFile = path.join(tmpDir, `agent-task-description-${taskIndex}.md`); fs.writeFileSync(taskFile, taskDescription, "utf8"); - core.info(`Task ${index + 1}: Task description written to ${taskFile}`); + core.info(`Task ${taskIndex}: Task description written to ${taskFile}`); // Build gh agent-task create command const ghArgs = ["agent-task", "create", "--from-file", taskFile, "--base", baseBranch]; @@ -144,7 +106,10 @@ async function main() { ghArgs.push("--repo", effectiveRepo); } - core.info(`Task ${index + 1}: Creating agent session with command: gh ${ghArgs.join(" ")}`); + core.info(`Task ${taskIndex}: Creating agent session with command: gh ${ghArgs.join(" ")}`); + + // Determine token: prefer per-handler token, fall back to step-level token + const ghToken = config["github-token"] || process.env.GH_AW_AGENT_SESSION_TOKEN || process.env.GITHUB_TOKEN || ""; // Execute gh agent-task create command let taskOutput; @@ -154,7 +119,7 @@ async function main() { ignoreReturnCode: false, env: { ...process.env, - GH_TOKEN: process.env.GITHUB_TOKEN || "", + GH_TOKEN: ghToken, }, }); } catch (execError) { @@ -162,55 +127,101 @@ async function main() { // Check for authentication/permission errors if (errorMessage.includes("authentication") || errorMessage.includes("permission") || errorMessage.includes("forbidden") || errorMessage.includes("401") || errorMessage.includes("403")) { - core.error(`Task ${index + 1}: Failed to create agent session due to authentication/permission error.`); - core.error(`The default GITHUB_TOKEN does not have permission to create agent sessions.`); - core.error(`You must configure a Personal Access Token (PAT) as COPILOT_GITHUB_TOKEN or GH_AW_GITHUB_TOKEN.`); + core.error(`Task ${taskIndex}: Failed to create agent session due to authentication/permission error.`); + core.error(`The default GITHUB_TOKEN may not have permission to create agent sessions.`); + core.error(`Configure a Personal Access Token (PAT) using the handler's github-token setting or GH_AW_AGENT_SESSION_TOKEN.`); core.error(`See documentation: https://github.github.com/gh-aw/reference/safe-outputs/#agent-task-creation-create-agent-session`); } else { - core.error(`Task ${index + 1}: Failed to create agent session: ${errorMessage}`); + core.error(`Task ${taskIndex}: Failed to create agent session: ${errorMessage}`); } - continue; + _allResults.push({ number: "", url: "", success: false, error: errorMessage }); + return { success: false, error: errorMessage }; } - // Parse the output to extract task number and URL + // Parse the output to extract task number and URL. // Expected output format from gh agent-task create is typically: // https://github.com/owner/repo/issues/123 const output = taskOutput.stdout.trim(); - core.info(`Task ${index + 1}: Agent task created: ${output}`); + core.info(`Task ${taskIndex}: Agent task created: ${output}`); // Extract task number from URL const urlMatch = output.match(/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/); if (urlMatch) { const taskNumber = urlMatch[1]; - createdTasks.push({ number: taskNumber, url: output }); - - summaryContent += `### Task ${index + 1}\n\n`; - summaryContent += `**Task:** [#${taskNumber}](${output})\n\n`; - summaryContent += `**Base Branch:** ${baseBranch}\n\n`; - core.info(`✅ Successfully created agent session #${taskNumber}`); + _allResults.push({ number: taskNumber, url: output, success: true }); + return { success: true, number: taskNumber, url: output }; } else { - core.warning(`Task ${index + 1}: Could not parse task number from output: ${output}`); - createdTasks.push({ number: "", url: output }); + core.warning(`Task ${taskIndex}: Could not parse task number from output: ${output}`); + _allResults.push({ number: "", url: output, success: true }); + return { success: true, number: "", url: output }; } } catch (error) { - core.error(`Task ${index + 1}: Error creating agent session: ${getErrorMessage(error)}`); + const errorMessage = getErrorMessage(error); + core.error(`Error creating agent session: ${errorMessage}`); + _allResults.push({ number: "", url: "", success: false, error: errorMessage }); + return { success: false, error: errorMessage }; } + }; +} + +/** + * Returns the session_number output: the number of the first successfully created session. + * @returns {string} + */ +function getCreateAgentSessionNumber() { + const first = _allResults.find(r => r.success && r.number); + return first ? first.number : ""; +} + +/** + * Returns the session_url output: the URL of the first successfully created session. + * @returns {string} + */ +function getCreateAgentSessionUrl() { + const first = _allResults.find(r => r.success && r.url); + return first ? first.url : ""; +} + +/** + * Writes a step summary for agent session creation results. + * Called by the handler manager after all messages have been processed. + * @returns {Promise} + */ +async function writeCreateAgentSessionSummary() { + const successResults = _allResults.filter(r => r.success); + const failedResults = _allResults.filter(r => !r.success); + + if (_allResults.length === 0) return; + + let summaryContent = "## Agent Sessions\n\n"; + + if (successResults.length > 0) { + summaryContent += `✅ Successfully created ${successResults.length} agent session(s):\n\n`; + summaryContent += successResults + .map((r, i) => { + if (r.url && r.number) { + return `- [#${r.number}](${r.url})`; + } else if (r.url) { + return `- [Session ${i + 1}](${r.url})`; + } + return `- Session ${i + 1}`; + }) + .join("\n"); + summaryContent += "\n\n"; } - // Set outputs for the first created task (for backward compatibility) - if (createdTasks.length > 0) { - core.setOutput("session_number", createdTasks[0].number); - core.setOutput("session_url", createdTasks[0].url); - } else { - core.setFailed("No agent sessions were created"); - return; + if (failedResults.length > 0) { + summaryContent += `❌ Failed to create ${failedResults.length} agent session(s):\n\n`; + summaryContent += failedResults.map(r => `- ${r.error || "Unknown error"}`).join("\n"); + summaryContent += "\n\n"; } - // Write summary - core.info(summaryContent); - core.summary.addRaw(summaryContent); - await core.summary.write(); + try { + await core.summary.addRaw(summaryContent).write(); + } catch (error) { + core.warning(`Failed to write agent session summary: ${getErrorMessage(error)}`); + } } -module.exports = { main }; +module.exports = { main, getCreateAgentSessionNumber, getCreateAgentSessionUrl, writeCreateAgentSessionSummary }; diff --git a/actions/setup/js/create_agent_session.test.cjs b/actions/setup/js/create_agent_session.test.cjs index d3d9d73e57c..f3105cc1696 100644 --- a/actions/setup/js/create_agent_session.test.cjs +++ b/actions/setup/js/create_agent_session.test.cjs @@ -1,193 +1,358 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; +import path from "path"; + +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockExec = { + exec: vi.fn().mockResolvedValue(0), + getExecOutput: vi.fn(), +}; + +const mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, +}; + +global.core = mockCore; +global.exec = mockExec; +global.context = mockContext; + describe("create_agent_session.cjs", () => { - let mockCore, mockExec, mockContext, testOutputFile; - (beforeEach(() => { - ((mockCore = { info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } }), - (mockExec = { exec: vi.fn().mockResolvedValue(0), getExecOutput: vi.fn() }), - (mockContext = { repo: { owner: "test-owner", repo: "test-repo" } }), - (global.core = mockCore), - (global.exec = mockExec), - (global.context = mockContext), - (testOutputFile = `/tmp/test_agent_output_${Date.now()}.json`)); - }), - afterEach(() => { - (delete global.core, - delete global.exec, - delete global.context, - delete process.env.GH_AW_AGENT_OUTPUT, - delete process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED, - delete process.env.GITHUB_AW_AGENT_SESSION_BASE, - delete process.env.GH_AW_TARGET_REPO_SLUG, - delete process.env.GH_AW_ALLOWED_REPOS, - delete process.env.GITHUB_REPOSITORY, - fs.existsSync(testOutputFile) && fs.unlinkSync(testOutputFile)); - })); - const createAgentOutput = items => { - const output = { items }; - (fs.writeFileSync(testOutputFile, JSON.stringify(output)), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile)); - }, - runScript = async () => { - ((global.core = mockCore), (global.exec = mockExec), (global.context = mockContext)); - const scriptPath = require("path").join(process.cwd(), "create_agent_session.cjs"); - delete require.cache[require.resolve(scriptPath)]; - try { - const { main } = require(scriptPath); - await main(); - } catch (error) {} - }; - ((describe("basic functionality", () => { - (it("should handle missing environment variable", async () => { - (delete process.env.GH_AW_AGENT_OUTPUT, - await runScript(), - expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"), - expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", ""), - expect(mockCore.setOutput).toHaveBeenCalledWith("session_url", "")); - }), - it("should handle missing output file", async () => { - ((process.env.GH_AW_AGENT_OUTPUT = "/nonexistent/file.json"), - await runScript(), - expect(mockCore.setFailed).not.toHaveBeenCalled(), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Agent output file not available"))); - }), - it("should handle empty agent output", async () => { - (fs.writeFileSync(testOutputFile, ""), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty")); - }), - it("should handle invalid JSON", async () => { - (fs.writeFileSync(testOutputFile, "invalid json"), - (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), - await runScript(), - expect(mockCore.setFailed).toHaveBeenCalled(), - expect(mockCore.setFailed.mock.calls[0][0]).toContain("Error parsing agent output JSON")); - }), - it("should handle no create_agent_session items", async () => { - (createAgentOutput([{ type: "create_issue", title: "Test", body: "Content" }]), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No create-agent-session items found in agent output")); - })); - }), + let createAgentSessionModule; + + beforeEach(() => { + vi.clearAllMocks(); + + delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; + delete process.env.GH_AW_TARGET_REPO_SLUG; + delete process.env.GH_AW_ALLOWED_REPOS; + delete process.env.GH_AW_AGENT_SESSION_TOKEN; + delete process.env.GITHUB_TOKEN; + + // Clear module cache to get a fresh module for each test + const scriptPath = path.join(process.cwd(), "create_agent_session.cjs"); + delete require.cache[require.resolve(scriptPath)]; + createAgentSessionModule = require(scriptPath); + }); + + afterEach(() => { + // Clean up tmp files + try { + const files = fs.readdirSync("/tmp/gh-aw").filter(f => f.startsWith("agent-task-description-")); + for (const file of files) { + fs.unlinkSync(path.join("/tmp/gh-aw", file)); + } + } catch {} + }); + + describe("handler factory", () => { + it("should return a function when main() is called", async () => { + const handler = await createAgentSessionModule.main({}); + expect(typeof handler).toBe("function"); + }); + + it("should log configuration on initialization", async () => { + await createAgentSessionModule.main({ base: "develop" }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Configured base branch: develop")); + }); + + it("should log target repo on initialization", async () => { + await createAgentSessionModule.main({ "target-repo": "owner/repo" }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Default target repo: owner/repo")); + }); + }); + + describe("message handler - empty/invalid body", () => { + it("should skip messages with empty body", async () => { + const handler = await createAgentSessionModule.main({}); + const result = await handler({ type: "create_agent_session", body: "" }); + expect(result.success).toBe(false); + expect(result.error).toContain("Empty task description"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent task description is empty, skipping")); + }); + + it("should skip messages with whitespace-only body", async () => { + const handler = await createAgentSessionModule.main({}); + const result = await handler({ type: "create_agent_session", body: " \n\t " }); + expect(result.success).toBe(false); + }); + }); + describe("staged mode", () => { - (beforeEach(() => { - ((process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), (process.env.GITHUB_AW_AGENT_SESSION_BASE = "main"), (process.env.GITHUB_REPOSITORY = "owner/repo")); - }), - it("should preview agent sessions in staged mode", async () => { - (createAgentOutput([ - { type: "create_agent_session", body: "Implement feature X" }, - { type: "create_agent_session", body: "Fix bug Y" }, - ]), - await runScript(), - expect(mockCore.info).toHaveBeenCalled()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - (expect(summaryCall[0]).toContain("🎭 Staged Mode: Create Agent Sessions Preview"), - expect(summaryCall[0]).toContain("Implement feature X"), - expect(summaryCall[0]).toContain("Fix bug Y"), - expect(summaryCall[0]).toContain("**Base Branch:** main"), - expect(summaryCall[0]).toContain("**Target Repository:** test-owner/test-repo"), - expect(mockCore.summary.write).toHaveBeenCalled()); - }), - it("should handle task without body in staged mode", async () => { - (createAgentOutput([{ type: "create_agent_session", body: "" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("No description provided"); - }), - it("should use target repo when specified", async () => { - ((process.env.GH_AW_TARGET_REPO_SLUG = "org/target-repo"), createAgentOutput([{ type: "create_agent_session", body: "Test task" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Target Repository:** org/target-repo"); - })); - }), - describe("agent session creation", () => { - (beforeEach(() => { - ((process.env.GITHUB_AW_AGENT_SESSION_BASE = "develop"), (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "false")); - }), - it("should skip tasks with empty body", async () => { - (createAgentOutput([ - { type: "create_agent_session", body: "" }, - { type: "create_agent_session", body: " \n\t " }, - ]), - await runScript(), - expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Agent task description is empty, skipping"))); - }), - it("should log agent output content length", async () => { - (createAgentOutput([{ type: "create_agent_session", body: "Test agent session description" }]), - await runScript(), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Agent output content length:")), - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found 1 create-agent-session item(s)"))); - })); - }), - describe("output initialization", () => { - it("should initialize outputs to empty strings", async () => { - (delete process.env.GH_AW_AGENT_OUTPUT, await runScript(), expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", ""), expect(mockCore.setOutput).toHaveBeenCalledWith("session_url", "")); - }); - }), - describe("edge cases", () => { - (it("should handle output with no items array", async () => { - (fs.writeFileSync(testOutputFile, JSON.stringify({})), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); - }), - it("should handle output with non-array items", async () => { - (fs.writeFileSync(testOutputFile, JSON.stringify({ items: "not an array" })), (process.env.GH_AW_AGENT_OUTPUT = testOutputFile), await runScript(), expect(mockCore.info).toHaveBeenCalledWith("No valid items found in agent output")); - }), - it("should use default base branch when not specified", async () => { - (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, delete process.env.GITHUB_REF_NAME, (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), createAgentOutput([{ type: "create_agent_session", body: "Test" }]), await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Base Branch:** main"); - }), - it("should use GITHUB_REF_NAME as fallback for base branch", async () => { - (delete process.env.GITHUB_AW_AGENT_SESSION_BASE, - (process.env.GITHUB_REF_NAME = "feature-branch"), - (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED = "true"), - createAgentOutput([{ type: "create_agent_session", body: "Test" }]), - await runScript()); - const summaryCall = mockCore.summary.addRaw.mock.calls[0]; - expect(summaryCall[0]).toContain("**Base Branch:** main"); - })); - })), - describe("Cross-repository allowlist validation", () => { - it("should reject target repository not in allowlist", async () => { - process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo"; - - createAgentOutput([{ type: "create_agent_session", body: "Test task", repo: "not-allowed/other-repo" }]); - - await runScript(); - - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("E004:")); - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("not in the allowed-repos list")); + it("should generate staged preview and return skipped without calling gh CLI", async () => { + process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"; + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Implement feature X" }); + expect(result.success).toBe(true); + expect(result.skipped).toBe(true); + expect(mockExec.getExecOutput).not.toHaveBeenCalled(); + // Should have written a staged preview summary + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("🎭 Staged Mode: Create Agent Session Preview")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Implement feature X")); + }); + + it("should include base branch and target repo in staged preview", async () => { + process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"; + const handler = await createAgentSessionModule.main({ base: "develop" }); + await handler({ type: "create_agent_session", body: "Test task" }); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("develop")); + }); + + it("should support staged mode via config flag", async () => { + const handler = await createAgentSessionModule.main({ staged: true, base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task" }); + expect(result.success).toBe(true); + expect(result.skipped).toBe(true); + expect(mockExec.getExecOutput).not.toHaveBeenCalled(); + }); + }); + + describe("successful session creation", () => { + it("should create agent session and extract task number from URL", async () => { + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/123", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Implement feature X" }); + + expect(result.success).toBe(true); + expect(result.number).toBe("123"); + expect(result.url).toBe("https://github.com/test-owner/test-repo/issues/123"); + }); + + it("should use configured base branch when calling gh CLI", async () => { + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/42", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "develop" }); + await handler({ type: "create_agent_session", body: "Test task" }); + + expect(mockExec.getExecOutput).toHaveBeenCalledWith("gh", expect.arrayContaining(["--base", "develop"]), expect.any(Object)); + }); + + it("should add --repo flag for cross-repo sessions", async () => { + process.env.GH_AW_ALLOWED_REPOS = "other-owner/other-repo"; + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/other-owner/other-repo/issues/99", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ "target-repo": "other-owner/other-repo", base: "main" }); + await handler({ type: "create_agent_session", body: "Cross-repo task", repo: "other-owner/other-repo" }); + + expect(mockExec.getExecOutput).toHaveBeenCalledWith("gh", expect.arrayContaining(["--repo", "other-owner/other-repo"]), expect.any(Object)); + }); + + it("should use GH_AW_AGENT_SESSION_TOKEN as GH_TOKEN for gh CLI", async () => { + process.env.GH_AW_AGENT_SESSION_TOKEN = "test-pat-token"; + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/55", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "main" }); + await handler({ type: "create_agent_session", body: "Test task" }); + + expect(mockExec.getExecOutput).toHaveBeenCalledWith( + "gh", + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ GH_TOKEN: "test-pat-token" }), + }) + ); + }); + + it("should prefer per-handler github-token over GH_AW_AGENT_SESSION_TOKEN", async () => { + process.env.GH_AW_AGENT_SESSION_TOKEN = "step-token"; + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/55", + stderr: "", }); - it("should allow target repository in allowlist", async () => { - process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + const handler = await createAgentSessionModule.main({ base: "main", "github-token": "per-handler-token" }); + await handler({ type: "create_agent_session", body: "Test task" }); + + expect(mockExec.getExecOutput).toHaveBeenCalledWith( + "gh", + expect.any(Array), + expect.objectContaining({ + env: expect.objectContaining({ GH_TOKEN: "per-handler-token" }), + }) + ); + }); + }); + + describe("error handling", () => { + it("should return failure when gh CLI fails with auth error", async () => { + mockExec.getExecOutput.mockRejectedValueOnce(new Error("permission denied (403)")); + + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task" }); + + expect(result.success).toBe(false); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("authentication/permission error")); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("GH_AW_AGENT_SESSION_TOKEN")); + }); - createAgentOutput([{ type: "create_agent_session", body: "Test task", repo: "allowed-owner/allowed-repo" }]); + it("should return failure when gh CLI fails with generic error", async () => { + mockExec.getExecOutput.mockRejectedValueOnce(new Error("gh: command not found")); - // Mock gh CLI command - mockExec.getExecOutput.mockResolvedValueOnce({ - exitCode: 0, - stdout: "https://github.com/allowed-owner/allowed-repo/issues/123", - stderr: "", - }); + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task" }); - await runScript(); + expect(result.success).toBe(false); + expect(mockCore.error).toHaveBeenCalled(); + }); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", "123"); + it("should warn when task number cannot be parsed from output", async () => { + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "Task created successfully", + stderr: "", }); - it("should allow default repository without allowlist", async () => { - // No GH_AW_TARGET_REPO_SLUG set, no GH_AW_ALLOWED_REPOS set - uses context repo - delete process.env.GH_AW_TARGET_REPO_SLUG; - delete process.env.GH_AW_ALLOWED_REPOS; + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task" }); + + expect(result.success).toBe(true); + expect(result.number).toBe(""); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not parse task number")); + }); - createAgentOutput([{ type: "create_agent_session", body: "Test task" }]); + it("should reject repositories not in allowlist", async () => { + process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo"; - // Mock gh CLI command - mockExec.getExecOutput.mockResolvedValueOnce({ - exitCode: 0, - stdout: "https://github.com/test-owner/test-repo/issues/123", - stderr: "", - }); + const handler = await createAgentSessionModule.main({ "target-repo": "other-owner/other-repo", base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task", repo: "not-allowed/other-repo" }); - await runScript(); + expect(result.success).toBe(false); + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("E004:")); + }); + }); - expect(mockCore.setFailed).not.toHaveBeenCalled(); - expect(mockCore.setOutput).toHaveBeenCalledWith("session_number", "123"); + describe("module-level getters", () => { + it("getCreateAgentSessionNumber() returns first successful session number", async () => { + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/42", + stderr: "", }); - })); + + const handler = await createAgentSessionModule.main({ base: "main" }); + await handler({ type: "create_agent_session", body: "Task 1" }); + + expect(createAgentSessionModule.getCreateAgentSessionNumber()).toBe("42"); + }); + + it("getCreateAgentSessionUrl() returns first successful session URL", async () => { + const expectedUrl = "https://github.com/test-owner/test-repo/issues/42"; + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: expectedUrl, + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "main" }); + await handler({ type: "create_agent_session", body: "Task 1" }); + + expect(createAgentSessionModule.getCreateAgentSessionUrl()).toBe(expectedUrl); + }); + + it("getCreateAgentSessionNumber() returns empty string when no sessions created", async () => { + await createAgentSessionModule.main({ base: "main" }); + expect(createAgentSessionModule.getCreateAgentSessionNumber()).toBe(""); + }); + + it("getCreateAgentSessionUrl() returns empty string when no sessions created", async () => { + await createAgentSessionModule.main({ base: "main" }); + expect(createAgentSessionModule.getCreateAgentSessionUrl()).toBe(""); + }); + }); + + describe("writeCreateAgentSessionSummary()", () => { + it("should write summary with successful sessions", async () => { + mockExec.getExecOutput.mockResolvedValue({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/42", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "main" }); + await handler({ type: "create_agent_session", body: "Task 1" }); + await createAgentSessionModule.writeCreateAgentSessionSummary(); + + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Agent Sessions")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("42")); + }); + + it("should not write summary when no results", async () => { + await createAgentSessionModule.main({ base: "main" }); + await createAgentSessionModule.writeCreateAgentSessionSummary(); + + expect(mockCore.summary.addRaw).not.toHaveBeenCalled(); + }); + + it("should write summary with failed sessions", async () => { + mockExec.getExecOutput.mockRejectedValueOnce(new Error("some error")); + + const handler = await createAgentSessionModule.main({ base: "main" }); + await handler({ type: "create_agent_session", body: "Task 1" }); + await createAgentSessionModule.writeCreateAgentSessionSummary(); + + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("❌ Failed")); + }); + }); + + describe("cross-repository allowlist validation", () => { + it("should allow target repository in allowlist", async () => { + process.env.GH_AW_ALLOWED_REPOS = "allowed-owner/allowed-repo"; + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/allowed-owner/allowed-repo/issues/123", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ "target-repo": "allowed-owner/allowed-repo", base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task", repo: "allowed-owner/allowed-repo" }); + + expect(result.success).toBe(true); + expect(result.number).toBe("123"); + }); + + it("should allow default repository without allowlist", async () => { + delete process.env.GH_AW_TARGET_REPO_SLUG; + delete process.env.GH_AW_ALLOWED_REPOS; + mockExec.getExecOutput.mockResolvedValueOnce({ + exitCode: 0, + stdout: "https://github.com/test-owner/test-repo/issues/123", + stderr: "", + }); + + const handler = await createAgentSessionModule.main({ base: "main" }); + const result = await handler({ type: "create_agent_session", body: "Test task" }); + + expect(result.success).toBe(true); + expect(result.number).toBe("123"); + }); + }); }); diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 11633385189..05ea9f2cef7 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -18,6 +18,7 @@ const { setCollectedMissings } = require("./missing_messages_helper.cjs"); const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs"); const { getIssuesToAssignCopilot } = require("./create_issue.cjs"); const { getAssignToAgentAssigned, getAssignToAgentErrors, getAssignToAgentErrorCount, writeAssignToAgentSummary } = require("./assign_to_agent.cjs"); +const { getCreateAgentSessionNumber, getCreateAgentSessionUrl, writeCreateAgentSessionSummary } = require("./create_agent_session.cjs"); const { createReviewBuffer } = require("./pr_review_buffer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { createManifestLogger, ensureManifestExists, extractCreatedItemFromResult } = require("./safe_output_manifest.cjs"); @@ -57,6 +58,7 @@ const HANDLER_MAP = { assign_to_user: "./assign_to_user.cjs", unassign_from_user: "./unassign_from_user.cjs", assign_to_agent: "./assign_to_agent.cjs", + create_agent_session: "./create_agent_session.cjs", create_code_scanning_alert: "./create_code_scanning_alert.cjs", autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs", dispatch_workflow: "./dispatch_workflow.cjs", @@ -78,10 +80,10 @@ const HANDLER_MAP = { * Message types handled by standalone steps (not through the handler manager) * These types should not trigger warnings when skipped by the handler manager * - * Standalone types: create_agent_session, upload_asset, noop + * Standalone types: upload_asset, noop * - Have dedicated processing steps with specialized logic */ -const STANDALONE_STEP_TYPES = new Set(["create_agent_session", "upload_asset", "noop"]); +const STANDALONE_STEP_TYPES = new Set(["upload_asset", "noop"]); /** * Code-push safe output types that must succeed before remaining outputs are processed. @@ -1184,6 +1186,16 @@ async function main() { await writeAssignToAgentSummary(); } + // Export create_agent_session outputs when the handler was loaded + if (messageHandlers.has("create_agent_session")) { + const sessionNumber = getCreateAgentSessionNumber(); + const sessionUrl = getCreateAgentSessionUrl(); + core.setOutput("session_number", sessionNumber); + core.setOutput("session_url", sessionUrl); + core.info(`Exported create_agent_session outputs (session_number=${sessionNumber})`); + await writeCreateAgentSessionSummary(); + } + // Export create_discussion errors for conclusion job const createDiscussionErrors = processingResult.results .filter(r => r.type === "create_discussion" && !r.success && !r.deferred && !r.skipped) diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index d3b228374eb..e609b532877 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -581,20 +581,19 @@ describe("Safe Output Handler Manager", () => { it("should silently skip message types handled by standalone steps", async () => { const messages = [ { type: "create_issue", title: "Issue" }, - { type: "create_agent_session", title: "Task" }, { type: "upload_asset", path: "file.txt" }, ]; const mockHandler = vi.fn().mockResolvedValue({ success: true }); // Only create_issue handler is available - // create_agent_session and upload_asset are handled by standalone steps + // upload_asset is handled by a standalone step const handlers = new Map([["create_issue", mockHandler]]); const result = await processMessages(handlers, messages); expect(result.success).toBe(true); - expect(result.results).toHaveLength(3); + expect(result.results).toHaveLength(2); // First message should succeed expect(result.results[0].success).toBe(true); @@ -602,29 +601,20 @@ describe("Safe Output Handler Manager", () => { // Second message should be skipped (standalone step) expect(result.results[1].success).toBe(false); - expect(result.results[1].type).toBe("create_agent_session"); + expect(result.results[1].type).toBe("upload_asset"); expect(result.results[1].skipped).toBe(true); expect(result.results[1].reason).toBe("Handled by standalone step"); - // Third message should also be skipped (standalone step) - expect(result.results[2].success).toBe(false); - expect(result.results[2].type).toBe("upload_asset"); - expect(result.results[2].skipped).toBe(true); - expect(result.results[2].reason).toBe("Handled by standalone step"); - // Should NOT have logged warnings for standalone step types - expect(core.warning).not.toHaveBeenCalledWith(expect.stringContaining("No handler loaded for message type 'create_agent_session'")); expect(core.warning).not.toHaveBeenCalledWith(expect.stringContaining("No handler loaded for message type 'upload_asset'")); // Should have logged debug messages - expect(core.debug).toHaveBeenCalledWith(expect.stringContaining("create_agent_session")); expect(core.debug).toHaveBeenCalledWith(expect.stringContaining("upload_asset")); }); it("should track skipped message types for logging", async () => { const messages = [ { type: "create_issue", title: "Issue" }, - { type: "create_agent_session", title: "Task" }, { type: "upload_asset", path: "file.txt" }, { type: "unknown_type", data: "test" }, { type: "another_unknown", data: "test2" }, @@ -642,7 +632,7 @@ describe("Safe Output Handler Manager", () => { // Collect skipped standalone types const skippedStandaloneResults = result.results.filter(r => r.skipped && r.reason === "Handled by standalone step"); const standaloneTypes = [...new Set(skippedStandaloneResults.map(r => r.type))]; - expect(standaloneTypes).toEqual(expect.arrayContaining(["create_agent_session", "upload_asset"])); + expect(standaloneTypes).toEqual(expect.arrayContaining(["upload_asset"])); // Collect skipped no-handler types const skippedNoHandlerResults = result.results.filter(r => !r.success && !r.skipped && r.error?.includes("No handler loaded")); diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 18e31cecc07..4eb757e46ec 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -350,6 +350,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("base", c.Base). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). AddIfTrue("staged", c.Staged). Build() }, diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 4a89cb4763c..d3ddcf9187a 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -160,6 +160,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa data.SafeOutputs.MissingTool != nil || data.SafeOutputs.MissingData != nil || data.SafeOutputs.AssignToAgent != nil || // assign_to_agent is now handled by the handler manager + data.SafeOutputs.CreateAgentSessions != nil || // create_agent_session is now handled by the handler manager len(data.SafeOutputs.Scripts) > 0 || // Custom scripts run in the handler loop len(data.SafeOutputs.Actions) > 0 // Custom actions need handler to export their payloads @@ -202,6 +203,13 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa outputs["assign_to_agent_assignment_error_count"] = "${{ steps.process_safe_outputs.outputs.assign_to_agent_assignment_error_count }}" } + // Export create_agent_session outputs from the handler manager step + if data.SafeOutputs.CreateAgentSessions != nil { + consolidatedSafeOutputsJobLog.Print("Exposing create_agent_session outputs from handler manager") + outputs["create_agent_session_session_number"] = "${{ steps.process_safe_outputs.outputs.session_number }}" + outputs["create_agent_session_session_url"] = "${{ steps.process_safe_outputs.outputs.session_url }}" + } + // If create-issue is configured with assignees: copilot, run a follow-up step to // assign the Copilot coding agent. The handler manager exports the list via // steps.process_safe_outputs.outputs.issues_to_assign_copilot. @@ -242,18 +250,7 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa steps = append(steps, buildSarifArtifactUploadStep(agentArtifactPrefix)...) } - // 3. Create Agent Session step - if data.SafeOutputs.CreateAgentSessions != nil { - stepConfig := c.buildCreateAgentSessionStepConfig(data, mainJobName, threatDetectionEnabled) - stepYAML := c.buildConsolidatedSafeOutputStep(data, stepConfig) - steps = append(steps, stepYAML...) - safeOutputStepNames = append(safeOutputStepNames, stepConfig.StepID) - - outputs["create_agent_session_session_number"] = "${{ steps.create_agent_session.outputs.session_number }}" - outputs["create_agent_session_session_url"] = "${{ steps.create_agent_session.outputs.session_url }}" - } - - // 4. Custom action steps — compiler-generated steps for each configured safe-output action. + // 3. Custom action steps — compiler-generated steps for each configured safe-output action. // These steps run after the handler manager, which processes the agent payload and exports // a JSON payload output for each action tool call. Each step is guarded by an `if:` condition // that checks whether the handler manager exported a payload for this action. diff --git a/pkg/workflow/compiler_safe_outputs_specialized.go b/pkg/workflow/compiler_safe_outputs_specialized.go deleted file mode 100644 index 6fb724966f4..00000000000 --- a/pkg/workflow/compiler_safe_outputs_specialized.go +++ /dev/null @@ -1,29 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var specializedOutputsLog = logger.New("workflow:compiler_safe_outputs_specialized") - -// buildCreateAgentTaskStepConfig builds the configuration for creating an agent session -func (c *Compiler) buildCreateAgentSessionStepConfig(data *WorkflowData, mainJobName string, threatDetectionEnabled bool) SafeOutputStepConfig { - cfg := data.SafeOutputs.CreateAgentSessions - specializedOutputsLog.Print("Building create-agent-session step config") - - var customEnvVars []string - customEnvVars = append(customEnvVars, c.buildStepLevelSafeOutputEnvVars(data, cfg.TargetRepoSlug)...) - customEnvVars = append(customEnvVars, buildAllowedReposEnvVar("GH_AW_ALLOWED_REPOS", cfg.AllowedRepos)...) - - condition := BuildSafeOutputType("create_agent_session") - - return SafeOutputStepConfig{ - StepName: "Create Agent Session", - StepID: "create_agent_session", - Script: "const { main } = require('${{ runner.temp }}/gh-aw/actions/create_agent_session.cjs'); await main();", - CustomEnvVars: customEnvVars, - Condition: condition, - Token: cfg.GitHubToken, - UseCopilotRequestsToken: true, - } -} diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index cbcba7692c5..54beee408bc 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -318,6 +318,17 @@ func (c *Compiler) buildHandlerManagerStep(data *WorkflowData) []string { consolidatedSafeOutputsStepsLog.Print("Added GH_AW_ASSIGN_TO_AGENT_TOKEN env var for assign-to-agent handler") } + // Add GH_AW_AGENT_SESSION_TOKEN when create-agent-session is configured. + // The create_agent_session handler passes this token as GH_TOKEN to the gh CLI + // (agent token preference chain), which is required because the default GITHUB_TOKEN + // does not have permission to create agent sessions via gh agent-task create. + if data.SafeOutputs != nil && data.SafeOutputs.CreateAgentSessions != nil { + agentSessionTokenStr := getEffectiveCopilotCodingAgentGitHubToken(data.SafeOutputs.CreateAgentSessions.GitHubToken) + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + steps = append(steps, fmt.Sprintf(" GH_AW_AGENT_SESSION_TOKEN: %s\n", agentSessionTokenStr)) + consolidatedSafeOutputsStepsLog.Print("Added GH_AW_AGENT_SESSION_TOKEN env var for create-agent-session handler") + } + // When create-pull-request or push-to-pull-request-branch is configured with a custom token // (including GitHub App), expose that token as GITHUB_TOKEN so that git CLI operations in // the JavaScript handlers can authenticate. The create_pull_request.cjs handler reads diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index cc0a31689b7..476ce389541 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -131,37 +131,6 @@ func (c *Compiler) buildStandardSafeOutputEnvVars(data *WorkflowData, targetRepo return customEnvVars } -// buildStepLevelSafeOutputEnvVars builds environment variables for consolidated safe output steps -// This excludes variables that are already set at the job level in consolidated jobs -func (c *Compiler) buildStepLevelSafeOutputEnvVars(data *WorkflowData, targetRepoSlug string) []string { - var customEnvVars []string - - // Only add target repo slug if it's different from the job-level setting - // (i.e., this step has a specific target-repo config that overrides the global trial mode target) - if targetRepoSlug != "" { - // Step-specific target repo overrides job-level setting - customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_TARGET_REPO_SLUG: %q\n", targetRepoSlug)) - } else if !c.trialMode && data.SafeOutputs.Staged { - // Step needs staged flag but there's no job-level target repo (not in trial mode) - // Job level only sets this if trialMode is true - customEnvVars = append(customEnvVars, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") - } - - // Note: The following are now set at job level and should NOT be included here: - // - GH_AW_WORKFLOW_NAME - // - GH_AW_WORKFLOW_SOURCE - // - GH_AW_WORKFLOW_SOURCE_URL - // - GH_AW_TRACKER_ID - // - GH_AW_ENGINE_ID - // - GH_AW_ENGINE_VERSION - // - GH_AW_ENGINE_MODEL - // - GH_AW_SAFE_OUTPUTS_STAGED (if in trial mode) - // - GH_AW_TARGET_REPO_SLUG (if in trial mode and no step override) - // - GH_AW_SAFE_OUTPUT_MESSAGES - - return customEnvVars -} - // buildEngineMetadataEnvVars builds engine metadata environment variables (id, version, model) // These are used by the JavaScript footer generation to create XML comment markers for traceability func buildEngineMetadataEnvVars(engineConfig *EngineConfig) []string { @@ -284,14 +253,3 @@ func (c *Compiler) addSafeOutputAgentGitHubTokenForConfig(steps *[]string, data effectiveToken := getEffectiveCopilotCodingAgentGitHubToken(effectiveCustomToken) *steps = append(*steps, fmt.Sprintf(" github-token: %s\n", effectiveToken)) } - -// buildAllowedReposEnvVar builds an allowed-repos environment variable line for safe-output jobs. -// envVarName should be the full env var name like "GH_AW_ALLOWED_REPOS". -// Returns an empty slice if allowedRepos is empty. -func buildAllowedReposEnvVar(envVarName string, allowedRepos []string) []string { - if len(allowedRepos) == 0 { - return nil - } - reposStr := strings.Join(allowedRepos, ",") - return []string{fmt.Sprintf(" %s: %q\n", envVarName, reposStr)} -}