diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 7d5d09b789c..1274c4cd2e0 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -4,10 +4,10 @@ # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/instructions/github-agentic-workflows.instructions.md name: "Dev" -"on": +on: push: branches: - - copilot/* + - copilot* workflow_dispatch: null permissions: {} @@ -101,157 +101,6 @@ jobs: steps: - run: echo "Activation success" - add_reaction: - needs: activation - if: > - github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_comment' || - github.event_name == 'pull_request_review_comment' || (github.event_name == 'pull_request') && - (github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - outputs: - reaction_id: ${{ steps.react.outputs.reaction-id }} - steps: - - name: Add eyes reaction to the triggering item - id: react - uses: actions/github-script@v8 - env: - GITHUB_AW_REACTION: eyes - with: - script: | - async function main() { - const reaction = process.env.GITHUB_AW_REACTION || "eyes"; - const command = process.env.GITHUB_AW_COMMAND; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - core.info(`Reaction type: ${reaction}`); - core.info(`Command name: ${command || "none"}`); - core.info(`Run ID: ${runId}`); - core.info(`Run URL: ${runUrl}`); - const validReactions = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]; - if (!validReactions.includes(reaction)) { - core.setFailed(`Invalid reaction type: ${reaction}. Valid reactions are: ${validReactions.join(", ")}`); - return; - } - let reactionEndpoint; - let commentUpdateEndpoint; - let shouldEditComment = false; - const eventName = context.eventName; - const owner = context.repo.owner; - const repo = context.repo.repo; - try { - switch (eventName) { - case "issues": - const issueNumber = context.payload?.issue?.number; - if (!issueNumber) { - core.setFailed("Issue number not found in event payload"); - return; - } - reactionEndpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`; - shouldEditComment = false; - break; - case "issue_comment": - const commentId = context.payload?.comment?.id; - if (!commentId) { - core.setFailed("Comment ID not found in event payload"); - return; - } - reactionEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`; - commentUpdateEndpoint = `/repos/${owner}/${repo}/issues/comments/${commentId}`; - shouldEditComment = command ? true : false; - break; - case "pull_request": - const prNumber = context.payload?.pull_request?.number; - if (!prNumber) { - core.setFailed("Pull request number not found in event payload"); - return; - } - reactionEndpoint = `/repos/${owner}/${repo}/issues/${prNumber}/reactions`; - shouldEditComment = false; - break; - case "pull_request_review_comment": - const reviewCommentId = context.payload?.comment?.id; - if (!reviewCommentId) { - core.setFailed("Review comment ID not found in event payload"); - return; - } - reactionEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}/reactions`; - commentUpdateEndpoint = `/repos/${owner}/${repo}/pulls/comments/${reviewCommentId}`; - shouldEditComment = command ? true : false; - break; - default: - core.setFailed(`Unsupported event type: ${eventName}`); - return; - } - core.info(`Reaction API endpoint: ${reactionEndpoint}`); - await addReaction(reactionEndpoint, reaction); - if (shouldEditComment && commentUpdateEndpoint) { - core.info(`Comment update endpoint: ${commentUpdateEndpoint}`); - await editCommentWithWorkflowLink(commentUpdateEndpoint, runUrl); - } else { - if (!command && commentUpdateEndpoint) { - core.info("Skipping comment edit - only available for command workflows"); - } else { - core.info(`Skipping comment edit for event type: ${eventName}`); - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to process reaction and comment edit: ${errorMessage}`); - core.setFailed(`Failed to process reaction and comment edit: ${errorMessage}`); - } - } - async function addReaction(endpoint, reaction) { - const response = await github.request("POST " + endpoint, { - content: reaction, - headers: { - Accept: "application/vnd.github+json", - }, - }); - const reactionId = response.data?.id; - if (reactionId) { - core.info(`Successfully added reaction: ${reaction} (id: ${reactionId})`); - core.setOutput("reaction-id", reactionId.toString()); - } else { - core.info(`Successfully added reaction: ${reaction}`); - core.setOutput("reaction-id", ""); - } - } - async function editCommentWithWorkflowLink(endpoint, runUrl) { - try { - const getResponse = await github.request("GET " + endpoint, { - headers: { - Accept: "application/vnd.github+json", - }, - }); - const originalBody = getResponse.data.body || ""; - const workflowLinkText = `\n\n---\n*🤖 [Workflow run](${runUrl}) triggered by this comment*`; - if (originalBody.includes("*🤖 [Workflow run](")) { - core.info("Comment already contains a workflow run link, skipping edit"); - return; - } - const updatedBody = originalBody + workflowLinkText; - const updateResponse = await github.request("PATCH " + endpoint, { - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - core.info(`Successfully updated comment with workflow link`); - core.info(`Comment ID: ${updateResponse.data.id}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning( - "Failed to edit comment with workflow link (This is not critical - the reaction was still added successfully): " + errorMessage - ); - } - } - await main(); - agent: needs: activation runs-on: ubuntu-latest @@ -369,6 +218,15 @@ jobs: EOF chmod +x .claude/hooks/network_permissions.py + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install GitHub Copilot CLI + run: npm install -g @github/copilot + - name: Setup Copilot CLI MCP Configuration + run: | + mkdir -p /tmp/.copilot - name: Setup agent output id: setup_agent_output uses: actions/github-script@v8 @@ -952,35 +810,27 @@ jobs: - name: Setup MCPs env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"print\":{\"inputs\":{\"message\":{\"description\":\"Message to print\",\"required\":true,\"type\":\"string\"}}}}" + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{}}" run: | mkdir -p /tmp/mcp-config - cat > /tmp/mcp-config/mcp-servers.json << 'EOF' + cat > /tmp/.copilot/mcp-config.json << 'EOF' { "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:sha-09deac4" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" - } + "GitHub": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp", + "headers": {}, + "tools": [ + "*" + ] }, "safe_outputs": { + "type": "local", "command": "node", "args": ["/tmp/safe-outputs/mcp-server.cjs"], "env": { "GITHUB_AW_SAFE_OUTPUTS": "${{ env.GITHUB_AW_SAFE_OUTPUTS }}", - "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}, - "GITHUB_AW_ASSETS_BRANCH": "${{ env.GITHUB_AW_ASSETS_BRANCH }}", - "GITHUB_AW_ASSETS_MAX_SIZE_KB": "${{ env.GITHUB_AW_ASSETS_MAX_SIZE_KB }}", - "GITHUB_AW_ASSETS_ALLOWED_EXTS": "${{ env.GITHUB_AW_ASSETS_ALLOWED_EXTS }}" + "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} } } } @@ -993,9 +843,7 @@ jobs: run: | mkdir -p $(dirname "$GITHUB_AW_PROMPT") cat > $GITHUB_AW_PROMPT << 'EOF' - Summarize and use print the message using the `print` tool. - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + Summarize the issue and post the summary in a comment. EOF - name: Append safe outputs instructions to prompt @@ -1006,10 +854,14 @@ jobs: --- - ## Reporting Missing Tools or Functionality + ## Creating an IssueReporting Missing Tools or Functionality **IMPORTANT**: To do the actions mentioned in the header of this section, use the **safe-outputs** tools, do NOT attempt to use `gh`, do NOT attempt to use the GitHub API. You don't have write access to the GitHub repo. + **Creating an Issue** + + To create an issue, use the create-issue tool from the safe-outputs MCP + EOF - name: Print prompt to step summary env: @@ -1027,12 +879,12 @@ jobs: const fs = require('fs'); const awInfo = { - engine_id: "claude", - engine_name: "Claude Code", + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", model: "", version: "", workflow_name: "Dev", - experimental: false, + experimental: true, supports_tools_allowlist: true, supports_http_transport: true, run_id: context.runId, @@ -1067,85 +919,22 @@ jobs: name: aw_info.json path: /tmp/aw_info.json if-no-files-found: warn - - name: Execute Claude Code CLI + - name: Execute GitHub Copilot CLI id: agentic_execution - # Allowed tools (sorted): - # - ExitPlanMode - # - Glob - # - Grep - # - LS - # - NotebookRead - # - Read - # - Task - # - TodoWrite - # - Write - # - mcp__github__download_workflow_run_artifact - # - mcp__github__get_code_scanning_alert - # - mcp__github__get_commit - # - mcp__github__get_dependabot_alert - # - mcp__github__get_discussion - # - mcp__github__get_discussion_comments - # - mcp__github__get_file_contents - # - mcp__github__get_issue - # - mcp__github__get_issue_comments - # - mcp__github__get_job_logs - # - mcp__github__get_me - # - mcp__github__get_notification_details - # - mcp__github__get_pull_request - # - mcp__github__get_pull_request_comments - # - mcp__github__get_pull_request_diff - # - mcp__github__get_pull_request_files - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_secret_scanning_alert - # - mcp__github__get_tag - # - mcp__github__get_workflow_run - # - mcp__github__get_workflow_run_logs - # - mcp__github__get_workflow_run_usage - # - mcp__github__list_branches - # - mcp__github__list_code_scanning_alerts - # - mcp__github__list_commits - # - mcp__github__list_dependabot_alerts - # - mcp__github__list_discussion_categories - # - mcp__github__list_discussions - # - mcp__github__list_issues - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_tags - # - mcp__github__list_workflow_jobs - # - mcp__github__list_workflow_run_artifacts - # - mcp__github__list_workflow_runs - # - mcp__github__list_workflows - # - mcp__github__search_code - # - mcp__github__search_issues - # - mcp__github__search_orgs - # - mcp__github__search_pull_requests - # - mcp__github__search_repositories - # - mcp__github__search_users timeout-minutes: 5 run: | set -o pipefail - # Execute Claude Code CLI with prompt from file - npx @anthropic-ai/claude-code@latest --print --mcp-config /tmp/mcp-config/mcp-servers.json --allowed-tools "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" --debug --verbose --permission-mode bypassPermissions --output-format json --settings /tmp/.claude/settings.json "$(cat /tmp/aw-prompts/prompt.txt)" 2>&1 | tee /tmp/dev.log + + INSTRUCTION=$(cat /tmp/aw-prompts/prompt.txt) + + # Run copilot CLI with log capture + copilot --log-level debug --log-dir /tmp/.copilot/logs/ --prompt "$INSTRUCTION" 2>&1 | tee /tmp/dev.log env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - DISABLE_TELEMETRY: "1" - DISABLE_ERROR_REPORTING: "1" - DISABLE_BUG_COMMAND: "1" - GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt - GITHUB_AW_MCP_CONFIG: /tmp/mcp-config/mcp-servers.json - MCP_TIMEOUT: "60000" GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" - - name: Ensure log file exists - if: always() - run: | - # Ensure log file exists - touch /tmp/dev.log - # Show last few lines for debugging - echo "=== Last 10 lines of Claude execution log ===" - tail -10 /tmp/dev.log || echo "No log content available" + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_COPILOT_CLI_TOKEN }} + XDG_CONFIG_HOME: /tmp/.copilot/ + XDG_STATE_HOME: /tmp/.copilot/ - name: Print Agent output env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} @@ -1176,7 +965,7 @@ jobs: uses: actions/github-script@v8 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"print\":{\"inputs\":{\"message\":{\"description\":\"Message to print\",\"required\":true,\"type\":\"string\"}}}}" + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{}}" with: script: | async function main() { @@ -1908,6 +1697,16 @@ jobs: name: agent_output.json path: ${{ env.GITHUB_AW_AGENT_OUTPUT }} if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@v4 + with: + name: agent_outputs + path: | + /tmp/.copilot/logs/ + if-no-files-found: ignore + - name: Clean up engine output files + run: | + rm -f /tmp/.copilot/logs/ - name: Upload MCP logs if: always() uses: actions/upload-artifact@v4 @@ -1915,419 +1714,169 @@ jobs: name: mcp-logs path: /tmp/mcp-logs/ if-no-files-found: ignore - - name: Parse agent logs for step summary + - name: Upload agent logs if: always() + uses: actions/upload-artifact@v4 + with: + name: dev.log + path: /tmp/dev.log + if-no-files-found: warn + + create_issue: + needs: agent + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.create_issue.outputs.issue_number }} + issue_url: ${{ steps.create_issue.outputs.issue_url }} + steps: + - name: Create Output Issue + id: create_issue uses: actions/github-script@v8 env: - GITHUB_AW_AGENT_OUTPUT: /tmp/dev.log + GITHUB_AW_AGENT_OUTPUT: ${{ needs.agent.outputs.output }} + GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" with: script: | - function main() { - const fs = require("fs"); - try { - const logFile = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!logFile) { - core.info("No agent log file specified"); - return; - } - if (!fs.existsSync(logFile)) { - core.info(`Log file not found: ${logFile}`); - return; - } - const logContent = fs.readFileSync(logFile, "utf8"); - const result = parseClaudeLog(logContent); - core.summary.addRaw(result.markdown).write(); - if (result.mcpFailures && result.mcpFailures.length > 0) { - const failedServers = result.mcpFailures.join(", "); - core.setFailed(`MCP server(s) failed to launch: ${failedServers}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.setFailed(errorMessage); + async function main() { + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - } - function parseClaudeLog(logContent) { + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - let logEntries; - try { - logEntries = JSON.parse(logContent); - if (!Array.isArray(logEntries)) { - throw new Error("Not a JSON array"); - } - } catch (jsonArrayError) { - logEntries = []; - const lines = logContent.split("\n"); - for (const line of lines) { - const trimmedLine = line.trim(); - if (trimmedLine === "") { - continue; - } - if (trimmedLine.startsWith("[{")) { - try { - const arrayEntries = JSON.parse(trimmedLine); - if (Array.isArray(arrayEntries)) { - logEntries.push(...arrayEntries); - continue; - } - } catch (arrayParseError) { - continue; - } - } - if (!trimmedLine.startsWith("{")) { - continue; - } - try { - const jsonEntry = JSON.parse(trimmedLine); - logEntries.push(jsonEntry); - } catch (jsonLineError) { - continue; - } - } - } - if (!Array.isArray(logEntries) || logEntries.length === 0) { - return { - markdown: "## Agent Log Summary\n\nLog format not recognized as Claude JSON array or JSONL.\n", - mcpFailures: [], - }; - } - let markdown = ""; - const mcpFailures = []; - const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); - if (initEntry) { - markdown += "## 🚀 Initialization\n\n"; - const initResult = formatInitializationSummary(initEntry); - markdown += initResult.markdown; - mcpFailures.push(...initResult.mcpFailures); - markdown += "\n"; - } - markdown += "## 🤖 Commands and Tools\n\n"; - const toolUsePairs = new Map(); - const commandSummary = []; - for (const entry of logEntries) { - if (entry.type === "user" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_result" && content.tool_use_id) { - toolUsePairs.set(content.tool_use_id, content); - } - } - } - } - for (const entry of logEntries) { - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_use") { - const toolName = content.name; - const input = content.input || {}; - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; - } - const toolResult = toolUsePairs.get(content.id); - let statusIcon = "❓"; - if (toolResult) { - statusIcon = toolResult.is_error === true ? "❌" : "✅"; - } - if (toolName === "Bash") { - const formattedCommand = formatBashCommand(input.command || ""); - commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``); - } else if (toolName.startsWith("mcp__")) { - const mcpName = formatMcpName(toolName); - commandSummary.push(`* ${statusIcon} \`${mcpName}(...)\``); - } else { - commandSummary.push(`* ${statusIcon} ${toolName}`); - } - } - } - } - } - if (commandSummary.length > 0) { - for (const cmd of commandSummary) { - markdown += `${cmd}\n`; - } - } else { - markdown += "No commands or tools used.\n"; - } - markdown += "\n## 📊 Information\n\n"; - const lastEntry = logEntries[logEntries.length - 1]; - if (lastEntry && (lastEntry.num_turns || lastEntry.duration_ms || lastEntry.total_cost_usd || lastEntry.usage)) { - if (lastEntry.num_turns) { - markdown += `**Turns:** ${lastEntry.num_turns}\n\n`; - } - if (lastEntry.duration_ms) { - const durationSec = Math.round(lastEntry.duration_ms / 1000); - const minutes = Math.floor(durationSec / 60); - const seconds = durationSec % 60; - markdown += `**Duration:** ${minutes}m ${seconds}s\n\n`; - } - if (lastEntry.total_cost_usd) { - markdown += `**Total Cost:** $${lastEntry.total_cost_usd.toFixed(4)}\n\n`; - } - if (lastEntry.usage) { - const usage = lastEntry.usage; - if (usage.input_tokens || usage.output_tokens) { - markdown += `**Token Usage:**\n`; - if (usage.input_tokens) markdown += `- Input: ${usage.input_tokens.toLocaleString()}\n`; - if (usage.cache_creation_input_tokens) markdown += `- Cache Creation: ${usage.cache_creation_input_tokens.toLocaleString()}\n`; - if (usage.cache_read_input_tokens) markdown += `- Cache Read: ${usage.cache_read_input_tokens.toLocaleString()}\n`; - if (usage.output_tokens) markdown += `- Output: ${usage.output_tokens.toLocaleString()}\n`; - markdown += "\n"; - } - } - if (lastEntry.permission_denials && lastEntry.permission_denials.length > 0) { - markdown += `**Permission Denials:** ${lastEntry.permission_denials.length}\n\n`; - } - } - markdown += "\n## 🤖 Reasoning\n\n"; - for (const entry of logEntries) { - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "text" && content.text) { - const text = content.text.trim(); - if (text && text.length > 0) { - markdown += text + "\n\n"; - } - } else if (content.type === "tool_use") { - const toolResult = toolUsePairs.get(content.id); - const toolMarkdown = formatToolUse(content, toolResult); - if (toolMarkdown) { - markdown += toolMarkdown; - } - } - } - } - } - return { markdown, mcpFailures }; + validatedOutput = JSON.parse(outputContent); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - markdown: `## Agent Log Summary\n\nError parsing Claude log (tried both JSON array and JSONL formats): ${errorMessage}\n`, - mcpFailures: [], - }; - } - } - function formatInitializationSummary(initEntry) { - let markdown = ""; - const mcpFailures = []; - if (initEntry.model) { - markdown += `**Model:** ${initEntry.model}\n\n`; + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - if (initEntry.session_id) { - markdown += `**Session ID:** ${initEntry.session_id}\n\n`; + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; } - if (initEntry.cwd) { - const cleanCwd = initEntry.cwd.replace(/^\/home\/runner\/work\/[^\/]+\/[^\/]+/, "."); - markdown += `**Working Directory:** ${cleanCwd}\n\n`; + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; } - if (initEntry.mcp_servers && Array.isArray(initEntry.mcp_servers)) { - markdown += "**MCP Servers:**\n"; - for (const server of initEntry.mcp_servers) { - const statusIcon = server.status === "connected" ? "✅" : server.status === "failed" ? "❌" : "❓"; - markdown += `- ${statusIcon} ${server.name} (${server.status})\n`; - if (server.status === "failed") { - mcpFailures.push(server.name); + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - markdown += "\n"; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; } - if (initEntry.tools && Array.isArray(initEntry.tools)) { - markdown += "**Available Tools:**\n"; - const categories = { - Core: [], - "File Operations": [], - "Git/GitHub": [], - MCP: [], - Other: [], - }; - for (const tool of initEntry.tools) { - if (["Task", "Bash", "BashOutput", "KillBash", "ExitPlanMode"].includes(tool)) { - categories["Core"].push(tool); - } else if (["Read", "Edit", "MultiEdit", "Write", "LS", "Grep", "Glob", "NotebookEdit"].includes(tool)) { - categories["File Operations"].push(tool); - } else if (tool.startsWith("mcp__github__")) { - categories["Git/GitHub"].push(formatMcpName(tool)); - } else if (tool.startsWith("mcp__") || ["ListMcpResourcesTool", "ReadMcpResourceTool"].includes(tool)) { - categories["MCP"].push(tool.startsWith("mcp__") ? formatMcpName(tool) : tool); - } else { - categories["Other"].push(tool); - } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); } - for (const [category, tools] of Object.entries(categories)) { - if (tools.length > 0) { - markdown += `- **${category}:** ${tools.length} tools\n`; - if (tools.length <= 5) { - markdown += ` - ${tools.join(", ")}\n`; - } else { - markdown += ` - ${tools.slice(0, 3).join(", ")}, and ${tools.length - 3} more\n`; - } - } + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - markdown += "\n"; - } - if (initEntry.slash_commands && Array.isArray(initEntry.slash_commands)) { - const commandCount = initEntry.slash_commands.length; - markdown += `**Slash Commands:** ${commandCount} available\n`; - if (commandCount <= 10) { - markdown += `- ${initEntry.slash_commands.join(", ")}\n`; - } else { - markdown += `- ${initEntry.slash_commands.slice(0, 5).join(", ")}, and ${commandCount - 5} more\n`; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - markdown += "\n"; - } - return { markdown, mcpFailures }; - } - function formatToolUse(toolUse, toolResult) { - const toolName = toolUse.name; - const input = toolUse.input || {}; - if (toolName === "TodoWrite") { - return ""; - } - function getStatusIcon() { - if (toolResult) { - return toolResult.is_error === true ? "❌" : "✅"; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - return "❓"; - } - let markdown = ""; - const statusIcon = getStatusIcon(); - switch (toolName) { - case "Bash": - const command = input.command || ""; - const description = input.description || ""; - const formattedCommand = formatBashCommand(command); - if (description) { - markdown += `${description}:\n\n`; - } - markdown += `${statusIcon} \`${formattedCommand}\`\n\n`; - break; - case "Read": - const filePath = input.file_path || input.path || ""; - const relativePath = filePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); - markdown += `${statusIcon} Read \`${relativePath}\`\n\n`; - break; - case "Write": - case "Edit": - case "MultiEdit": - const writeFilePath = input.file_path || input.path || ""; - const writeRelativePath = writeFilePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); - markdown += `${statusIcon} Write \`${writeRelativePath}\`\n\n`; - break; - case "Grep": - case "Glob": - const query = input.query || input.pattern || ""; - markdown += `${statusIcon} Search for \`${truncateString(query, 80)}\`\n\n`; - break; - case "LS": - const lsPath = input.path || ""; - const lsRelativePath = lsPath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); - markdown += `${statusIcon} LS: ${lsRelativePath || lsPath}\n\n`; - break; - default: - if (toolName.startsWith("mcp__")) { - const mcpName = formatMcpName(toolName); - const params = formatMcpParameters(input); - markdown += `${statusIcon} ${mcpName}(${params})\n\n`; - } else { - const keys = Object.keys(input); - if (keys.length > 0) { - const mainParam = keys.find(k => ["query", "command", "path", "file_path", "content"].includes(k)) || keys[0]; - const value = String(input[mainParam] || ""); - if (value) { - markdown += `${statusIcon} ${toolName}: ${truncateString(value, 100)}\n\n`; - } else { - markdown += `${statusIcon} ${toolName}\n\n`; - } - } else { - markdown += `${statusIcon} ${toolName}\n\n`; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } } - } - return markdown; - } - function formatMcpName(toolName) { - if (toolName.startsWith("mcp__")) { - const parts = toolName.split("__"); - if (parts.length >= 3) { - const provider = parts[1]; - const method = parts.slice(2).join("_"); - return `${provider}::${method}`; + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } } - return toolName; - } - function formatMcpParameters(input) { - const keys = Object.keys(input); - if (keys.length === 0) return ""; - const paramStrs = []; - for (const key of keys.slice(0, 4)) { - const value = String(input[key] || ""); - paramStrs.push(`${key}: ${truncateString(value, 40)}`); - } - if (keys.length > 4) { - paramStrs.push("..."); - } - return paramStrs.join(", "); - } - function formatBashCommand(command) { - if (!command) return ""; - let formatted = command - .replace(/\n/g, " ") - .replace(/\r/g, " ") - .replace(/\t/g, " ") - .replace(/\s+/g, " ") - .trim(); - formatted = formatted.replace(/`/g, "\\`"); - const maxLength = 80; - if (formatted.length > maxLength) { - formatted = formatted.substring(0, maxLength) + "..."; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - return formatted; - } - function truncateString(str, maxLength) { - if (!str) return ""; - if (str.length <= maxLength) return str; - return str.substring(0, maxLength) + "..."; - } - if (typeof module !== "undefined" && module.exports) { - module.exports = { - parseClaudeLog, - formatToolUse, - formatInitializationSummary, - formatBashCommand, - truncateString, - }; + core.info(`Successfully created ${createdIssues.length} issue(s)`); } - main(); - - name: Upload agent logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: dev.log - path: /tmp/dev.log - if-no-files-found: warn - - print: - needs: agent - runs-on: ubuntu-latest - steps: - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@v5 - with: - name: agent_output.json - path: /tmp/safe-jobs/ - - name: Setup Safe Job Environment Variables - run: | - echo "Setting up environment for safe job" - echo "GITHUB_AW_AGENT_OUTPUT=/tmp/safe-jobs/agent_output.json" >> $GITHUB_ENV - - name: See artifacts - run: cd /tmp/safe-jobs && ls -lR - - name: print message - run: |- - if [ -f "$GITHUB_AW_AGENT_OUTPUT" ]; then - MESSAGE=$(cat "$GITHUB_AW_AGENT_OUTPUT" | jq -r '.items[] | select(.type == "print") | .message') - echo "print: $MESSAGE" - echo "### Print Step Summary" >> "$GITHUB_STEP_SUMMARY" - echo "$MESSAGE" >> "$GITHUB_STEP_SUMMARY" - else - echo "No agent output found, using default: Hello from safe-job!" - fi + (async () => { + await main(); + })(); diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 7887cddb483..d963c91d6ab 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -1,36 +1,12 @@ --- on: workflow_dispatch: - reaction: "eyes" push: branches: - - copilot/* -engine: claude + - copilot* +engine: copilot safe-outputs: - staged: true -safe-jobs: - print: - #name: "print the message" - runs-on: ubuntu-latest - inputs: - message: - description: "Message to print" - required: true - type: string - steps: - - name: See artifacts - run: cd /tmp/safe-jobs && ls -lR - - name: print message - run: | - if [ -f "$GITHUB_AW_AGENT_OUTPUT" ]; then - MESSAGE=$(cat "$GITHUB_AW_AGENT_OUTPUT" | jq -r '.items[] | select(.type == "print") | .message') - echo "print: $MESSAGE" - echo "### Print Step Summary" >> "$GITHUB_STEP_SUMMARY" - echo "$MESSAGE" >> "$GITHUB_STEP_SUMMARY" - else - echo "No agent output found, using default: Hello from safe-job!" - fi + create-issue: + staged: true --- -Summarize and use print the message using the `print` tool. - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +Summarize the issue and post the summary in a comment. diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index e3270b4dc71..b0dc8269520 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -20,8 +20,8 @@ var verbose bool // validateEngine validates the engine flag value func validateEngine(engine string) error { - if engine != "" && engine != "claude" && engine != "codex" { - return fmt.Errorf("invalid engine value '%s'. Must be 'claude' or 'codex'", engine) + if engine != "" && engine != "claude" && engine != "codex" && engine != "copilot" { + return fmt.Errorf("invalid engine value '%s'. Must be 'copilot', 'claude' or 'codex'", engine) } return nil } diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 74c1a6217e0..92ff25f3f8c 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -7,9 +7,9 @@ sidebar: GitHub Agentic Workflows support multiple AI engines to interpret and execute natural language instructions. Each engine has unique capabilities and configuration options. -## Available Engines +## Agentic Engines -### Claude (Default) +### Anthropic Claude Code (Default) Claude Code is the default and recommended AI engine for most workflows. It excels at reasoning, code analysis, and understanding complex contexts. @@ -29,13 +29,23 @@ engine: DEBUG_MODE: "true" ``` -**Features:** -- Excellent reasoning and code analysis capabilities -- Supports max-turns for cost control -- Uses MCP servers for tool integration -- Generates `mcp-servers.json` configuration +#### Secrets + +- `ANTHROPIC_API_KEY` secret is required for authentication. + +### GitHub Copilot (Experimental) -### Codex (Experimental) +[GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-copilot-cli) + +```yaml +engine: copilot +``` + +#### Secrets + +- `GITHUB_COPILOT_CLI_TOKEN` secret is required for authentication. + +### OpenAI Codex (Experimental) OpenAI Codex CLI with MCP server support. Designed for code-focused tasks and integration scenarios. @@ -72,6 +82,10 @@ engine: - **`user-agent`** (optional): Custom user agent string for GitHub MCP server configuration - **`config`** (optional): Additional TOML configuration text appended to generated config.toml +#### Secrets + +- `OPENAI_API_KEY` secret is required for authentication. + ### Custom Engine For advanced users who want to define completely custom GitHub Actions steps instead of using AI interpretation. diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index 436bc628e66..386bb63944e 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -127,9 +127,10 @@ func (b *InteractiveWorkflowBuilder) promptForTrigger() error { // promptForEngine asks the user to select the AI engine func (b *InteractiveWorkflowBuilder) promptForEngine() error { engineOptions := []huh.Option[string]{ - huh.NewOption("claude - Anthropic Claude Code coding agent", "claude"), - huh.NewOption("codex - OpenAI Codex engine", "codex"), - huh.NewOption("custom - Custom engine configuration", "custom"), + huh.NewOption("copilot - GitHub Copilot CLI", "copilot"), + huh.NewOption("claude - Anthropic Claude Code", "claude"), + huh.NewOption("codex - OpenAI Codex", "codex"), + huh.NewOption("custom - Custom configuration", "custom"), } form := huh.NewForm( diff --git a/pkg/cli/logs.go b/pkg/cli/logs.go index b7e941dc585..ca750109943 100644 --- a/pkg/cli/logs.go +++ b/pkg/cli/logs.go @@ -357,7 +357,7 @@ func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, ou if detectedEngine != nil { // Get the engine ID to compare with the filter registry := workflow.GetGlobalEngineRegistry() - for _, supportedEngine := range []string{"claude", "codex"} { + for _, supportedEngine := range constants.AgenticEngines { if testEngine, err := registry.GetEngine(supportedEngine); err == nil && testEngine == detectedEngine { engineMatches = (supportedEngine == engine) break @@ -371,7 +371,7 @@ func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, ou if detectedEngine != nil { // Try to get a readable name for the detected engine registry := workflow.GetGlobalEngineRegistry() - for _, supportedEngine := range []string{"claude", "codex"} { + for _, supportedEngine := range constants.AgenticEngines { if testEngine, err := registry.GetEngine(supportedEngine); err == nil && testEngine == detectedEngine { engineName = supportedEngine break diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 7086a6c5f2a..998ced1e5af 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -68,3 +68,5 @@ var AllowedExpressions = []string{ const AgentJobName = "agent" const SafeOutputArtifactName = "safe_output.jsonl" const AgentOutputArtifactName = "agent_output.json" + +var AgenticEngines = []string{"claude", "codex", "copilot"} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 81a8d2fdd78..161f411b802 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -815,11 +815,12 @@ { "type": "string", "enum": [ + "copilot", "claude", "codex", "custom" ], - "description": "Simple engine name (claude, codex, or custom)" + "description": "Agentic engine (copilot, claude, codex, or custom)" }, { "type": "object", diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 657499cb648..d4159b0b02b 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -138,6 +138,7 @@ func NewEngineRegistry() *EngineRegistry { } // Register built-in engines + registry.Register(NewCopilotEngine()) registry.Register(NewClaudeEngine()) registry.Register(NewCodexEngine()) registry.Register(NewCustomEngine()) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index e1b09abc831..db02c1749e7 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -137,7 +137,7 @@ type WorkflowData struct { RunsOn string Tools map[string]any MarkdownContent string - AI string // "claude" or "codex" (for backwards compatibility) + AI string // "copilot", "claude" or "codex" (for backwards compatibility) EngineConfig *EngineConfig // Extended engine configuration StopTime string Command string // for /command trigger support diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go new file mode 100644 index 00000000000..249aa2087e2 --- /dev/null +++ b/pkg/workflow/copilot_engine.go @@ -0,0 +1,427 @@ +package workflow + +import ( + "fmt" + "sort" + "strings" +) + +const tempFolder = "/tmp/.copilot/" +const logsFolder = tempFolder + "logs/" + +// CopilotEngine represents the GitHub Copilot CLI agentic engine +type CopilotEngine struct { + BaseEngine +} + +func NewCopilotEngine() *CopilotEngine { + return &CopilotEngine{ + BaseEngine: BaseEngine{ + id: "copilot", + displayName: "GitHub Copilot CLI", + description: "Uses GitHub Copilot CLI with MCP server support", + experimental: true, + supportsToolsAllowlist: true, + supportsHTTPTransport: true, // Copilot CLI supports HTTP transport via MCP + supportsMaxTurns: false, // Copilot CLI does not support max-turns feature yet + }, + } +} + +func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { + // Build the npm install command, optionally with version + installCmd := "npm install -g @github/copilot" + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { + installCmd = fmt.Sprintf("npm install -g @github/copilot@%s", workflowData.EngineConfig.Version) + } + + var steps []GitHubActionStep + + // Check if network permissions are configured (only for Copilot engine) + if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID == "copilot" && ShouldEnforceNetworkPermissions(workflowData.NetworkPermissions) { + // Generate network hook generator and settings generator + hookGenerator := &NetworkHookGenerator{} + settingsGenerator := &ClaudeSettingsGenerator{} // Using Claude settings generator as it's generic + + allowedDomains := GetAllowedDomains(workflowData.NetworkPermissions) + + // Add settings generation step + settingsStep := settingsGenerator.GenerateSettingsWorkflowStep() + steps = append(steps, settingsStep) + + // Add hook generation step + hookStep := hookGenerator.GenerateNetworkHookWorkflowStep(allowedDomains) + steps = append(steps, hookStep) + } + + installationSteps := []GitHubActionStep{ + { + " - name: Setup Node.js", + " uses: actions/setup-node@v4", + " with:", + " node-version: '22'", + }, + { + " - name: Install GitHub Copilot CLI", + fmt.Sprintf(" run: %s", installCmd), + }, + { + " - name: Setup Copilot CLI MCP Configuration", + " run: |", + " mkdir -p /tmp/.copilot", + }, + } + + steps = append(steps, installationSteps...) + return steps +} + +func (e *CopilotEngine) GetDeclaredOutputFiles() []string { + return []string{logsFolder} +} + +// GetExecutionSteps returns the GitHub Actions steps for executing GitHub Copilot CLI +func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep { + var steps []GitHubActionStep + + // Handle custom steps if they exist in engine config + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Steps) > 0 { + for _, step := range workflowData.EngineConfig.Steps { + stepYAML, err := e.convertStepToYAML(step) + if err != nil { + // Log error but continue with other steps + continue + } + steps = append(steps, GitHubActionStep{stepYAML}) + } + } + + // Build copilot CLI arguments based on configuration + var copilotArgs []string = []string{"--log-level", "debug", "--log-dir", logsFolder} + + // Add model if specified (check if Copilot CLI supports this) + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" { + copilotArgs = append(copilotArgs, "--model", workflowData.EngineConfig.Model) + } + + copilotArgs = append(copilotArgs, "--prompt", "\"$INSTRUCTION\"") + command := fmt.Sprintf(`set -o pipefail + +INSTRUCTION=$(cat /tmp/aw-prompts/prompt.txt) + +# Run copilot CLI with log capture +copilot %s 2>&1 | tee %s`, strings.Join(copilotArgs, " "), logFile) + + env := map[string]string{ + "XDG_CONFIG_HOME": tempFolder, // copilot help environment + "XDG_STATE_HOME": tempFolder, // copilot cache environment + "GITHUB_TOKEN": "${{ secrets.GITHUB_COPILOT_CLI_TOKEN }}", + "GITHUB_STEP_SUMMARY": "${{ env.GITHUB_STEP_SUMMARY }}", + } + + // Add GITHUB_AW_SAFE_OUTPUTS if output is needed + hasOutput := workflowData.SafeOutputs != nil + if hasOutput { + env["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + } + + // Add custom environment variables from engine config + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + for key, value := range workflowData.EngineConfig.Env { + env[key] = value + } + } + + // Generate the step for Copilot CLI execution + stepName := "Execute GitHub Copilot CLI" + var stepLines []string + + stepLines = append(stepLines, fmt.Sprintf(" - name: %s", stepName)) + stepLines = append(stepLines, " id: agentic_execution") + + // Add timeout at step level (GitHub Actions standard) + if workflowData.TimeoutMinutes != "" { + stepLines = append(stepLines, fmt.Sprintf(" timeout-minutes: %s", strings.TrimPrefix(workflowData.TimeoutMinutes, "timeout_minutes: "))) + } else { + stepLines = append(stepLines, " timeout-minutes: 5") // Default timeout + } + + stepLines = append(stepLines, " run: |") + + // Split command into lines and indent them properly + commandLines := strings.Split(command, "\n") + for _, line := range commandLines { + stepLines = append(stepLines, " "+line) + } + + // Add environment variables + if len(env) > 0 { + stepLines = append(stepLines, " env:") + // Sort environment keys for consistent output + envKeys := make([]string, 0, len(env)) + for key := range env { + envKeys = append(envKeys, key) + } + sort.Strings(envKeys) + + for _, key := range envKeys { + value := env[key] + stepLines = append(stepLines, fmt.Sprintf(" %s: %s", key, value)) + } + } + + steps = append(steps, GitHubActionStep(stepLines)) + + return steps +} + +// convertStepToYAML converts a step map to YAML string - uses proper YAML serialization +func (e *CopilotEngine) convertStepToYAML(stepMap map[string]any) (string, error) { + return ConvertStepToYAML(stepMap) +} + +func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) { + yaml.WriteString(" cat > /tmp/.copilot/mcp-config.json << 'EOF'\n") + yaml.WriteString(" {\n") + yaml.WriteString(" \"mcpServers\": {\n") + + // Add safe-outputs MCP server if safe-outputs are configured + totalServers := len(mcpTools) + serverCount := 0 + + // Generate configuration for each MCP tool using shared logic + for _, toolName := range mcpTools { + serverCount++ + isLast := serverCount == totalServers + + switch toolName { + case "github": + githubTool := tools["github"] + e.renderGitHubCopilotMCPConfig(yaml, githubTool, isLast) + case "playwright": + playwrightTool := tools["playwright"] + e.renderPlaywrightCopilotMCPConfig(yaml, playwrightTool, isLast, workflowData.NetworkPermissions) + case "cache-memory": + e.renderCacheMemoryCopilotMCPConfig(yaml, isLast, workflowData) + case "safe-outputs": + e.renderSafeOutputsCopilotMCPConfig(yaml, isLast) + default: + // Handle custom MCP tools (those with MCP-compatible type) + if toolConfig, ok := tools[toolName].(map[string]any); ok { + if hasMcp, _ := hasMCPConfig(toolConfig); hasMcp { + if err := e.renderCopilotMCPConfig(yaml, toolName, toolConfig, isLast); err != nil { + fmt.Printf("Error generating custom MCP configuration for %s: %v\n", toolName, err) + } + } + } + } + } + + yaml.WriteString(" }\n") + yaml.WriteString(" }\n") + yaml.WriteString(" EOF\n") +} + +// renderGitHubCopilotMCPConfig generates the GitHub MCP server configuration for Copilot CLI +func (e *CopilotEngine) renderGitHubCopilotMCPConfig(yaml *strings.Builder, githubTool any, isLast bool) { + yaml.WriteString(" \"GitHub\": {\n") + yaml.WriteString(" \"type\": \"http\",\n") + yaml.WriteString(" \"url\": \"https://api.githubcopilot.com/mcp\",\n") + yaml.WriteString(" \"headers\": {},\n") + yaml.WriteString(" \"tools\": [\n") + yaml.WriteString(" \"*\"\n") + yaml.WriteString(" ]\n") + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// renderCopilotMCPConfig generates custom MCP server configuration for a single tool in Copilot CLI mcpconfig +func (e *CopilotEngine) renderCopilotMCPConfig(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { + yaml.WriteString(fmt.Sprintf(" \"%s\": {\n", toolName)) + + // Use the shared MCP config renderer with JSON format + renderer := MCPConfigRenderer{ + IndentLevel: " ", + Format: "json", + } + + err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer) + if err != nil { + return err + } + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } + + return nil +} + +// renderPlaywrightCopilotMCPConfig generates the Playwright MCP server configuration for Copilot CLI +// Uses npx to launch Playwright MCP instead of Docker for better performance and simplicity +func (e *CopilotEngine) renderPlaywrightCopilotMCPConfig(yaml *strings.Builder, playwrightTool any, isLast bool, networkPermissions *NetworkPermissions) { + args := generatePlaywrightDockerArgs(playwrightTool, networkPermissions) + + yaml.WriteString(" \"playwright\": {\n") + yaml.WriteString(" \"type\": \"local\",\n") + yaml.WriteString(" \"command\": \"npx\",\n") + yaml.WriteString(" \"args\": [\n") + yaml.WriteString(" \"@playwright/mcp@latest\",\n") + if len(args.AllowedDomains) > 0 { + yaml.WriteString(" \"--allowed-origins\",\n") + yaml.WriteString(" \"" + strings.Join(args.AllowedDomains, ";") + "\"\n") + } + yaml.WriteString(" ]\n") + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// renderCacheMemoryCopilotMCPConfig handles cache-memory configuration without MCP server mounting +// Cache-memory is now a simple file share, not an MCP server +func (e *CopilotEngine) renderCacheMemoryCopilotMCPConfig(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) { + // Cache-memory no longer uses MCP server mounting + // The cache folder is available as a simple file share at /tmp/cache-memory/ + // The folder is created by the cache step and is accessible to all tools + // No MCP configuration is needed for simple file access +} + +// renderSafeOutputsCopilotMCPConfig generates the Safe Outputs MCP server configuration for Copilot CLI +func (e *CopilotEngine) renderSafeOutputsCopilotMCPConfig(yaml *strings.Builder, isLast bool) { + yaml.WriteString(" \"safe_outputs\": {\n") + yaml.WriteString(" \"type\": \"local\",\n") + yaml.WriteString(" \"command\": \"node\",\n") + yaml.WriteString(" \"args\": [\"/tmp/safe-outputs/mcp-server.cjs\"],\n") + yaml.WriteString(" \"env\": {\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}\n") + yaml.WriteString(" }\n") + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// ParseLogMetrics implements engine-specific log parsing for Copilot CLI +func (e *CopilotEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { + var metrics LogMetrics + var maxTokenUsage int + + lines := strings.Split(logContent, "\n") + toolCallMap := make(map[string]*ToolCallInfo) // Track tool calls + var currentSequence []string // Track tool sequence + turns := 0 + + for _, line := range lines { + // Skip empty lines + if strings.TrimSpace(line) == "" { + continue + } + + // Count turns based on interaction patterns (adjust based on actual Copilot CLI output) + if strings.Contains(line, "User:") || strings.Contains(line, "Human:") || strings.Contains(line, "Query:") { + turns++ + // Start of a new turn, save previous sequence if any + if len(currentSequence) > 0 { + metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) + currentSequence = []string{} + } + } + + // Extract tool calls and add to sequence (adjust based on actual Copilot CLI output format) + if toolName := e.parseCopilotToolCallsWithSequence(line, toolCallMap); toolName != "" { + currentSequence = append(currentSequence, toolName) + } + + // Try to extract token usage from JSON format if available + jsonMetrics := ExtractJSONMetrics(line, verbose) + if jsonMetrics.TokenUsage > 0 || jsonMetrics.EstimatedCost > 0 { + if jsonMetrics.TokenUsage > maxTokenUsage { + maxTokenUsage = jsonMetrics.TokenUsage + } + if jsonMetrics.EstimatedCost > 0 { + metrics.EstimatedCost += jsonMetrics.EstimatedCost + } + } + + // Count errors and warnings + lowerLine := strings.ToLower(line) + if strings.Contains(lowerLine, "error") { + metrics.ErrorCount++ + } + if strings.Contains(lowerLine, "warning") { + metrics.WarningCount++ + } + } + + // Add final sequence if any + if len(currentSequence) > 0 { + metrics.ToolSequences = append(metrics.ToolSequences, currentSequence) + } + + metrics.TokenUsage = maxTokenUsage + metrics.Turns = turns + + // Convert tool call map to slice + for _, toolInfo := range toolCallMap { + metrics.ToolCalls = append(metrics.ToolCalls, *toolInfo) + } + + // Sort tool calls by name for consistent output + sort.Slice(metrics.ToolCalls, func(i, j int) bool { + return metrics.ToolCalls[i].Name < metrics.ToolCalls[j].Name + }) + + return metrics +} + +// parseCopilotToolCallsWithSequence extracts tool call information from Copilot CLI log lines and returns tool name +func (e *CopilotEngine) parseCopilotToolCallsWithSequence(line string, toolCallMap map[string]*ToolCallInfo) string { + // This method needs to be adjusted based on actual Copilot CLI output format + // For now, using a generic approach that can be refined once we see actual logs + + // Look for common tool call patterns (adjust based on actual Copilot CLI output) + if strings.Contains(line, "calling") || strings.Contains(line, "tool:") || strings.Contains(line, "function:") { + // Extract tool name from various possible formats + toolName := "" + if strings.Contains(line, "github") { + toolName = "github" + } else if strings.Contains(line, "playwright") { + toolName = "playwright" + } else if strings.Contains(line, "safe") && strings.Contains(line, "output") { + toolName = "safe_outputs" + } + + if toolName != "" { + // Initialize or update tool call info + if toolInfo, exists := toolCallMap[toolName]; exists { + toolInfo.CallCount++ + } else { + toolCallMap[toolName] = &ToolCallInfo{ + Name: toolName, + CallCount: 1, + MaxOutputSize: 0, // TODO: Extract output size from results if available + } + } + return toolName + } + } + + return "" +} + +// GetLogParserScript returns the JavaScript script name for parsing Copilot logs +func (e *CopilotEngine) GetLogParserScriptId() string { + return "parse_copilot_log" +}