diff --git a/.github/instructions/github-agentic-workflows.instructions.md b/.github/instructions/github-agentic-workflows.instructions.md index 3330bd0eac3..b2a97345e37 100644 --- a/.github/instructions/github-agentic-workflows.instructions.md +++ b/.github/instructions/github-agentic-workflows.instructions.md @@ -795,6 +795,7 @@ gh aw logs weekly-research # Filter logs by AI engine type gh aw logs --engine claude # Only Claude workflows gh aw logs --engine codex # Only Codex workflows +gh aw logs --engine copilot # Only Copilot workflows # Limit number of runs and filter by date (absolute dates) gh aw logs -c 10 --start-date 2024-01-01 --end-date 2024-01-31 diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 7d5d09b789c..3cd51dcf9f6 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -369,6 +369,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 @@ -955,32 +964,24 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"print\":{\"inputs\":{\"message\":{\"description\":\"Message to print\",\"required\":true,\"type\":\"string\"}}}}" 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) }} } } } @@ -1027,12 +1028,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 +1068,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 --add-dir /tmp/ --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.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 }} @@ -1908,6 +1846,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 -fr /tmp/.copilot/logs/ - name: Upload MCP logs if: always() uses: actions/upload-artifact@v4 @@ -1925,375 +1873,102 @@ jobs: function main() { const fs = require("fs"); try { - const logFile = process.env.GITHUB_AW_AGENT_OUTPUT; + const logFile = process.env.AGENT_LOG_FILE; if (!logFile) { - core.info("No agent log file specified"); + console.log("No agent log file specified"); return; } if (!fs.existsSync(logFile)) { - core.info(`Log file not found: ${logFile}`); + console.log(`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}`); + const content = fs.readFileSync(logFile, "utf8"); + const parsedLog = parseCopilotLog(content); + if (parsedLog) { + core.summary.addRaw(parsedLog).write(); + console.log("Copilot log parsed successfully"); + } else { + console.log("Failed to parse Copilot log"); } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.setFailed(errorMessage); + core.setFailed(error.message); } } - function parseClaudeLog(logContent) { + function parseCopilotLog(logContent) { 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}`); - } + const lines = logContent.split("\n"); + let markdown = "## 🤖 GitHub Copilot CLI Execution\n\n"; + let hasOutput = false; + let inCodeBlock = false; + let currentCodeBlock = ""; + let currentLanguage = ""; + for (const line of lines) { + if (line.trim().startsWith("```")) { + if (!inCodeBlock) { + inCodeBlock = true; + currentLanguage = line.trim().substring(3); + currentCodeBlock = ""; + } else { + inCodeBlock = false; + if (currentCodeBlock.trim()) { + markdown += `\`\`\`${currentLanguage}\n${currentCodeBlock}\`\`\`\n\n`; + hasOutput = true; } + currentCodeBlock = ""; + currentLanguage = ""; } + continue; } - } - 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`; + if (inCodeBlock) { + currentCodeBlock += line + "\n"; + continue; } - } - 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; - } - } - } + if (line.includes("copilot -p") || line.includes("github copilot")) { + markdown += `**Command:** \`${line.trim()}\`\n\n`; + hasOutput = true; } - } - return { markdown, mcpFailures }; - } 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`; - } - if (initEntry.session_id) { - markdown += `**Session ID:** ${initEntry.session_id}\n\n`; - } - if (initEntry.cwd) { - const cleanCwd = initEntry.cwd.replace(/^\/home\/runner\/work\/[^\/]+\/[^\/]+/, "."); - markdown += `**Working Directory:** ${cleanCwd}\n\n`; - } - 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); + if (line.includes("Suggestion:") || line.includes("Response:")) { + markdown += `**${line.trim()}**\n\n`; + hasOutput = true; } - } - markdown += "\n"; - } - 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); + if (line.toLowerCase().includes("error:")) { + markdown += `❌ **Error:** ${line.trim()}\n\n`; + hasOutput = true; + } else if (line.toLowerCase().includes("warning:")) { + markdown += `⚠️ **Warning:** ${line.trim()}\n\n`; + hasOutput = true; } - } - 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`; + const trimmedLine = line.trim(); + if ( + trimmedLine && + !trimmedLine.startsWith("$") && + !trimmedLine.startsWith("#") && + !trimmedLine.match(/^\d{4}-\d{2}-\d{2}/) && + trimmedLine.length > 10 + ) { + if ( + trimmedLine.includes("copilot") || + trimmedLine.includes("suggestion") || + trimmedLine.includes("generate") || + trimmedLine.includes("explain") + ) { + markdown += `${trimmedLine}\n\n`; + hasOutput = true; } } } - 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`; - } - 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 (inCodeBlock && currentCodeBlock.trim()) { + markdown += `\`\`\`${currentLanguage}\n${currentCodeBlock}\`\`\`\n\n`; + hasOutput = true; } - 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`; - } - } - } - 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 (!hasOutput) { + markdown += "*No significant output captured from Copilot CLI execution.*\n"; } + return markdown; + } catch (error) { + console.error("Error parsing Copilot log:", error); + return `## 🤖 GitHub Copilot CLI Execution\n\n*Error parsing log: ${error.message}*\n`; } - 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) + "..."; - } - 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, - }; } main(); - name: Upload agent logs diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 7887cddb483..e5c38877239 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -5,7 +5,7 @@ on: push: branches: - copilot/* -engine: claude +engine: copilot safe-outputs: staged: true safe-jobs: diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index e3270b4dc71..fd40d43624e 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 'claude', 'codex', or 'copilot'", engine) } return nil } @@ -285,7 +285,7 @@ func init() { uninstallCmd.Flags().BoolP("local", "l", false, "Uninstall packages from local .aw/packages instead of global ~/.aw/packages") // Add AI flag to compile and add commands - compileCmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex)") + compileCmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex, copilot)") compileCmd.Flags().Bool("validate", true, "Enable GitHub Actions workflow schema validation (default: true)") compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically") compileCmd.Flags().String("workflows-dir", "", "Relative directory containing workflows (default: .github/workflows)") diff --git a/cmd/gh-aw/main_entry_test.go b/cmd/gh-aw/main_entry_test.go index e5b9fee45f8..6e411861c88 100644 --- a/cmd/gh-aw/main_entry_test.go +++ b/cmd/gh-aw/main_entry_test.go @@ -33,6 +33,11 @@ func TestValidateEngine(t *testing.T) { engine: "codex", expectErr: false, }, + { + name: "valid copilot engine", + engine: "copilot", + expectErr: false, + }, { name: "invalid engine", engine: "gpt4", @@ -75,7 +80,7 @@ func TestValidateEngine(t *testing.T) { return } - if tt.errMessage != "" && err.Error() != fmt.Sprintf("invalid engine value '%s'. Must be 'claude' or 'codex'", tt.engine) { + if tt.errMessage != "" && err.Error() != fmt.Sprintf("invalid engine value '%s'. Must be 'claude', 'codex', or 'copilot'", tt.engine) { t.Errorf("validateEngine(%q) error message = %v, want to contain %v", tt.engine, err.Error(), tt.errMessage) } } else { diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 74c1a6217e0..19fcd9bfd23 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 + +- `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/docs/src/content/docs/tools/cli.md b/docs/src/content/docs/tools/cli.md index be7138e0dec..4a476064379 100644 --- a/docs/src/content/docs/tools/cli.md +++ b/docs/src/content/docs/tools/cli.md @@ -232,6 +232,7 @@ gh aw logs --start-date -1mo # Last month's runs # Filter by AI engine type gh aw logs --engine claude # Only Claude workflows gh aw logs --engine codex # Only Codex workflows +gh aw logs --engine copilot # Only Copilot workflows # Filter by branch name gh aw logs --branch main # Only runs from main branch diff --git a/pkg/cli/add_command.go b/pkg/cli/add_command.go index 4b0bac764e5..720e25ce019 100644 --- a/pkg/cli/add_command.go +++ b/pkg/cli/add_command.go @@ -85,7 +85,7 @@ It's a shortcut for: cmd.Flags().StringP("name", "n", "", "Specify name for the added workflow (without .md extension)") // Add AI flag to add command - cmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex)") + cmd.Flags().StringP("engine", "a", "", "Override AI engine (claude, codex, copilot, custom)") // Add repository flag to add command cmd.Flags().StringP("repo", "r", "", "Install and use workflows from specified repository (org/repo)") diff --git a/pkg/cli/interactive.go b/pkg/cli/interactive.go index 436bc628e66..dfbe1ad246e 100644 --- a/pkg/cli/interactive.go +++ b/pkg/cli/interactive.go @@ -127,6 +127,7 @@ 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("copilot - GitHub Copilot CLI", "copilot"), huh.NewOption("claude - Anthropic Claude Code coding agent", "claude"), huh.NewOption("codex - OpenAI Codex engine", "codex"), huh.NewOption("custom - Custom engine configuration", "custom"), diff --git a/pkg/cli/logs.go b/pkg/cli/logs.go index b7e941dc585..8a3e84a2ed2 100644 --- a/pkg/cli/logs.go +++ b/pkg/cli/logs.go @@ -159,6 +159,8 @@ Examples: ` + constants.CLIExtensionPrefix + ` logs --start-date -1mo # Filter runs from last month ` + constants.CLIExtensionPrefix + ` logs --engine claude # Filter logs by claude engine ` + constants.CLIExtensionPrefix + ` logs --engine codex # Filter logs by codex engine + ` + constants.CLIExtensionPrefix + ` logs --engine copilot # Filter logs by copilot engine + ` + constants.CLIExtensionPrefix + ` logs -o ./my-logs # Custom output directory ` + constants.CLIExtensionPrefix + ` logs --branch main # Filter logs by branch name ` + constants.CLIExtensionPrefix + ` logs --branch feature-xyz # Filter logs by feature branch ` + constants.CLIExtensionPrefix + ` logs --after-run-id 1000 # Filter runs after run ID 1000 @@ -257,7 +259,7 @@ Examples: logsCmd.Flags().String("start-date", "", "Filter runs created after this date (YYYY-MM-DD or delta like -1d, -1w, -1mo)") logsCmd.Flags().String("end-date", "", "Filter runs created before this date (YYYY-MM-DD or delta like -1d, -1w, -1mo)") logsCmd.Flags().StringP("output", "o", "./logs", "Output directory for downloaded logs and artifacts") - logsCmd.Flags().String("engine", "", "Filter logs by agentic engine type (claude, codex)") + logsCmd.Flags().String("engine", "", "Filter logs by agentic engine type (claude, codex, copilot)") logsCmd.Flags().String("branch", "", "Filter runs by branch name (e.g., main, feature-branch)") logsCmd.Flags().Int64("before-run-id", 0, "Filter runs with database ID before this value (exclusive)") logsCmd.Flags().Int64("after-run-id", 0, "Filter runs with database ID after this value (exclusive)") @@ -357,7 +359,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 +373,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/cli/logs_test.go b/pkg/cli/logs_test.go index 1e3cf2dbe11..be0541f84a2 100644 --- a/pkg/cli/logs_test.go +++ b/pkg/cli/logs_test.go @@ -773,6 +773,11 @@ func TestDownloadWorkflowLogsWithEngineFilter(t *testing.T) { engine: "codex", expectError: false, }, + { + name: "valid copilot engine", + engine: "copilot", + expectError: false, + }, { name: "empty engine (no filter)", engine: "", @@ -864,7 +869,7 @@ func TestLogsCommandFlags(t *testing.T) { t.Fatal("Engine flag not found") } - if engineFlag.Usage != "Filter logs by agentic engine type (claude, codex)" { + if engineFlag.Usage != "Filter logs by agentic engine type (claude, codex, copilot)" { t.Errorf("Unexpected engine flag usage text: %s", engineFlag.Usage) } diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 3330bd0eac3..09a6206fbff 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -61,11 +61,11 @@ The YAML frontmatter supports these fields: ### Agentic Workflow Specific Fields - **`engine:`** - AI processor configuration - - String format: `"claude"` (default), `"codex"`, `"custom"` (⚠️ experimental) + - String format: `"claude"` (default), `"codex"`, `"copilot"`, `"custom"` (⚠️ experimental) - Object format for extended configuration: ```yaml engine: - id: claude # Required: coding agent identifier (claude, codex, custom) + id: claude # Required: coding agent identifier (claude, codex, copilot, custom) version: beta # Optional: version of the action (has sensible default) model: claude-3-5-sonnet-20241022 # Optional: LLM model to use (has sensible default) max-turns: 5 # Optional: maximum chat iterations per run (has sensible default) @@ -184,7 +184,7 @@ The YAML frontmatter supports these fields: ``` Useful when you need additional permissions or want to perform actions across repositories. -- **`alias:`** - Alternative workflow name (string) +- **`command:`** - Command trigger configuration for /mention workflows - **`cache:`** - Cache configuration for workflow dependencies (object or array) - **`cache-memory:`** - Memory MCP server with persistent cache storage (boolean or object) @@ -795,6 +795,7 @@ gh aw logs weekly-research # Filter logs by AI engine type gh aw logs --engine claude # Only Claude workflows gh aw logs --engine codex # Only Codex workflows +gh aw logs --engine copilot # Only Copilot workflows # Limit number of runs and filter by date (absolute dates) gh aw logs -c 10 --start-date 2024-01-01 --end-date 2024-01-31 @@ -932,7 +933,7 @@ The workflow frontmatter is validated against JSON Schema during compilation. Co - **Invalid field names** - Only fields in the schema are allowed - **Wrong field types** - e.g., `timeout_minutes` must be integer -- **Invalid enum values** - e.g., `engine` must be "claude", "codex", or "custom" +- **Invalid enum values** - e.g., `engine` must be "claude", "codex", "copilot" or "custom" - **Missing required fields** - Some triggers require specific configuration Use `gh aw compile --verbose` to see detailed validation messages, or `gh aw compile --verbose` to validate a specific workflow. 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..ea15db00922 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -817,9 +817,10 @@ "enum": [ "claude", "codex", + "copilot", "custom" ], - "description": "Simple engine name (claude, codex, or custom)" + "description": "Simple engine name (claude, codex, copilot or custom)" }, { "type": "object", @@ -830,9 +831,10 @@ "enum": [ "claude", "codex", - "custom" + "custom", + "copilot" ], - "description": "Agent CLI identifier (claude, codex, or custom)" + "description": "Agent CLI identifier (claude, codex, copilot, or custom)" }, "version": { "type": "string", diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 657499cb648..69f7cd216ec 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -140,6 +140,7 @@ func NewEngineRegistry() *EngineRegistry { // Register built-in engines registry.Register(NewClaudeEngine()) registry.Register(NewCodexEngine()) + registry.Register(NewCopilotEngine()) registry.Register(NewCustomEngine()) return registry diff --git a/pkg/workflow/agentic_engine_test.go b/pkg/workflow/agentic_engine_test.go index 4ec48c7e333..b754a8196a6 100644 --- a/pkg/workflow/agentic_engine_test.go +++ b/pkg/workflow/agentic_engine_test.go @@ -9,8 +9,8 @@ func TestEngineRegistry(t *testing.T) { // Test that built-in engines are registered supportedEngines := registry.GetSupportedEngines() - if len(supportedEngines) != 3 { - t.Errorf("Expected 3 supported engines, got %d", len(supportedEngines)) + if len(supportedEngines) != 4 { + t.Errorf("Expected 4 supported engines, got %d", len(supportedEngines)) } // Test getting engines by ID @@ -116,7 +116,7 @@ func TestEngineRegistryCustomEngine(t *testing.T) { // Test that supported engines list is updated supportedEngines := registry.GetSupportedEngines() - if len(supportedEngines) != 4 { - t.Errorf("Expected 4 supported engines after adding test-custom, got %d", len(supportedEngines)) + if len(supportedEngines) != 5 { + t.Errorf("Expected 5 supported engines after adding test-custom, got %d", len(supportedEngines)) } } diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go new file mode 100644 index 00000000000..c789a1d0dcf --- /dev/null +++ b/pkg/workflow/copilot_engine.go @@ -0,0 +1,432 @@ +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{"--add-dir", "/tmp/", "--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) + } + + // if cache-memory tool is used, --add-dir + if workflowData.CacheMemoryConfig != nil { + copilotArgs = append(copilotArgs, "--add-dir", "/tmp/cache-memory/") + } + + 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.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" +} diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go new file mode 100644 index 00000000000..2373479b0e1 --- /dev/null +++ b/pkg/workflow/copilot_engine_test.go @@ -0,0 +1,121 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestCopilotEngine(t *testing.T) { + engine := NewCopilotEngine() + + // Test basic properties + if engine.GetID() != "copilot" { + t.Errorf("Expected copilot engine ID, got '%s'", engine.GetID()) + } + + if engine.GetDisplayName() != "GitHub Copilot CLI" { + t.Errorf("Expected 'GitHub Copilot CLI' display name, got '%s'", engine.GetDisplayName()) + } + + if !engine.IsExperimental() { + t.Error("Expected copilot engine to be experimental") + } + + if !engine.SupportsToolsAllowlist() { + t.Error("Expected copilot engine to support tools allowlist") + } + + if !engine.SupportsHTTPTransport() { + t.Error("Expected copilot engine to support HTTP transport") + } + + if engine.SupportsMaxTurns() { + t.Error("Expected copilot engine to not support max-turns yet") + } +} + +func TestCopilotEngineInstallationSteps(t *testing.T) { + engine := NewCopilotEngine() + + // Test with no version + workflowData := &WorkflowData{} + steps := engine.GetInstallationSteps(workflowData) + if len(steps) != 3 { + t.Errorf("Expected 3 installation steps, got %d", len(steps)) + } + + // Test with version + workflowDataWithVersion := &WorkflowData{ + EngineConfig: &EngineConfig{Version: "1.0.0"}, + } + stepsWithVersion := engine.GetInstallationSteps(workflowDataWithVersion) + if len(stepsWithVersion) != 3 { + t.Errorf("Expected 3 installation steps with version, got %d", len(stepsWithVersion)) + } +} + +func TestCopilotEngineExecutionSteps(t *testing.T) { + engine := NewCopilotEngine() + workflowData := &WorkflowData{ + Name: "test-workflow", + } + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + + if len(steps) != 1 { + t.Fatalf("Expected 1 step for Copilot CLI execution, got %d", len(steps)) + } + + // Check the execution step + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "name: Execute GitHub Copilot CLI") { + t.Errorf("Expected step name 'Execute GitHub Copilot CLI' in step content:\n%s", stepContent) + } + + if !strings.Contains(stepContent, "copilot --add-dir /tmp/ --log-level debug --log-dir") { + t.Errorf("Expected command to contain 'copilot --add-dir /tmp/ --log-level debug --log-dir' in step content:\n%s", stepContent) + } + + if !strings.Contains(stepContent, "/tmp/test.log") { + t.Errorf("Expected command to contain log file name in step content:\n%s", stepContent) + } + + if !strings.Contains(stepContent, "GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }}") { + t.Errorf("Expected GITHUB_TOKEN environment variable in step content:\n%s", stepContent) + } + + // Test that GITHUB_AW_SAFE_OUTPUTS is not present when SafeOutputs is nil + if strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS") { + t.Error("Expected GITHUB_AW_SAFE_OUTPUTS to not be present when SafeOutputs is nil") + } +} + +func TestCopilotEngineExecutionStepsWithOutput(t *testing.T) { + engine := NewCopilotEngine() + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{}, // Non-nil to trigger output handling + } + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + + if len(steps) != 1 { + t.Fatalf("Expected 1 step for Copilot CLI execution with output, got %d", len(steps)) + } + + // Check the execution step + stepContent := strings.Join([]string(steps[0]), "\n") + + // Test that GITHUB_AW_SAFE_OUTPUTS is present when SafeOutputs is not nil + if !strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}") { + t.Errorf("Expected GITHUB_AW_SAFE_OUTPUTS environment variable when SafeOutputs is not nil in step content:\n%s", stepContent) + } +} + +func TestCopilotEngineGetLogParserScript(t *testing.T) { + engine := NewCopilotEngine() + script := engine.GetLogParserScriptId() + + if script != "parse_copilot_log" { + t.Errorf("Expected 'parse_copilot_log', got '%s'", script) + } +} diff --git a/pkg/workflow/engine_output.go b/pkg/workflow/engine_output.go index d6f088afbe9..e19737f44a4 100644 --- a/pkg/workflow/engine_output.go +++ b/pkg/workflow/engine_output.go @@ -30,6 +30,6 @@ func (c *Compiler) generateEngineOutputCollection(yaml *strings.Builder, engine yaml.WriteString(" - name: Clean up engine output files\n") yaml.WriteString(" run: |\n") for _, file := range outputFiles { - yaml.WriteString(" rm -f " + file + "\n") + yaml.WriteString(" rm -fr " + file + "\n") } } diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 624352d6b8f..a02663f814b 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -63,6 +63,9 @@ var parseClaudeLogScript string //go:embed js/parse_codex_log.cjs var parseCodexLogScript string +//go:embed js/parse_copilot_log.cjs +var parseCopilotLogScript string + //go:embed js/validate_errors.cjs var validateErrorsScript string @@ -430,6 +433,8 @@ func GetLogParserScript(name string) string { return parseClaudeLogScript case "parse_codex_log": return parseCodexLogScript + case "parse_copilot_log": + return parseCopilotLogScript case "validate_errors": return validateErrorsScript default: diff --git a/pkg/workflow/js/package.json b/pkg/workflow/js/package.json index 4ba4e18372f..c051bb692a4 100644 --- a/pkg/workflow/js/package.json +++ b/pkg/workflow/js/package.json @@ -22,6 +22,6 @@ "test:js-watch": "vitest", "test:js-coverage": "vitest run --coverage", "format:cjs": "prettier --write '**/*.cjs' '**/*.js' '**/*.ts'", - "lint:cjs": "prettier --check '**/*.cjs' '**/*.ts'" + "lint:cjs": "prettier --check '**/*.cjs' '**/*.js' '**/*.ts'" } } diff --git a/pkg/workflow/js/parse_copilot_log.cjs b/pkg/workflow/js/parse_copilot_log.cjs new file mode 100644 index 00000000000..e759a5ac7a7 --- /dev/null +++ b/pkg/workflow/js/parse_copilot_log.cjs @@ -0,0 +1,126 @@ +function main() { + const fs = require("fs"); + + try { + const logFile = process.env.AGENT_LOG_FILE; + if (!logFile) { + console.log("No agent log file specified"); + return; + } + + if (!fs.existsSync(logFile)) { + console.log(`Log file not found: ${logFile}`); + return; + } + + const content = fs.readFileSync(logFile, "utf8"); + const parsedLog = parseCopilotLog(content); + + if (parsedLog) { + core.summary.addRaw(parsedLog).write(); + console.log("Copilot log parsed successfully"); + } else { + console.log("Failed to parse Copilot log"); + } + } catch (error) { + core.setFailed(error.message); + } +} + +function parseCopilotLog(logContent) { + try { + const lines = logContent.split("\n"); + let markdown = "## 🤖 GitHub Copilot CLI Execution\n\n"; + + let hasOutput = false; + let inCodeBlock = false; + let currentCodeBlock = ""; + let currentLanguage = ""; + + for (const line of lines) { + // Look for code block markers + if (line.trim().startsWith("```")) { + if (!inCodeBlock) { + // Starting a code block + inCodeBlock = true; + currentLanguage = line.trim().substring(3); + currentCodeBlock = ""; + } else { + // Ending a code block + inCodeBlock = false; + if (currentCodeBlock.trim()) { + markdown += `\`\`\`${currentLanguage}\n${currentCodeBlock}\`\`\`\n\n`; + hasOutput = true; + } + currentCodeBlock = ""; + currentLanguage = ""; + } + continue; + } + + if (inCodeBlock) { + currentCodeBlock += line + "\n"; + continue; + } + + // Look for copilot CLI specific patterns + if (line.includes("copilot -p") || line.includes("github copilot")) { + markdown += `**Command:** \`${line.trim()}\`\n\n`; + hasOutput = true; + } + + // Look for responses or suggestions + if (line.includes("Suggestion:") || line.includes("Response:")) { + markdown += `**${line.trim()}**\n\n`; + hasOutput = true; + } + + // Look for errors or warnings + if (line.toLowerCase().includes("error:")) { + markdown += `❌ **Error:** ${line.trim()}\n\n`; + hasOutput = true; + } else if (line.toLowerCase().includes("warning:")) { + markdown += `⚠️ **Warning:** ${line.trim()}\n\n`; + hasOutput = true; + } + + // Capture general output that looks important + const trimmedLine = line.trim(); + if ( + trimmedLine && + !trimmedLine.startsWith("$") && + !trimmedLine.startsWith("#") && + !trimmedLine.match(/^\d{4}-\d{2}-\d{2}/) && // Skip timestamps + trimmedLine.length > 10 + ) { + // Only include lines that look like actual copilot output + if ( + trimmedLine.includes("copilot") || + trimmedLine.includes("suggestion") || + trimmedLine.includes("generate") || + trimmedLine.includes("explain") + ) { + markdown += `${trimmedLine}\n\n`; + hasOutput = true; + } + } + } + + // Handle any remaining code block + if (inCodeBlock && currentCodeBlock.trim()) { + markdown += `\`\`\`${currentLanguage}\n${currentCodeBlock}\`\`\`\n\n`; + hasOutput = true; + } + + if (!hasOutput) { + markdown += "*No significant output captured from Copilot CLI execution.*\n"; + } + + return markdown; + } catch (error) { + console.error("Error parsing Copilot log:", error); + return `## 🤖 GitHub Copilot CLI Execution\n\n*Error parsing log: ${error.message}*\n`; + } +} + +main();