diff --git a/.github/instructions/github-agentic-workflows.instructions.md b/.github/instructions/github-agentic-workflows.instructions.md index 3330bd0eac3..7630206355c 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 Codex 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..a29e900a003 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 @@ -261,6 +110,31 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v5 + # Cache memory file share configuration from frontmatter processed below + - name: Create cache-memory directory + run: | + mkdir -p /tmp/cache-memory + echo "Cache memory directory created at /tmp/cache-memory" + echo "This folder provides persistent file storage across workflow runs" + echo "LLMs and agentic tools can freely read and write files in this directory" + - name: Cache memory file share data + uses: actions/cache@v4 + with: + key: memory-${{ github.workflow }}-${{ github.run_id }} + path: /tmp/cache-memory + restore-keys: | + memory-${{ github.workflow }}- + memory- + - name: Upload cache-memory data as artifact + uses: actions/upload-artifact@v4 + with: + name: cache-memory + path: /tmp/cache-memory + - name: Configure Git credentials + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "${{ github.workflow }}" + echo "Git configured with standard GitHub Actions identity" - name: Generate Claude Settings run: | mkdir -p /tmp/.claude @@ -369,6 +243,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 +835,19 @@ 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-pull-request\":{}}" 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 }}" - } - }, "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,10 +860,33 @@ jobs: run: | mkdir -p $(dirname "$GITHUB_AW_PROMPT") cat > $GITHUB_AW_PROMPT << 'EOF' - Summarize and use print the message using the `print` tool. + Create a new poem and save it to file poem.txt, then push the changes to a pull request. - 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. + EOF + - name: Append cache memory instructions to prompt + env: + GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt + run: | + cat >> $GITHUB_AW_PROMPT << 'EOF' + + --- + + ## Cache Folder Available + + You have access to a persistent cache folder at `/tmp/cache-memory/` where you can read and write files to create memories and store information. + + - **Read/Write Access**: You can freely read from and write to any files in this folder + - **Persistence**: Files in this folder persist across workflow runs via GitHub Actions cache + - **Last Write Wins**: If multiple processes write to the same file, the last write will be preserved + - **File Share**: Use this as a simple file share - organize files as you see fit + Examples of what you can store: + - `/tmp/cache-memory/notes.txt` - general notes and observations + - `/tmp/cache-memory/preferences.json` - user preferences and settings + - `/tmp/cache-memory/history.log` - activity history and logs + - `/tmp/cache-memory/state/` - organized state files in subdirectories + + Feel free to create, read, update, and organize files in this folder as needed for your tasks. EOF - name: Append safe outputs instructions to prompt env: @@ -1006,10 +896,19 @@ jobs: --- - ## Reporting Missing Tools or Functionality + ## Creating a Pull RequestReporting 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 a Pull Request** + + To create a pull request: + 1. Make any file changes directly in the working directory + 2. If you haven't done so already, create a local branch using an appropriate unique name + 3. Add and commit your changes to the branch. Be careful to add exactly the files you intend, and check there are no extra files left un-added. Check you haven't deleted or changed any files you didn't intend to. + 4. Do not push your changes. That will be done by the tool. + 5. Create the pull request with the create-pull-request tool from the safe-outputs MCP + EOF - name: Print prompt to step summary env: @@ -1027,12 +926,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 +966,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/ --add-dir /tmp/ --add-dir /tmp/cache-memory/ --allow-all-tools --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 }} @@ -1176,7 +1012,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-pull-request\":{}}" with: script: | async function main() { @@ -1908,6 +1744,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 -rf /tmp/.copilot/logs/ - name: Upload MCP logs if: always() uses: actions/upload-artifact@v4 @@ -1915,419 +1761,433 @@ 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 + - name: Generate git patch + if: always() + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_SHA: ${{ github.sha }} + run: | + # Check current git status + echo "Current git status:" + git status + + # Extract branch name from JSONL output + BRANCH_NAME="" + if [ -f "$GITHUB_AW_SAFE_OUTPUTS" ]; then + echo "Checking for branch name in JSONL output..." + while IFS= read -r line; do + if [ -n "$line" ]; then + # Extract branch from create-pull-request line using simple grep and sed + if echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"create-pull-request"'; then + echo "Found create-pull-request line: $line" + # Extract branch value using sed + BRANCH_NAME=$(echo "$line" | sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$BRANCH_NAME" ]; then + echo "Extracted branch name from create-pull-request: $BRANCH_NAME" + break + fi + # Extract branch from push-to-pull-request-branch line using simple grep and sed + elif echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"push-to-pull-request-branch"'; then + echo "Found push-to-pull-request-branch line: $line" + # Extract branch value using sed + BRANCH_NAME=$(echo "$line" | sed -n 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$BRANCH_NAME" ]; then + echo "Extracted branch name from push-to-pull-request-branch: $BRANCH_NAME" + break + fi + fi + fi + done < "$GITHUB_AW_SAFE_OUTPUTS" + fi + + # If no branch or branch doesn't exist, no patch + if [ -z "$BRANCH_NAME" ]; then + echo "No branch found, no patch generation" + fi + + # If we have a branch name, check if that branch exists and get its diff + if [ -n "$BRANCH_NAME" ]; then + echo "Looking for branch: $BRANCH_NAME" + # Check if the branch exists + if git show-ref --verify --quiet refs/heads/$BRANCH_NAME; then + echo "Branch $BRANCH_NAME exists, generating patch from branch changes" + + # Check if origin/$BRANCH_NAME exists to use as base + if git show-ref --verify --quiet refs/remotes/origin/$BRANCH_NAME; then + echo "Using origin/$BRANCH_NAME as base for patch generation" + BASE_REF="origin/$BRANCH_NAME" + else + echo "origin/$BRANCH_NAME does not exist, using merge-base with default branch" + # Get the default branch name + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + echo "Default branch: $DEFAULT_BRANCH" + # Fetch the default branch to ensure it's available locally + git fetch origin $DEFAULT_BRANCH + # Find merge base between default branch and current branch + BASE_REF=$(git merge-base origin/$DEFAULT_BRANCH $BRANCH_NAME) + echo "Using merge-base as base: $BASE_REF" + fi + + # Generate patch from the determined base to the branch + git format-patch "$BASE_REF".."$BRANCH_NAME" --stdout > /tmp/aw.patch || echo "Failed to generate patch from branch" > /tmp/aw.patch + echo "Patch file created from branch: $BRANCH_NAME (base: $BASE_REF)" + else + echo "Branch $BRANCH_NAME does not exist, no patch" + fi + fi + + # Show patch info if it exists + if [ -f /tmp/aw.patch ]; then + ls -la /tmp/aw.patch + # Show the first 50 lines of the patch for review + echo '## Git Patch' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```diff' >> $GITHUB_STEP_SUMMARY + head -500 /tmp/aw.patch >> $GITHUB_STEP_SUMMARY || echo "Could not display patch contents" >> $GITHUB_STEP_SUMMARY + echo '...' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + fi + - name: Upload git patch if: always() + uses: actions/upload-artifact@v4 + with: + name: aw.patch + path: /tmp/aw.patch + if-no-files-found: ignore + + create_pull_request: + needs: agent + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.create_pull_request.outputs.branch_name }} + fallback_used: ${{ steps.create_pull_request.outputs.fallback_used }} + issue_number: ${{ steps.create_pull_request.outputs.issue_number }} + issue_url: ${{ steps.create_pull_request.outputs.issue_url }} + pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} + pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + steps: + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@v5 + with: + name: aw.patch + path: /tmp/ + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Configure Git credentials + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "${{ github.workflow }}" + echo "Git configured with standard GitHub Actions identity" + - name: Create Pull Request + id: create_pull_request uses: actions/github-script@v8 env: - GITHUB_AW_AGENT_OUTPUT: /tmp/dev.log + GITHUB_AW_AGENT_OUTPUT: ${{ needs.agent.outputs.output }} + GITHUB_AW_WORKFLOW_ID: "agent" + GITHUB_AW_BASE_BRANCH: ${{ github.ref_name }} + GITHUB_AW_PR_DRAFT: "true" + GITHUB_AW_PR_IF_NO_CHANGES: "warn" + GITHUB_AW_MAX_PATCH_SIZE: 1024 + 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}`); + const fs = require("fs"); + const crypto = require("crypto"); + async function main() { + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; + if (!workflowId) { + throw new Error("GITHUB_AW_WORKFLOW_ID environment variable is required"); + } + const baseBranch = process.env.GITHUB_AW_BASE_BRANCH; + if (!baseBranch) { + throw new Error("GITHUB_AW_BASE_BRANCH environment variable is required"); + } + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT || ""; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; + if (!fs.existsSync("/tmp/aw.patch")) { + const message = "No patch file found - cannot create pull request without changes"; + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ⚠️ No patch file found\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (no patch file)"); 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}`); + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + return; + case "warn": + default: + core.warning(message); + return; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.setFailed(errorMessage); } - } - function parseClaudeLog(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 patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); + if (patchContent.includes("Failed to generate patch")) { + const message = "Patch file contains error message - cannot create pull request without changes"; + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (patch error)"); + return; } - if (commandSummary.length > 0) { - for (const cmd of commandSummary) { - markdown += `${cmd}\n`; - } - } else { - markdown += "No commands or tools used.\n"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + return; + case "warn": + default: + core.warning(message); + return; } - 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`; + } + const isEmpty = !patchContent || !patchContent.trim(); + if (!isEmpty) { + const maxSizeKb = parseInt(process.env.GITHUB_AW_MAX_PATCH_SIZE || "1024", 10); + const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); + const patchSizeKb = Math.ceil(patchSizeBytes / 1024); + core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); + if (patchSizeKb > maxSizeKb) { + const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; + summaryContent += `**Message:** ${message}\n\n`; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary (patch size error)"); + return; } + throw new Error(message); } - 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; - } - } - } - } + core.info("Patch size validation passed"); + } + if (isEmpty && !isStaged) { + const message = "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + throw new Error("No changes to push - failing as configured by if-no-changes: error"); + case "ignore": + return; + case "warn": + default: + core.warning(message); + return; } - 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`; + core.debug(`Agent output content length: ${outputContent.length}`); + if (!isEmpty) { + core.info("Patch content validation passed"); + } else { + core.info("Patch file is empty - processing noop operation"); + } + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + 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.warning("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 pullRequestItem = validatedOutput.items.find( item => item.type === "create-pull-request"); + if (!pullRequestItem) { + core.warning("No create-pull-request item 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.debug(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; + summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; + summaryContent += `**Base:** ${baseBranch}\n\n`; + if (pullRequestItem.body) { + summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; } - 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); + if (fs.existsSync("/tmp/aw.patch")) { + const patchStats = fs.readFileSync("/tmp/aw.patch", "utf8"); + if (patchStats.trim()) { + summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; } else { - categories["Other"].push(tool); + summaryContent += `**Changes:** No changes (empty patch)\n\n`; } } - 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`; - } - } - } - 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"; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Pull request creation preview written to step summary"); + return; } - return { markdown, mcpFailures }; - } - function formatToolUse(toolUse, toolResult) { - const toolName = toolUse.name; - const input = toolUse.input || {}; - if (toolName === "TodoWrite") { - return ""; + let title = pullRequestItem.title.trim(); + let bodyLines = pullRequestItem.body.split("\n"); + let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; + if (!title) { + title = "Agent Output"; } - function getStatusIcon() { - if (toolResult) { - return toolResult.is_error === true ? "❌" : "✅"; - } - return "❓"; + const titlePrefix = process.env.GITHUB_AW_PR_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - 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(); + const labelsEnv = process.env.GITHUB_AW_PR_LABELS; + const labels = labelsEnv + ? labelsEnv + .split(",") + .map( label => label.trim()) + .filter( label => label) + : []; + const draftEnv = process.env.GITHUB_AW_PR_DRAFT; + const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; + core.info(`Creating pull request with title: ${title}`); + core.debug(`Labels: ${JSON.stringify(labels)}`); + core.debug(`Draft: ${draft}`); + core.debug(`Body length: ${body.length}`); + const randomHex = crypto.randomBytes(8).toString("hex"); + if (!branchName) { + core.debug("No branch name provided in JSONL, generating unique branch name"); + branchName = `${workflowId}-${randomHex}`; + } else { + branchName = `${branchName}-${randomHex}`; + core.debug(`Using branch name from JSONL with added salt: ${branchName}`); } - 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}`; + core.info(`Generated branch name: ${branchName}`); + core.debug(`Base branch: ${baseBranch}`); + core.debug(`Fetching latest changes and checking out base branch: ${baseBranch}`); + await exec.exec("git fetch origin"); + await exec.exec(`git checkout ${baseBranch}`); + core.debug(`Branch should not exist locally, creating new branch from base: ${branchName}`); + await exec.exec(`git checkout -b ${branchName}`); + core.info(`Created new branch from base: ${branchName}`); + if (!isEmpty) { + core.info("Applying patch..."); + await exec.exec("git am /tmp/aw.patch"); + core.info("Patch applied successfully"); + await exec.exec(`git push origin ${branchName}`); + core.info("Changes pushed to branch"); + } else { + core.info("Skipping patch application (empty patch)"); + const message = "No changes to apply - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + throw new Error("No changes to apply - failing as configured by if-no-changes: error"); + case "ignore": + return; + case "warn": + default: + core.warning(message); + return; } } - 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) + "..."; + try { + const { data: pullRequest } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + head: branchName, + base: baseBranch, + draft: draft, + }); + core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); + if (labels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: labels, + }); + core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); + } + core.setOutput("pull_request_number", pullRequest.number); + core.setOutput("pull_request_url", pullRequest.html_url); + core.setOutput("branch_name", branchName); + await core.summary + .addRaw( + ` + ## Pull Request + - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) + - **Branch**: \`${branchName}\` + - **Base Branch**: \`${baseBranch}\` + ` + ) + .write(); + } catch (prError) { + core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); + core.info("Falling back to creating an issue instead"); + const branchUrl = context.payload.repository + ? `${context.payload.repository.html_url}/tree/${branchName}` + : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; + const fallbackBody = `${body} + --- + **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). + **Original error:** ${prError instanceof Error ? prError.message : String(prError)} + You can manually create a pull request from the branch if needed.`; + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: fallbackBody, + labels: labels, + }); + core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + core.setOutput("branch_name", branchName); + core.setOutput("fallback_used", "true"); + await core.summary + .addRaw( + ` + ## Fallback Issue Created + - **Issue**: [#${issue.number}](${issue.html_url}) + - **Branch**: [\`${branchName}\`](${branchUrl}) + - **Base Branch**: \`${baseBranch}\` + - **Note**: Pull request creation failed, created issue as fallback + ` + ) + .write(); + } catch (issueError) { + core.setFailed( + `Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` + ); + return; + } } - 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 - 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 + await main(); diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 7887cddb483..8125465113d 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -1,36 +1,15 @@ --- on: workflow_dispatch: - reaction: "eyes" push: branches: - - copilot/* -engine: claude + - copilot* +engine: copilot +tools: + cache-memory: true 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-pull-request: + 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. +Create a new poem and save it to file poem.txt, then push the changes to a pull request. \ No newline at end of file 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..edc7c6557c9 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/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/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 3330bd0eac3..7630206355c 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/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 Codex 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/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..fec0c9799da --- /dev/null +++ b/pkg/workflow/copilot_engine.go @@ -0,0 +1,423 @@ +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{"--log-level", "debug", "--log-dir", logsFolder, "--add-dir", "/tmp/"} + + // 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/") + } + + // TODO: tool allow list generation + copilotArgs = append(copilotArgs, "--allow-all-tools") + + 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) { + // GitHub is builtin +} + +// 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/engine_output.go b/pkg/workflow/engine_output.go index d6f088afbe9..6f4d216b39d 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 -rf " + file + "\n") } } 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'" } }