From e2badfe7f58d2cd86c6476c7a1b4ce6f757a44c7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:17:38 -0700 Subject: [PATCH 1/2] Add --no-emit flag and Agent Cook workflow for generating agentic workflows (#92) * Initial plan * Add --no-emit flag and agent-cook workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Address PR feedback: Remove write permissions, use XML tags for specifications, add code blocks in PR template, and reference instructions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Update XML formatting and strengthen safe-outputs guidance Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/agent-cook.lock.yml | 1956 +++++++++++++++++++++ .github/workflows/agent-cook.md | 102 ++ cmd/gh-aw/main.go | 4 +- pkg/cli/commands.go | 5 +- pkg/cli/commands_compile_workflow_test.go | 2 +- pkg/cli/commands_test.go | 56 +- pkg/cli/templates/instructions.md | 30 +- pkg/workflow/compiler.go | 42 +- 8 files changed, 2168 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/agent-cook.lock.yml create mode 100644 .github/workflows/agent-cook.md diff --git a/.github/workflows/agent-cook.lock.yml b/.github/workflows/agent-cook.lock.yml new file mode 100644 index 00000000000..291bcaa5f0e --- /dev/null +++ b/.github/workflows/agent-cook.lock.yml @@ -0,0 +1,1956 @@ +# This file was automatically generated by gh-aw. DO NOT EDIT. +# To update this file, edit the corresponding .md file and run: +# gh aw compile + +name: "Agent Cook - Agentic Workflow Generator" +on: + workflow_dispatch: + inputs: + output_specification: + description: Specify the desired output (e.g., "create issue comment", "create pull request", "add labels") + required: true + type: string + task_specification: + description: Describe what the workflow should do (e.g., "analyze the issue and provide suggestions", "run tests and create summary") + required: true + type: string + trigger_specification: + description: Specify when the workflow should trigger (e.g., "on issues opened", "on push to main", "manually via workflow_dispatch") + required: true + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number }}" + +run-name: "Agent Cook - Agentic Workflow Generator" + +jobs: + agent-cook-agentic-workflow-generator: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + outputs: + output: ${{ steps.collect_output.outputs.output }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + - name: Generate Claude Settings + run: | + cat > .claude/settings.json << 'EOF' + { + "hooks": { + "PreToolUse": [ + { + "matcher": "WebFetch|WebSearch", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/network_permissions.py" + } + ] + } + ] + } + } + EOF + - name: Generate Network Permissions Hook + run: | + mkdir -p .claude/hooks + cat > .claude/hooks/network_permissions.py << 'EOF' + #!/usr/bin/env python3 + """ + Network permissions validator for Claude Code engine. + Generated by gh-aw from engine network permissions configuration. + """ + + import json + import sys + import urllib.parse + import re + + # Domain allow-list (populated during generation) + ALLOWED_DOMAINS = ["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","packagecloud.io","packages.cloud.google.com","packages.microsoft.com"] + + def extract_domain(url_or_query): + """Extract domain from URL or search query.""" + if not url_or_query: + return None + + if url_or_query.startswith(('http://', 'https://')): + return urllib.parse.urlparse(url_or_query).netloc.lower() + + # Check for domain patterns in search queries + match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) + if match: + return match.group(1).lower() + + return None + + def is_domain_allowed(domain): + """Check if domain is allowed.""" + if not domain: + # If no domain detected, allow only if not under deny-all policy + return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains + + # Empty allowed domains means deny all + if not ALLOWED_DOMAINS: + return False + + for pattern in ALLOWED_DOMAINS: + regex = pattern.replace('.', r'\.').replace('*', '.*') + if re.match(f'^{regex}$', domain): + return True + return False + + # Main logic + try: + data = json.load(sys.stdin) + tool_name = data.get('tool_name', '') + tool_input = data.get('tool_input', {}) + + if tool_name not in ['WebFetch', 'WebSearch']: + sys.exit(0) # Allow other tools + + target = tool_input.get('url') or tool_input.get('query', '') + domain = extract_domain(target) + + # For WebSearch, apply domain restrictions consistently + # If no domain detected in search query, check if restrictions are in place + if tool_name == 'WebSearch' and not domain: + # Since this hook is only generated when network permissions are configured, + # empty ALLOWED_DOMAINS means deny-all policy + if not ALLOWED_DOMAINS: # Empty list means deny all + print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) + print(f"No domains are allowed for WebSearch", file=sys.stderr) + sys.exit(2) # Block under deny-all policy + else: + print(f"Network access blocked for web-search: no specific domain detected", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block general searches when domain allowlist is configured + + if not is_domain_allowed(domain): + print(f"Network access blocked for domain: {domain}", file=sys.stderr) + print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) + sys.exit(2) # Block with feedback to Claude + + sys.exit(0) # Allow + + except Exception as e: + print(f"Network validation error: {e}", file=sys.stderr) + sys.exit(2) # Block on errors + + EOF + chmod +x .claude/hooks/network_permissions.py + - name: Setup agent output + id: setup_agent_output + uses: actions/github-script@v7 + with: + script: | + function main() { + const fs = require("fs"); + const crypto = require("crypto"); + // Generate a random filename for the output file + const randomId = crypto.randomBytes(8).toString("hex"); + const outputFile = `/tmp/aw_output_${randomId}.txt`; + // Ensure the /tmp directory exists and create empty output file + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(outputFile, "", { mode: 0o644 }); + // Verify the file was created and is writable + if (!fs.existsSync(outputFile)) { + throw new Error(`Failed to create output file: ${outputFile}`); + } + // Set the environment variable for subsequent steps + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); + console.log("Created agentic output file:", outputFile); + // Also set as step output for reference + core.setOutput("output_file", outputFile); + } + main(); + - name: Setup MCPs + run: | + mkdir -p /tmp/mcp-config + cat > /tmp/mcp-config/mcp-servers.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 }}" + } + } + } + } + EOF + - name: Create prompt + env: + GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/aw-prompts + cat > $GITHUB_AW_PROMPT << 'EOF' + # Agent Cook - Agentic Workflow Generator + + You are the "Agent Cook" - an AI assistant that helps users create agentic workflows for GitHub repositories. Your task is to generate a new agentic workflow based on the user's specifications. + + ## User's Requirements + + ${{ github.event.inputs.trigger_specification }} + + ${{ github.event.inputs.task_specification }} + + ${{ github.event.inputs.output_specification }} + + ## Your Task + + 1. **Parse the Requirements**: Analyze the user's trigger, task, and output specifications to understand what they want to achieve. + + 2. **Design the Workflow**: Create a complete agentic workflow markdown file with: + - Appropriate `on:` trigger configuration based on the trigger specification + - Proper `permissions:` section for the required GitHub API access + - Correct `safe-outputs:` configuration based on the output specification + - Clear, actionable instructions in natural language for the AI to execute + + 3. **Validate the Syntax**: Use the gh-aw compiler to validate your generated workflow: + ```bash + # Create a temporary workflow file to test + echo "YOUR_GENERATED_WORKFLOW_CONTENT" > /tmp/test-workflow.md + + # Validate the syntax without generating lock files + gh-aw compile /tmp/test-workflow.md --no-emit --validate --verbose + ``` + + 4. **Generate the Final Workflow**: Create a properly formatted agentic workflow file with: + - A meaningful name (derived from the task specification) + - Complete frontmatter configuration + - Clear, step-by-step instructions for the AI + - Proper use of GitHub context expressions where needed + + 5. **Create Pull Request**: Save your final workflow as `.github/workflows/[generated-name].md` and create a pull request with: + - **Title**: "Add [workflow-name] agentic workflow" + - **Body**: + ``` + ## Generated Agentic Workflow + + This workflow was generated by Agent Cook based on the following specifications: + + **Trigger**: `${{ github.event.inputs.trigger_specification }}` + **Task**: `${{ github.event.inputs.task_specification }}` + **Output**: `${{ github.event.inputs.output_specification }}` + + ## Workflow Details + + [Include a brief explanation of what the workflow does and how it works] + + ## Usage + + [Include any special instructions for using the workflow] + ``` + + ## Guidelines + + - Follow the agentic workflow format with YAML frontmatter and markdown content + - Use appropriate GitHub Actions triggers (issues, pull_request, workflow_dispatch, schedule, etc.) + - Include only necessary permissions - follow principle of least privilege + - Use safe-outputs for GitHub API interactions (create-issue, add-issue-comment, create-pull-request, etc.) + - Write clear, specific instructions that an AI can follow + - Include relevant GitHub context expressions (e.g., `${{ github.event.issue.number }}`) + - Ensure the workflow name is descriptive and follows kebab-case convention + - Test your generated workflow with the compiler before creating the PR + - For detailed formatting and configuration options, reference the comprehensive agentic workflow documentation + + Remember: You are creating a workflow FILE, not just explaining how to create one. The output should be a complete, working agentic workflow that can be compiled and executed. + + + --- + + ## Creating a Pull RequestReporting Missing Tools or Functionality + + **IMPORTANT**: To do the actions mentioned in the header of this section, do NOT attempt to use MCP 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. Instead write JSON objects to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}". Each line should contain a single JSON object (JSONL format). You can write them one by one as you do them. + + **Format**: Write one JSON object per line. Each object must have a `type` field specifying the action type. + + ### Available Output Types: + + **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 later. Instead append the PR specification to the file "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": + ```json + {"type": "create-pull-request", "branch": "branch-name", "title": "PR title", "body": "PR body in markdown", "labels": ["optional", "labels"]} + ``` + 5. After you write to that file, read it as JSONL and check it is valid. If it isn't, make any necessary corrections to it to fix it up + + **Example JSONL file content:** + ``` + {"type": "create-pull-request", "title": "Fix typo", "body": "Corrected spelling mistake in documentation"} + ``` + + **Important Notes:** + - Do NOT attempt to use MCP tools, `gh`, or the GitHub API for these actions + - Each JSON object must be on its own line + - Only include output types that are configured for this workflow + - The content of this file will be automatically processed and executed + + EOF + - name: Print prompt to step summary + run: | + echo "## Generated Prompt" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````markdown' >> $GITHUB_STEP_SUMMARY + cat $GITHUB_AW_PROMPT >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Generate agentic run info + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "claude", + engine_name: "Claude Code", + model: "", + version: "", + workflow_name: "Agent Cook - Agentic Workflow Generator", + experimental: false, + supports_tools_whitelist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + created_at: new Date().toISOString() + }; + + // Write to /tmp directory to avoid inclusion in PR + const tmpPath = '/tmp/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + - name: Upload agentic run info + if: always() + uses: actions/upload-artifact@v4 + with: + name: aw_info.json + path: /tmp/aw_info.json + if-no-files-found: warn + - name: Execute Claude Code Action + id: agentic_execution + uses: anthropics/claude-code-base-action@v0.0.56 + with: + # Allowed tools (sorted): + # - Bash(git add:*) + # - Bash(git branch:*) + # - Bash(git checkout:*) + # - Bash(git commit:*) + # - Bash(git merge:*) + # - Bash(git rm:*) + # - Bash(git switch:*) + # - BashOutput + # - Edit + # - ExitPlanMode + # - Glob + # - Grep + # - KillBash + # - LS + # - MultiEdit + # - NotebookEdit + # - 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 + allowed_tools: "Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git commit:*),Bash(git merge:*),Bash(git rm:*),Bash(git switch:*),BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,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" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_env: | + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + mcp_config: /tmp/mcp-config/mcp-servers.json + prompt_file: /tmp/aw-prompts/prompt.txt + settings: .claude/settings.json + timeout_minutes: 5 + env: + GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + - name: Capture Agentic Action logs + if: always() + run: | + # Copy the detailed execution file from Agentic Action if available + if [ -n "${{ steps.agentic_execution.outputs.execution_file }}" ] && [ -f "${{ steps.agentic_execution.outputs.execution_file }}" ]; then + cp ${{ steps.agentic_execution.outputs.execution_file }} /tmp/agent-cook-agentic-workflow-generator.log + else + echo "No execution file output found from Agentic Action" >> /tmp/agent-cook-agentic-workflow-generator.log + fi + + # Ensure log file exists + touch /tmp/agent-cook-agentic-workflow-generator.log + - name: Check if workflow-complete.txt exists, if so upload it + id: check_file + run: | + if [ -f workflow-complete.txt ]; then + echo "File exists" + echo "upload=true" >> $GITHUB_OUTPUT + else + echo "File does not exist" + echo "upload=false" >> $GITHUB_OUTPUT + fi + - name: Upload workflow-complete.txt + if: steps.check_file.outputs.upload == 'true' + uses: actions/upload-artifact@v4 + with: + name: workflow-complete + path: workflow-complete.txt + - name: Collect agent output + id: collect_output + uses: actions/github-script@v7 + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-pull-request\":true}" + with: + script: | + async function main() { + const fs = require("fs"); + /** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content + */ + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + "github.com", + "github.io", + "githubusercontent.com", + "githubassets.com", + "github.dev", + "codespaces.new", + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + // XML character escaping + sanitized = sanitized + .replace(/&/g, "&") // Must be first to avoid double-escaping + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = + sanitized.substring(0, maxLength) + + "\n[Content truncated due to length]"; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = + lines.slice(0, maxLines).join("\n") + + "\n[Content truncated due to line count]"; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace( + /\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, + (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return ( + hostname === normalizedAllowed || + hostname.endsWith("." + normalizedAllowed) + ); + }); + return isAllowed ? match : "(redacted)"; + } + ); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace( + /\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, + (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + } + ); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace( + /\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\`` + ); + } + } + /** + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count + */ + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if ( + config && + config[itemType] && + typeof config[itemType] === "object" && + config[itemType].max + ) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case "create-issue": + return 1; // Only one issue allowed + case "add-issue-comment": + return 1; // Only one comment allowed + case "create-pull-request": + return 1; // Only one pull request allowed + case "create-pull-request-review-comment": + return 10; // Default to 10 review comments allowed + case "add-issue-label": + return 5; // Only one labels operation allowed + case "update-issue": + return 1; // Only one issue update allowed + case "push-to-branch": + return 1; // Only one push to branch allowed + case "create-discussion": + return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) + default: + return 1; // Default to single item for unknown types + } + } + /** + * Attempts to repair common JSON syntax issues in LLM-generated content + * @param {string} jsonStr - The potentially malformed JSON string + * @returns {string} The repaired JSON string + */ + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + // Fix single quotes to double quotes (must be done first) + repaired = repaired.replace(/'/g, '"'); + // Fix missing quotes around object keys + repaired = repaired.replace( + /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, + '$1"$2":' + ); + // Fix newlines and tabs inside strings by escaping them + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if ( + content.includes("\n") || + content.includes("\r") || + content.includes("\t") + ) { + const escaped = content + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + // Fix unescaped quotes inside string values + repaired = repaired.replace( + /"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, + (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}` + ); + // Fix wrong bracket/brace types - arrays should end with ] not } + repaired = repaired.replace( + /(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, + "$1]" + ); + // Fix missing closing braces/brackets + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + // Fix missing closing brackets for arrays + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + // Fix trailing commas in objects and arrays (AFTER fixing brackets/braces) + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + /** + * Attempts to parse JSON with repair fallback + * @param {string} jsonStr - The JSON string to parse + * @returns {Object|undefined} The parsed JSON object, or undefined if parsing fails + */ + function parseJsonWithRepair(jsonStr) { + try { + // First, try normal JSON.parse + return JSON.parse(jsonStr); + } catch (originalError) { + try { + // If that fails, try repairing and parsing again + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + // If repair also fails, print error to console and return undefined + console.log( + `JSON parsing failed. Original: ${originalError.message}. After repair: ${repairError.message}` + ); + return undefined; + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + console.log("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + console.log("Output file does not exist:", outputFile); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + console.log("Output file is empty"); + core.setOutput("output", ""); + return; + } + console.log("Raw output content length:", outputContent.length); + // Parse the safe-outputs configuration + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + console.log("Expected output types:", Object.keys(expectedOutputTypes)); + } catch (error) { + console.log( + "Warning: Could not parse safe-outputs config:", + error.message + ); + } + } + // Parse JSONL content + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; // Skip empty lines + try { + const item = parseJsonWithRepair(line); + // If item is undefined (failed to parse), add error and process next line + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + // Validate that the item has a 'type' field + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + // Validate against expected output types + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push( + `Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}` + ); + continue; + } + // Check for too many items of the same type + const typeCount = parsedItems.filter( + existing => existing.type === itemType + ).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push( + `Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.` + ); + continue; + } + // Basic validation based on type + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push( + `Line ${i + 1}: create-issue requires a 'title' string field` + ); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: create-issue requires a 'body' string field` + ); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => + typeof label === "string" ? sanitizeContent(label) : label + ); + } + break; + case "add-issue-comment": + if (!item.body || typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: add-issue-comment requires a 'body' string field` + ); + continue; + } + // Sanitize text content + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push( + `Line ${i + 1}: create-pull-request requires a 'title' string field` + ); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: create-pull-request requires a 'body' string field` + ); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + // Sanitize branch name if present + if (item.branch && typeof item.branch === "string") { + item.branch = sanitizeContent(item.branch); + } + // Sanitize labels if present + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => + typeof label === "string" ? sanitizeContent(label) : label + ); + } + break; + case "add-issue-label": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push( + `Line ${i + 1}: add-issue-label requires a 'labels' array field` + ); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push( + `Line ${i + 1}: add-issue-label labels array must contain only strings` + ); + continue; + } + // Sanitize label strings + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + // Check that at least one updateable field is provided + const hasValidField = + item.status !== undefined || + item.title !== undefined || + item.body !== undefined; + if (!hasValidField) { + errors.push( + `Line ${i + 1}: update-issue requires at least one of: 'status', 'title', or 'body' fields` + ); + continue; + } + // Validate status if provided + if (item.status !== undefined) { + if ( + typeof item.status !== "string" || + (item.status !== "open" && item.status !== "closed") + ) { + errors.push( + `Line ${i + 1}: update-issue 'status' must be 'open' or 'closed'` + ); + continue; + } + } + // Validate title if provided + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push( + `Line ${i + 1}: update-issue 'title' must be a string` + ); + continue; + } + item.title = sanitizeContent(item.title); + } + // Validate body if provided + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: update-issue 'body' must be a string` + ); + continue; + } + item.body = sanitizeContent(item.body); + } + // Validate issue_number if provided (for target "*") + if (item.issue_number !== undefined) { + if ( + typeof item.issue_number !== "number" && + typeof item.issue_number !== "string" + ) { + errors.push( + `Line ${i + 1}: update-issue 'issue_number' must be a number or string` + ); + continue; + } + } + break; + case "push-to-branch": + // Validate message if provided (optional) + if (item.message !== undefined) { + if (typeof item.message !== "string") { + errors.push( + `Line ${i + 1}: push-to-branch 'message' must be a string` + ); + continue; + } + item.message = sanitizeContent(item.message); + } + // Validate pull_request_number if provided (for target "*") + if (item.pull_request_number !== undefined) { + if ( + typeof item.pull_request_number !== "number" && + typeof item.pull_request_number !== "string" + ) { + errors.push( + `Line ${i + 1}: push-to-branch 'pull_request_number' must be a number or string` + ); + continue; + } + } + break; + case "create-pull-request-review-comment": + // Validate required path field + if (!item.path || typeof item.path !== "string") { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field` + ); + continue; + } + // Validate required line field + if ( + item.line === undefined || + (typeof item.line !== "number" && typeof item.line !== "string") + ) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment requires a 'line' number or string field` + ); + continue; + } + // Validate line is a positive integer + const lineNumber = + typeof item.line === "string" ? parseInt(item.line, 10) : item.line; + if ( + isNaN(lineNumber) || + lineNumber <= 0 || + !Number.isInteger(lineNumber) + ) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment 'line' must be a positive integer` + ); + continue; + } + // Validate required body field + if (!item.body || typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field` + ); + continue; + } + // Sanitize required text content + item.body = sanitizeContent(item.body); + // Validate optional start_line field + if (item.start_line !== undefined) { + if ( + typeof item.start_line !== "number" && + typeof item.start_line !== "string" + ) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment 'start_line' must be a number or string` + ); + continue; + } + const startLineNumber = + typeof item.start_line === "string" + ? parseInt(item.start_line, 10) + : item.start_line; + if ( + isNaN(startLineNumber) || + startLineNumber <= 0 || + !Number.isInteger(startLineNumber) + ) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment 'start_line' must be a positive integer` + ); + continue; + } + if (startLineNumber > lineNumber) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'` + ); + continue; + } + } + // Validate optional side field + if (item.side !== undefined) { + if ( + typeof item.side !== "string" || + (item.side !== "LEFT" && item.side !== "RIGHT") + ) { + errors.push( + `Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'` + ); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push( + `Line ${i + 1}: create-discussion requires a 'title' string field` + ); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push( + `Line ${i + 1}: create-discussion requires a 'body' string field` + ); + continue; + } + // Sanitize text content + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; + default: + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + console.log(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + errors.push(`Line ${i + 1}: Invalid JSON - ${error.message}`); + } + } + // Report validation results + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + // For now, we'll continue with valid items but log the errors + // In the future, we might want to fail the workflow for invalid items + } + console.log(`Successfully parsed ${parsedItems.length} valid output items`); + // Set the parsed and validated items as output + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + // Store validatedOutput JSON in "agent_output.json" file + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + // Ensure the /tmp directory exists + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + console.log(`Stored validated output to: ${agentOutputFile}`); + // Set the environment variable GITHUB_AW_AGENT_OUTPUT to the file path + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + console.error(`Failed to write agent output file: ${error.message}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + } + // Call the main function + await main(); + - name: Print agent output to step summary + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + run: | + echo "## Agent Output (JSONL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````json' >> $GITHUB_STEP_SUMMARY + cat ${{ env.GITHUB_AW_SAFE_OUTPUTS }} >> $GITHUB_STEP_SUMMARY + # Ensure there's a newline after the file content if it doesn't end with one + if [ -s ${{ env.GITHUB_AW_SAFE_OUTPUTS }} ] && [ "$(tail -c1 ${{ env.GITHUB_AW_SAFE_OUTPUTS }})" != "" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + fi + echo '``````' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Processed Output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '``````json' >> $GITHUB_STEP_SUMMARY + echo '${{ steps.collect_output.outputs.output }}' >> $GITHUB_STEP_SUMMARY + echo '``````' >> $GITHUB_STEP_SUMMARY + - name: Upload agentic output file + if: always() && steps.collect_output.outputs.output != '' + uses: actions/upload-artifact@v4 + with: + name: safe_output.jsonl + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Upload agent output JSON + if: always() && env.GITHUB_AW_AGENT_OUTPUT + uses: actions/upload-artifact@v4 + with: + 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: | + output.txt + if-no-files-found: ignore + - name: Clean up engine output files + run: | + rm -f output.txt + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@v7 + env: + AGENT_LOG_FILE: /tmp/agent-cook-agentic-workflow-generator.log + with: + script: | + function main() { + const fs = require("fs"); + try { + // Get the log file path from environment + 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 logContent = fs.readFileSync(logFile, "utf8"); + const markdown = parseClaudeLog(logContent); + // Append to GitHub step summary + core.summary.addRaw(markdown).write(); + } catch (error) { + core.error(`Error parsing Claude log: ${error.message}`); + core.setFailed(error.message); + } + } + function parseClaudeLog(logContent) { + try { + const logEntries = JSON.parse(logContent); + if (!Array.isArray(logEntries)) { + return "## Agent Log Summary\n\nLog format not recognized as Claude JSON array.\n"; + } + let markdown = "## 🤖 Commands and Tools\n\n"; + const toolUsePairs = new Map(); // Map tool_use_id to tool_result + const commandSummary = []; // For the succinct summary + // First pass: collect tool results by tool_use_id + 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); + } + } + } + } + // Collect all tool uses for summary + 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 || {}; + // Skip internal tools - only show external commands and API calls + if ( + [ + "Read", + "Write", + "Edit", + "MultiEdit", + "LS", + "Grep", + "Glob", + "TodoWrite", + ].includes(toolName) + ) { + continue; // Skip internal file operations and searches + } + // Find the corresponding tool result to get status + const toolResult = toolUsePairs.get(content.id); + let statusIcon = "❓"; + if (toolResult) { + statusIcon = toolResult.is_error === true ? "❌" : "✅"; + } + // Add to command summary (only external tools) + 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 { + // Handle other external tools (if any) + commandSummary.push(`* ${statusIcon} ${toolName}`); + } + } + } + } + } + // Add command summary + if (commandSummary.length > 0) { + for (const cmd of commandSummary) { + markdown += `${cmd}\n`; + } + } else { + markdown += "No commands or tools used.\n"; + } + // Add Information section from the last entry with result metadata + markdown += "\n## 📊 Information\n\n"; + // Find the last entry with metadata + const lastEntry = logEntries[logEntries.length - 1]; + if ( + lastEntry && + (lastEntry.num_turns || + lastEntry.duration_ms || + lastEntry.total_cost_usd || + lastEntry.usage) + ) { + if (lastEntry.num_turns) { + markdown += `**Turns:** ${lastEntry.num_turns}\n\n`; + } + if (lastEntry.duration_ms) { + const durationSec = Math.round(lastEntry.duration_ms / 1000); + const minutes = Math.floor(durationSec / 60); + const seconds = durationSec % 60; + markdown += `**Duration:** ${minutes}m ${seconds}s\n\n`; + } + if (lastEntry.total_cost_usd) { + markdown += `**Total Cost:** $${lastEntry.total_cost_usd.toFixed(4)}\n\n`; + } + if (lastEntry.usage) { + const usage = lastEntry.usage; + if (usage.input_tokens || usage.output_tokens) { + markdown += `**Token Usage:**\n`; + if (usage.input_tokens) + markdown += `- Input: ${usage.input_tokens.toLocaleString()}\n`; + if (usage.cache_creation_input_tokens) + markdown += `- Cache Creation: ${usage.cache_creation_input_tokens.toLocaleString()}\n`; + if (usage.cache_read_input_tokens) + markdown += `- Cache Read: ${usage.cache_read_input_tokens.toLocaleString()}\n`; + if (usage.output_tokens) + markdown += `- Output: ${usage.output_tokens.toLocaleString()}\n`; + markdown += "\n"; + } + } + if ( + lastEntry.permission_denials && + lastEntry.permission_denials.length > 0 + ) { + markdown += `**Permission Denials:** ${lastEntry.permission_denials.length}\n\n`; + } + } + markdown += "\n## 🤖 Reasoning\n\n"; + // Second pass: process assistant messages in sequence + 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) { + // Add reasoning text directly (no header) + const text = content.text.trim(); + if (text && text.length > 0) { + markdown += text + "\n\n"; + } + } else if (content.type === "tool_use") { + // Process tool use with its result + const toolResult = toolUsePairs.get(content.id); + const toolMarkdown = formatToolUse(content, toolResult); + if (toolMarkdown) { + markdown += toolMarkdown; + } + } + } + } + } + return markdown; + } catch (error) { + return `## Agent Log Summary\n\nError parsing Claude log: ${error.message}\n`; + } + } + function formatToolUse(toolUse, toolResult) { + const toolName = toolUse.name; + const input = toolUse.input || {}; + // Skip TodoWrite except the very last one (we'll handle this separately) + if (toolName === "TodoWrite") { + return ""; // Skip for now, would need global context to find the last one + } + // Helper function to determine status icon + function getStatusIcon() { + if (toolResult) { + return toolResult.is_error === true ? "❌" : "✅"; + } + return "❓"; // Unknown by default + } + let markdown = ""; + const statusIcon = getStatusIcon(); + switch (toolName) { + case "Bash": + const command = input.command || ""; + const description = input.description || ""; + // Format the command to be single line + 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( + /^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, + "" + ); // Remove /home/runner/work/repo/repo/ prefix + 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: + // Handle MCP calls and other tools + if (toolName.startsWith("mcp__")) { + const mcpName = formatMcpName(toolName); + const params = formatMcpParameters(input); + markdown += `${statusIcon} ${mcpName}(${params})\n\n`; + } else { + // Generic tool formatting - show the tool name and main parameters + const keys = Object.keys(input); + if (keys.length > 0) { + // Try to find the most important parameter + 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) { + // Convert mcp__github__search_issues to github::search_issues + if (toolName.startsWith("mcp__")) { + const parts = toolName.split("__"); + if (parts.length >= 3) { + const provider = parts[1]; // github, etc. + const method = parts.slice(2).join("_"); // search_issues, etc. + return `${provider}::${method}`; + } + } + 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)) { + // Show up to 4 parameters + 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 ""; + // Convert multi-line commands to single line by replacing newlines with spaces + // and collapsing multiple spaces + let formatted = command + .replace(/\n/g, " ") // Replace newlines with spaces + .replace(/\r/g, " ") // Replace carriage returns with spaces + .replace(/\t/g, " ") // Replace tabs with spaces + .replace(/\s+/g, " ") // Collapse multiple spaces into one + .trim(); // Remove leading/trailing whitespace + // Escape backticks to prevent markdown issues + formatted = formatted.replace(/`/g, "\\`"); + // Truncate if too long (keep reasonable length for summary) + 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) + "..."; + } + // Export for testing + if (typeof module !== "undefined" && module.exports) { + module.exports = { + parseClaudeLog, + formatToolUse, + formatBashCommand, + truncateString, + }; + } + main(); + - name: Upload agent logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: agent-cook-agentic-workflow-generator.log + path: /tmp/agent-cook-agentic-workflow-generator.log + if-no-files-found: warn + - name: Generate git patch + if: always() + env: + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + 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-branch line using simple grep and sed + elif echo "$line" | grep -q '"type"[[:space:]]*:[[:space:]]*"push-to-branch"'; then + echo "Found push-to-branch line: $line" + # For push-to-branch, we don't extract branch from JSONL since it's configured in the workflow + # The branch name should come from the environment variable GITHUB_AW_PUSH_BRANCH + if [ -n "$GITHUB_AW_PUSH_BRANCH" ]; then + BRANCH_NAME="$GITHUB_AW_PUSH_BRANCH" + echo "Using configured push-to-branch target: $BRANCH_NAME" + break + fi + fi + fi + done < "$GITHUB_AW_SAFE_OUTPUTS" + fi + + # Get the initial commit SHA from the base branch of the pull request + if [ "$GITHUB_EVENT_NAME" = "pull_request" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ]; then + INITIAL_SHA="$GITHUB_BASE_REF" + else + INITIAL_SHA="$GITHUB_SHA" + fi + echo "Base commit SHA: $INITIAL_SHA" + # Configure git user for GitHub Actions + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + + # 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" + # Generate patch from the base to the branch + git format-patch "$INITIAL_SHA".."$BRANCH_NAME" --stdout > /tmp/aw.patch || echo "Failed to generate patch from branch" > /tmp/aw.patch + echo "Patch file created from branch: $BRANCH_NAME" + else + echo "Branch $BRANCH_NAME does not exist, falling back to current HEAD" + BRANCH_NAME="" + fi + fi + + # If no branch or branch doesn't exist, use the existing logic + if [ -z "$BRANCH_NAME" ]; then + echo "Using current HEAD for patch generation" + # Stage any unstaged files + git add -A || true + # Check if there are staged files to commit + if ! git diff --cached --quiet; then + echo "Staged files found, committing them..." + git commit -m "[agent] staged files" || true + echo "Staged files committed" + else + echo "No staged files to commit" + fi + # Check updated git status + echo "Updated git status after committing staged files:" + git status + # Show compact diff information between initial commit and HEAD (committed changes only) + echo '## Git diff' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + git diff --name-only "$INITIAL_SHA"..HEAD >> $GITHUB_STEP_SUMMARY || true + echo '```' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + # Check if there are any committed changes since the initial commit + if git diff --quiet "$INITIAL_SHA" HEAD; then + echo "No committed changes detected since initial commit" + echo "Skipping patch generation - no committed changes to create patch from" + else + echo "Committed changes detected, generating patch..." + # Generate patch from initial commit to HEAD (committed changes only) + git format-patch "$INITIAL_SHA"..HEAD --stdout > /tmp/aw.patch || echo "Failed to generate patch" > /tmp/aw.patch + echo "Patch file created at /tmp/aw.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 -50 /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-cook-agentic-workflow-generator + 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 }} + 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: Create Pull Request + id: create_pull_request + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.agent-cook-agentic-workflow-generator.outputs.output }} + GITHUB_AW_WORKFLOW_ID: "agent-cook-agentic-workflow-generator" + GITHUB_AW_BASE_BRANCH: ${{ github.ref_name }} + GITHUB_AW_PR_TITLE_PREFIX: "[agent-cook] " + GITHUB_AW_PR_LABELS: "agentic-workflow,automation" + GITHUB_AW_PR_DRAFT: "true" + GITHUB_AW_PR_IF_NO_CHANGES: "warn" + with: + script: | + /** @type {typeof import("fs")} */ + const fs = require("fs"); + /** @type {typeof import("crypto")} */ + const crypto = require("crypto"); + const { execSync } = require("child_process"); + async function main() { + // Environment validation - fail early if required variables are missing + 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() === "") { + console.log("Agent output content is empty"); + } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; + // Check if patch file exists and has valid content + if (!fs.existsSync("/tmp/aw.patch")) { + const message = + "No patch file found - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + 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": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + console.log("Agent output content length:", outputContent.length); + if (!isEmpty) { + console.log("Patch content validation passed"); + } else { + console.log("Patch file is empty - processing noop operation"); + } + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + console.log( + "Error parsing agent output JSON:", + error instanceof Error ? error.message : String(error) + ); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + console.log("No valid items found in agent output"); + return; + } + // Find the create-pull-request item + const pullRequestItem = validatedOutput.items.find( + /** @param {any} item */ item => item.type === "create-pull-request" + ); + if (!pullRequestItem) { + console.log("No create-pull-request item found in agent output"); + return; + } + console.log("Found create-pull-request item:", { + title: pullRequestItem.title, + bodyLength: pullRequestItem.body.length, + }); + // Extract title, body, and branch from the JSON item + let title = pullRequestItem.title.trim(); + let bodyLines = pullRequestItem.body.split("\n"); + let branchName = pullRequestItem.branch + ? pullRequestItem.branch.trim() + : null; + // If no title was found, use a default + if (!title) { + title = "Agent Output"; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_PR_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + // Add AI disclaimer with run id, run htmlurl + 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 [${runId}](${runUrl})`, + "" + ); + // Prepare the body content + const body = bodyLines.join("\n").trim(); + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_PR_LABELS; + const labels = labelsEnv + ? labelsEnv + .split(",") + .map(/** @param {string} label */ label => label.trim()) + .filter(/** @param {string} label */ label => label) + : []; + // Parse draft setting from environment variable (defaults to true) + const draftEnv = process.env.GITHUB_AW_PR_DRAFT; + const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; + console.log("Creating pull request with title:", title); + console.log("Labels:", labels); + console.log("Draft:", draft); + console.log("Body length:", body.length); + // Use branch name from JSONL if provided, otherwise generate unique branch name + if (!branchName) { + console.log( + "No branch name provided in JSONL, generating unique branch name" + ); + // Generate unique branch name using cryptographic random hex + const randomHex = crypto.randomBytes(8).toString("hex"); + branchName = `${workflowId}/${randomHex}`; + } else { + console.log("Using branch name from JSONL:", branchName); + } + console.log("Generated branch name:", branchName); + console.log("Base branch:", baseBranch); + // Create a new branch using git CLI + // Configure git (required for commits) + execSync('git config --global user.email "action@github.com"', { + stdio: "inherit", + }); + execSync('git config --global user.name "GitHub Action"', { + stdio: "inherit", + }); + // Handle branch creation/checkout + const branchFromJsonl = pullRequestItem.branch + ? pullRequestItem.branch.trim() + : null; + if (branchFromJsonl) { + console.log("Checking if branch from JSONL exists:", branchFromJsonl); + console.log( + "Branch does not exist locally, creating new branch:", + branchFromJsonl + ); + execSync(`git checkout -b ${branchFromJsonl}`, { stdio: "inherit" }); + console.log("Using existing/created branch:", branchFromJsonl); + } else { + // Create and checkout new branch with generated name + execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); + console.log("Created and checked out new branch:", branchName); + } + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } else { + console.log("Skipping patch application (empty patch)"); + } + // Commit and push the changes + execSync("git add .", { stdio: "inherit" }); + // Check if there are changes to commit + let hasChanges = false; + let gitError = null; + try { + execSync("git diff --cached --exit-code", { stdio: "ignore" }); + // No changes - exit code 0 + hasChanges = false; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + if (!hasChanges) { + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to commit - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + if (hasChanges) { + execSync(`git commit -m "Add agent output: ${title}"`, { + stdio: "inherit", + }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed"); + } else { + // This should not happen due to the early return above, but keeping for safety + console.log("No changes to commit"); + return; + } + // Create the pull request + 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, + }); + console.log( + "Created pull request #" + pullRequest.number + ": " + pullRequest.html_url + ); + // Add labels if specified + if (labels.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pullRequest.number, + labels: labels, + }); + console.log("Added labels to pull request:", labels); + } + // Set output for other jobs to use + core.setOutput("pull_request_number", pullRequest.number); + core.setOutput("pull_request_url", pullRequest.html_url); + core.setOutput("branch_name", branchName); + // Write summary to GitHub Actions summary + await core.summary + .addRaw( + ` + ## Pull Request + - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) + - **Branch**: \`${branchName}\` + - **Base Branch**: \`${baseBranch}\` + ` + ) + .write(); + } + await main(); + diff --git a/.github/workflows/agent-cook.md b/.github/workflows/agent-cook.md new file mode 100644 index 00000000000..9e224793968 --- /dev/null +++ b/.github/workflows/agent-cook.md @@ -0,0 +1,102 @@ +--- +name: Agent Cook +on: + workflow_dispatch: + inputs: + trigger_specification: + description: 'Specify when the workflow should trigger (e.g., "on issues opened", "on push to main", "manually via workflow_dispatch")' + required: true + type: string + task_specification: + description: 'Describe what the workflow should do (e.g., "analyze the issue and provide suggestions", "run tests and create summary")' + required: true + type: string + output_specification: + description: 'Specify the desired output (e.g., "create issue comment", "create pull request", "add labels")' + required: true + type: string + +permissions: + contents: read + actions: read + +safe-outputs: + create-pull-request: + title-prefix: "[agent-cook] " + labels: [agentic-workflow, automation] + draft: true + +engine: claude +--- + +# Agent Cook - Agentic Workflow Generator + +You are the "Agent Cook" - an AI assistant that helps users create agentic workflows for GitHub repositories. Your task is to generate a new agentic workflow based on the user's specifications. + +## User's Requirements + +${{ github.event.inputs.trigger_specification }} + +${{ github.event.inputs.task_specification }} + +${{ github.event.inputs.output_specification }} + +## Your Task + +1. **Parse the Requirements**: Analyze the user's trigger, task, and output specifications to understand what they want to achieve. + +2. **Design the Workflow**: Create a complete agentic workflow markdown file with: + - Appropriate `on:` trigger configuration based on the trigger specification + - Proper `permissions:` section for the required GitHub API access + - Correct `safe-outputs:` configuration based on the output specification + - Clear, actionable instructions in natural language for the AI to execute + +3. **Validate the Syntax**: Use the gh-aw compiler to validate your generated workflow: + ```bash + # Create a temporary workflow file to test + echo "YOUR_GENERATED_WORKFLOW_CONTENT" > /tmp/test-workflow.md + + # Validate the syntax without generating lock files + gh-aw compile /tmp/test-workflow.md --no-emit --validate --verbose + ``` + +4. **Generate the Final Workflow**: Create a properly formatted agentic workflow file with: + - A meaningful name (derived from the task specification) + - Complete frontmatter configuration + - Clear, step-by-step instructions for the AI + - Proper use of GitHub context expressions where needed + +5. **Create Pull Request**: Save your final workflow as `.github/workflows/[generated-name].md` and create a pull request with: + - **Title**: "Add [workflow-name] agentic workflow" + - **Body**: + ``` + ## Generated Agentic Workflow + + This workflow was generated by Agent Cook based on the following specifications: + + **Trigger**: `${{ github.event.inputs.trigger_specification }}` + **Task**: `${{ github.event.inputs.task_specification }}` + **Output**: `${{ github.event.inputs.output_specification }}` + + ## Workflow Details + + [Include a brief explanation of what the workflow does and how it works] + + ## Usage + + [Include any special instructions for using the workflow] + ``` + +## Guidelines + +- Follow the agentic workflow format with YAML frontmatter and markdown content +- Use appropriate GitHub Actions triggers (issues, pull_request, workflow_dispatch, schedule, etc.) +- Include only necessary permissions - follow principle of least privilege +- Use safe-outputs for GitHub API interactions (create-issue, add-issue-comment, create-pull-request, etc.) +- Write clear, specific instructions that an AI can follow +- Include relevant GitHub context expressions (e.g., `${{ github.event.issue.number }}`) +- Ensure the workflow name is descriptive and follows kebab-case convention +- Test your generated workflow with the compiler before creating the PR +- For detailed formatting and configuration options, reference the comprehensive agentic workflow documentation + +Remember: You are creating a workflow FILE, not just explaining how to create one. The output should be a complete, working agentic workflow that can be compiled and executed. \ No newline at end of file diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 08a6f6930a2..5698a2f74aa 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -212,11 +212,12 @@ Examples: validate, _ := cmd.Flags().GetBool("validate") watch, _ := cmd.Flags().GetBool("watch") instructions, _ := cmd.Flags().GetBool("instructions") + noEmit, _ := cmd.Flags().GetBool("no-emit") if err := validateEngine(engineOverride); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } - if err := cli.CompileWorkflows(args, verbose, engineOverride, validate, watch, instructions); err != nil { + if err := cli.CompileWorkflows(args, verbose, engineOverride, validate, watch, instructions, noEmit); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } @@ -346,6 +347,7 @@ func init() { compileCmd.Flags().Bool("validate", false, "Enable GitHub Actions workflow schema validation") compileCmd.Flags().BoolP("watch", "w", false, "Watch for changes to workflow files and recompile automatically") compileCmd.Flags().Bool("instructions", false, "Generate or update GitHub Copilot instructions file") + compileCmd.Flags().Bool("no-emit", false, "Validate workflow without generating lock files") // Add flags to remove command removeCmd.Flags().Bool("keep-orphans", false, "Skip removal of orphaned include files that are no longer referenced by any workflow") diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 7f42b7045e7..e990147199d 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -568,13 +568,16 @@ func AddWorkflowWithTracking(workflow string, number int, verbose bool, engineOv } // CompileWorkflows compiles markdown files into GitHub Actions workflow files -func CompileWorkflows(markdownFiles []string, verbose bool, engineOverride string, validate bool, watch bool, writeInstructions bool) error { +func CompileWorkflows(markdownFiles []string, verbose bool, engineOverride string, validate bool, watch bool, writeInstructions bool, noEmit bool) error { // Create compiler with verbose flag and AI engine override compiler := workflow.NewCompiler(verbose, engineOverride, GetVersion()) // Set validation based on the validate flag (false by default for compatibility) compiler.SetSkipValidation(!validate) + // Set noEmit flag to validate without generating lock files + compiler.SetNoEmit(noEmit) + if watch { // Watch mode: watch for file changes and recompile automatically // For watch mode, we only support a single file for now diff --git a/pkg/cli/commands_compile_workflow_test.go b/pkg/cli/commands_compile_workflow_test.go index 7af1761b61a..f883f2b9f47 100644 --- a/pkg/cli/commands_compile_workflow_test.go +++ b/pkg/cli/commands_compile_workflow_test.go @@ -512,7 +512,7 @@ This is a test workflow for backward compatibility. if tt.workflowID != "" { args = []string{tt.workflowID} } - err = CompileWorkflows(args, false, "", false, false, false) + err = CompileWorkflows(args, false, "", false, false, false, false) if tt.expectError { if err == nil { diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index 106573e786a..77aaa0ef4e7 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -108,7 +108,7 @@ func TestCompileWorkflows(t *testing.T) { if tt.markdownFile != "" { args = []string{tt.markdownFile} } - err := CompileWorkflows(args, false, "", false, false, false) + err := CompileWorkflows(args, false, "", false, false, false, false) if tt.expectError && err == nil { t.Errorf("Expected error for test '%s', got nil", tt.name) @@ -119,6 +119,58 @@ func TestCompileWorkflows(t *testing.T) { } } +func TestCompileWorkflowsWithNoEmit(t *testing.T) { + defer os.RemoveAll(".github") + + // Create test directory and workflow + err := os.MkdirAll(".github/workflows", 0755) + if err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Create a simple test workflow + workflowContent := `--- +on: + workflow_dispatch: +permissions: + contents: read +--- + +# Test Workflow for No Emit + +This is a test workflow to verify the --no-emit flag functionality.` + + err = os.WriteFile(".github/workflows/no-emit-test.md", []byte(workflowContent), 0644) + if err != nil { + t.Fatalf("Failed to create test workflow file: %v", err) + } + + // Test compilation with noEmit = false (should create lock file) + err = CompileWorkflows([]string{"no-emit-test"}, false, "", false, false, false, false) + if err != nil { + t.Errorf("CompileWorkflows with noEmit=false should not error, got: %v", err) + } + + // Verify lock file was created + if _, err := os.Stat(".github/workflows/no-emit-test.lock.yml"); os.IsNotExist(err) { + t.Error("Lock file should have been created when noEmit=false") + } + + // Remove lock file + os.Remove(".github/workflows/no-emit-test.lock.yml") + + // Test compilation with noEmit = true (should NOT create lock file) + err = CompileWorkflows([]string{"no-emit-test"}, false, "", false, false, false, true) + if err != nil { + t.Errorf("CompileWorkflows with noEmit=true should not error, got: %v", err) + } + + // Verify lock file was NOT created + if _, err := os.Stat(".github/workflows/no-emit-test.lock.yml"); !os.IsNotExist(err) { + t.Error("Lock file should NOT have been created when noEmit=true") + } +} + func TestRemoveWorkflows(t *testing.T) { err := RemoveWorkflows("test-pattern", false) @@ -239,7 +291,7 @@ func TestAllCommandsExist(t *testing.T) { }{ {func() error { return ListWorkflows(false) }, false, "ListWorkflows"}, {func() error { return AddWorkflowWithTracking("", 1, false, "", "", false, nil) }, false, "AddWorkflowWithTracking (empty name)"}, // Shows help when empty, doesn't error - {func() error { return CompileWorkflows([]string{}, false, "", false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully + {func() error { return CompileWorkflows([]string{}, false, "", false, false, false, false) }, false, "CompileWorkflows"}, // Should compile existing markdown files successfully {func() error { return RemoveWorkflows("test", false) }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully {func() error { return StatusWorkflows("test", false) }, false, "StatusWorkflows"}, // Should handle missing directory gracefully {func() error { return EnableWorkflows("test") }, false, "EnableWorkflows"}, // Should handle missing directory gracefully diff --git a/pkg/cli/templates/instructions.md b/pkg/cli/templates/instructions.md index 19c5a076515..5d9037960a8 100644 --- a/pkg/cli/templates/instructions.md +++ b/pkg/cli/templates/instructions.md @@ -528,6 +528,8 @@ Additional instructions for the coding agent. ## Permission Patterns +**IMPORTANT**: When using `safe-outputs` configuration, agentic workflows should NOT include write permissions (`issues: write`, `pull-requests: write`, `contents: write`) in the main job. The safe-outputs system provides these capabilities through separate, secured jobs with appropriate permissions. + ### Read-Only Pattern ```yaml permissions: @@ -535,13 +537,6 @@ permissions: metadata: read ``` -### Direct Issue Management Pattern -```yaml -permissions: - contents: read - issues: write -``` - ### Output Processing Pattern (Recommended) ```yaml permissions: @@ -550,9 +545,24 @@ permissions: safe-outputs: create-issue: # Automatic issue creation + add-issue-comment: # Automatic comment creation + create-pull-request: # Automatic PR creation ``` -**Note**: With output processing, the main job doesn't need `issues: write`, `pull-requests: write`, or `contents: write` permissions. The separate output creation jobs automatically get the required permissions. +**Key Benefits of Safe-Outputs:** +- **Security**: Main job runs with minimal permissions +- **Separation of Concerns**: Write operations are handled by dedicated jobs +- **Permission Management**: Safe-outputs jobs automatically receive required permissions +- **Audit Trail**: Clear separation between AI processing and GitHub API interactions + +### Direct Issue Management Pattern (Not Recommended) +```yaml +permissions: + contents: read + issues: write # Avoid when possible - use safe-outputs instead +``` + +**Note**: Direct write permissions should only be used when safe-outputs cannot meet your workflow requirements. Always prefer the Output Processing Pattern with `safe-outputs` configuration. ## Output Processing Examples @@ -647,7 +657,7 @@ permissions: metadata: read ``` -### Full Repository Access +### Full Repository Access (Use with Caution) ```yaml permissions: contents: write @@ -658,6 +668,8 @@ permissions: discussions: write ``` +**Note**: Full write permissions should be avoided whenever possible. Use `safe-outputs` configuration instead to provide secure, controlled access to GitHub API operations without granting write permissions to the main AI job. + ## Common Workflow Patterns ### Issue Triage Bot diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index ce6f5dc8920..a0a4acdc3c5 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -35,6 +35,7 @@ type Compiler struct { customOutput string // If set, output will be written to this path instead of default location version string // Version of the extension skipValidation bool // If true, skip schema validation + noEmit bool // If true, validate without generating lock files jobManager *JobManager // Manages jobs and dependencies engineRegistry *EngineRegistry // Registry of available agentic engines fileTracker FileTracker // Optional file tracker for tracking created files @@ -91,6 +92,11 @@ func (c *Compiler) SetSkipValidation(skip bool) { c.skipValidation = skip } +// SetNoEmit configures whether to validate without generating lock files +func (c *Compiler) SetNoEmit(noEmit bool) { + c.noEmit = noEmit +} + // SetFileTracker sets the file tracker for tracking created files func (c *Compiler) SetFileTracker(tracker FileTracker) { c.fileTracker = tracker @@ -345,21 +351,27 @@ func (c *Compiler) CompileWorkflow(markdownPath string) error { fmt.Println(console.FormatWarningMessage("Schema validation available but skipped (use SetSkipValidation(false) to enable)")) } - // Write to lock file - if c.verbose { - fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Writing output to: %s", console.ToRelativePath(lockFile)))) - } - if err := os.WriteFile(lockFile, []byte(yamlContent), 0644); err != nil { - formattedErr := console.FormatError(console.CompilerError{ - Position: console.ErrorPosition{ - File: lockFile, - Line: 1, - Column: 1, - }, - Type: "error", - Message: fmt.Sprintf("failed to write lock file: %v", err), - }) - return errors.New(formattedErr) + // Write to lock file (unless noEmit is enabled) + if c.noEmit { + if c.verbose { + fmt.Println(console.FormatInfoMessage("Validation completed - no lock file generated (--no-emit enabled)")) + } + } else { + if c.verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Writing output to: %s", console.ToRelativePath(lockFile)))) + } + if err := os.WriteFile(lockFile, []byte(yamlContent), 0644); err != nil { + formattedErr := console.FormatError(console.CompilerError{ + Position: console.ErrorPosition{ + File: lockFile, + Line: 1, + Column: 1, + }, + Type: "error", + Message: fmt.Sprintf("failed to write lock file: %v", err), + }) + return errors.New(formattedErr) + } } fmt.Println(console.FormatSuccessMessage(console.ToRelativePath(markdownPath))) From 587d6334fc8a85edd8b1449dd9c964f893baef9f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:18:22 -0700 Subject: [PATCH 2/2] Add turn count extraction and display to logs command (#91) * Initial plan * Add turn count extraction and display to logs command Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * Fix Codex turn counting pattern matching for exec and tool commands * Format and lint code according to project standards Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: Peli de Halleux --- pkg/cli/logs.go | 19 +++++++- pkg/cli/logs_test.go | 83 ++++++++++++++++++++++++++++++++++- pkg/workflow/claude_engine.go | 26 ++++++++--- pkg/workflow/codex_engine.go | 13 ++++++ pkg/workflow/custom_engine.go | 33 ++++++++++++++ pkg/workflow/metrics.go | 1 + 6 files changed, 168 insertions(+), 7 deletions(-) diff --git a/pkg/cli/logs.go b/pkg/cli/logs.go index 9ca5a839299..4d35450c1d4 100644 --- a/pkg/cli/logs.go +++ b/pkg/cli/logs.go @@ -38,6 +38,7 @@ type WorkflowRun struct { Duration time.Duration TokenUsage int EstimatedCost float64 + Turns int LogsPath string } @@ -343,6 +344,7 @@ func DownloadWorkflowLogs(workflowName string, count int, startDate, endDate, ou run := result.Run run.TokenUsage = result.Metrics.TokenUsage run.EstimatedCost = result.Metrics.EstimatedCost + run.Turns = result.Metrics.Turns run.LogsPath = result.LogsPath // Store access analysis for later display (we'll access it via the result) @@ -741,6 +743,11 @@ func extractLogMetrics(logDir string, verbose bool) (LogMetrics, error) { metrics.EstimatedCost += fileMetrics.EstimatedCost metrics.ErrorCount += fileMetrics.ErrorCount metrics.WarningCount += fileMetrics.WarningCount + if fileMetrics.Turns > metrics.Turns { + // For turns, take the maximum rather than summing, since turns represent + // the total conversation turns for the entire workflow run + metrics.Turns = fileMetrics.Turns + } } return nil @@ -858,12 +865,13 @@ func displayLogsOverview(runs []WorkflowRun) { } // Prepare table data - headers := []string{"Run ID", "Workflow", "Status", "Duration", "Tokens", "Cost ($)", "Created", "Logs Path"} + headers := []string{"Run ID", "Workflow", "Status", "Duration", "Tokens", "Cost ($)", "Turns", "Created", "Logs Path"} var rows [][]string var totalTokens int var totalCost float64 var totalDuration time.Duration + var totalTurns int for _, run := range runs { // Format duration @@ -887,6 +895,13 @@ func displayLogsOverview(runs []WorkflowRun) { totalTokens += run.TokenUsage } + // Format turns + turnsStr := "N/A" + if run.Turns > 0 { + turnsStr = fmt.Sprintf("%d", run.Turns) + totalTurns += run.Turns + } + // Truncate workflow name if too long workflowName := run.WorkflowName if len(workflowName) > 20 { @@ -903,6 +918,7 @@ func displayLogsOverview(runs []WorkflowRun) { durationStr, tokensStr, costStr, + turnsStr, run.CreatedAt.Format("2006-01-02"), relPath, } @@ -917,6 +933,7 @@ func displayLogsOverview(runs []WorkflowRun) { formatDuration(totalDuration), formatNumber(totalTokens), fmt.Sprintf("%.3f", totalCost), + fmt.Sprintf("%d", totalTurns), "", "", } diff --git a/pkg/cli/logs_test.go b/pkg/cli/logs_test.go index cf147332a08..2761ca92de2 100644 --- a/pkg/cli/logs_test.go +++ b/pkg/cli/logs_test.go @@ -466,6 +466,12 @@ Claude processing request... t.Errorf("Expected cost %f, got %f", expectedCost, metrics.EstimatedCost) } + // Check turns extraction from num_turns + expectedTurns := 66 + if metrics.Turns != expectedTurns { + t.Errorf("Expected turns %d, got %d", expectedTurns, metrics.Turns) + } + // Duration is no longer extracted from logs - using GitHub API timestamps instead } @@ -474,11 +480,15 @@ func TestParseLogFileWithCodexFormat(t *testing.T) { tmpDir := t.TempDir() logFile := filepath.Join(tmpDir, "test-codex.log") - // This is the exact Codex format provided in the issue + // This is the exact Codex format provided in the issue with thinking sections added logContent := `[2025-08-13T00:24:45] Starting Codex workflow execution +[2025-08-13T00:24:50] thinking +I need to analyze the pull request details first. [2025-08-13T00:24:50] codex I'm ready to generate a Codex PR summary, but I need the pull request number to fetch its details. Could you please share the PR number (and confirm the repo/owner if it isn't ` + "`githubnext/gh-aw`" + `)? +[2025-08-13T00:24:50] thinking +Now I need to wait for the user's response. [2025-08-13T00:24:50] tokens used: 13934 [2025-08-13T00:24:55] Workflow completed successfully` @@ -500,6 +510,12 @@ I'm ready to generate a Codex PR summary, but I need the pull request number to t.Errorf("Expected token usage %d, got %d", expectedTokens, metrics.TokenUsage) } + // Check turns extraction from thinking sections + expectedTurns := 2 // Two thinking sections in the test data + if metrics.Turns != expectedTurns { + t.Errorf("Expected turns %d, got %d", expectedTurns, metrics.Turns) + } + // Duration is no longer extracted from logs - using GitHub API timestamps instead } @@ -875,3 +891,68 @@ func TestExtractLogMetricsWithAwOutputFile(t *testing.T) { t.Fatalf("extractLogMetrics in verbose mode failed: %v", err) } } + +func TestCustomEngineParseLogMetrics(t *testing.T) { + // Test that custom engine tries both Claude and Codex parsing approaches + customEngine := workflow.NewCustomEngine() + + // Test Case 1: Claude-style logs (properly formatted as JSON array) + claudeLogContent := `[{"type": "message", "content": "Starting workflow"}, {"type": "result", "subtype": "success", "is_error": false, "num_turns": 42, "total_cost_usd": 1.5, "usage": {"input_tokens": 1000, "output_tokens": 500}}]` + + metrics := customEngine.ParseLogMetrics(claudeLogContent, false) + + // Should extract turns, tokens, and cost from Claude format + if metrics.Turns != 42 { + t.Errorf("Expected turns 42 from Claude-style logs, got %d", metrics.Turns) + } + if metrics.TokenUsage != 1500 { + t.Errorf("Expected token usage 1500 from Claude-style logs, got %d", metrics.TokenUsage) + } + if metrics.EstimatedCost != 1.5 { + t.Errorf("Expected cost 1.5 from Claude-style logs, got %f", metrics.EstimatedCost) + } + + // Test Case 2: Codex-style logs + codexLogContent := `[2025-08-13T00:24:45] Starting workflow +[2025-08-13T00:24:50] thinking +I need to analyze the problem. +[2025-08-13T00:24:51] codex +Working on solution. +[2025-08-13T00:24:52] thinking +Now I'll implement the solution. +[2025-08-13T00:24:53] tokens used: 5000 +[2025-08-13T00:24:55] Workflow completed` + + metrics = customEngine.ParseLogMetrics(codexLogContent, false) + + // Should extract turns and tokens from Codex format + if metrics.Turns != 2 { + t.Errorf("Expected turns 2 from Codex-style logs, got %d", metrics.Turns) + } + if metrics.TokenUsage != 5000 { + t.Errorf("Expected token usage 5000 from Codex-style logs, got %d", metrics.TokenUsage) + } + + // Test Case 3: Generic logs (fallback) + genericLogContent := `2025-08-13T00:24:45 Starting workflow +2025-08-13T00:24:50 Processing request +2025-08-13T00:24:52 Warning: Something happened +2025-08-13T00:24:53 Error: Failed to process +2025-08-13T00:24:55 Workflow completed` + + metrics = customEngine.ParseLogMetrics(genericLogContent, false) + + // Should fall back to basic parsing + if metrics.Turns != 0 { + t.Errorf("Expected turns 0 from generic logs, got %d", metrics.Turns) + } + if metrics.TokenUsage != 0 { + t.Errorf("Expected token usage 0 from generic logs, got %d", metrics.TokenUsage) + } + if metrics.WarningCount != 1 { + t.Errorf("Expected warning count 1 from generic logs, got %d", metrics.WarningCount) + } + if metrics.ErrorCount != 1 { + t.Errorf("Expected error count 1 from generic logs, got %d", metrics.ErrorCount) + } +} diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index ae94ff1cc35..5914eb9e29b 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -655,9 +655,10 @@ func (e *ClaudeEngine) ParseLogMetrics(logContent string, verbose bool) LogMetri // First try to parse as JSON array (Claude logs are structured as JSON arrays) if strings.TrimSpace(logContent) != "" { - if resultMetrics := e.parseClaudeJSONLog(logContent, verbose); resultMetrics.TokenUsage > 0 || resultMetrics.EstimatedCost > 0 { + if resultMetrics := e.parseClaudeJSONLog(logContent, verbose); resultMetrics.TokenUsage > 0 || resultMetrics.EstimatedCost > 0 || resultMetrics.Turns > 0 { metrics.TokenUsage = resultMetrics.TokenUsage metrics.EstimatedCost = resultMetrics.EstimatedCost + metrics.Turns = resultMetrics.Turns } } @@ -671,15 +672,16 @@ func (e *ClaudeEngine) ParseLogMetrics(logContent string, verbose bool) LogMetri } // If we haven't found cost data yet from JSON parsing, try streaming JSON - if metrics.TokenUsage == 0 || metrics.EstimatedCost == 0 { + if metrics.TokenUsage == 0 || metrics.EstimatedCost == 0 || metrics.Turns == 0 { jsonMetrics := ExtractJSONMetrics(line, verbose) if jsonMetrics.TokenUsage > 0 || jsonMetrics.EstimatedCost > 0 { // Check if this is a Claude result payload with aggregated costs if e.isClaudeResultPayload(line) { // For Claude result payloads, use the aggregated values directly - if resultMetrics := e.extractClaudeResultMetrics(line); resultMetrics.TokenUsage > 0 || resultMetrics.EstimatedCost > 0 { + if resultMetrics := e.extractClaudeResultMetrics(line); resultMetrics.TokenUsage > 0 || resultMetrics.EstimatedCost > 0 || resultMetrics.Turns > 0 { metrics.TokenUsage = resultMetrics.TokenUsage metrics.EstimatedCost = resultMetrics.EstimatedCost + metrics.Turns = resultMetrics.Turns } } else { // For streaming JSON, keep the maximum token usage found @@ -765,6 +767,13 @@ func (e *ClaudeEngine) extractClaudeResultMetrics(line string) LogMetrics { } } + // Extract number of turns + if numTurns, exists := jsonData["num_turns"]; exists { + if turns := ConvertToInt(numTurns); turns > 0 { + metrics.Turns = turns + } + } + return metrics } @@ -807,9 +816,16 @@ func (e *ClaudeEngine) parseClaudeJSONLog(logContent string, verbose bool) LogMe } } + // Extract number of turns + if numTurns, exists := entry["num_turns"]; exists { + if turns := ConvertToInt(numTurns); turns > 0 { + metrics.Turns = turns + } + } + if verbose { - fmt.Printf("Extracted from Claude result payload: tokens=%d, cost=%.4f\n", - metrics.TokenUsage, metrics.EstimatedCost) + fmt.Printf("Extracted from Claude result payload: tokens=%d, cost=%.4f, turns=%d\n", + metrics.TokenUsage, metrics.EstimatedCost, metrics.Turns) } break } diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 595f76aeff2..50f11dc8af0 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -236,6 +236,8 @@ func (e *CodexEngine) ParseLogMetrics(logContent string, verbose bool) LogMetric var totalTokenUsage int lines := strings.Split(logContent, "\n") + turns := 0 + inThinkingSection := false for _, line := range lines { // Skip empty lines @@ -243,6 +245,16 @@ func (e *CodexEngine) ParseLogMetrics(logContent string, verbose bool) LogMetric continue } + // Detect thinking sections as indicators of turns + if strings.Contains(line, "] thinking") { + if !inThinkingSection { + turns++ + inThinkingSection = true + } + } else if strings.Contains(line, "] tool") || strings.Contains(line, "] exec") || strings.Contains(line, "] codex") { + inThinkingSection = false + } + // Extract Codex-specific token usage (always sum for Codex) if tokenUsage := e.extractCodexTokenUsage(line); tokenUsage > 0 { totalTokenUsage += tokenUsage @@ -259,6 +271,7 @@ func (e *CodexEngine) ParseLogMetrics(logContent string, verbose bool) LogMetric } metrics.TokenUsage = totalTokenUsage + metrics.Turns = turns return metrics } diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 24c877b5322..0d384a51607 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -245,9 +245,42 @@ func (e *CustomEngine) renderCustomMCPConfig(yaml *strings.Builder, toolName str } // ParseLogMetrics implements basic log parsing for custom engine +// For custom engines, try both Claude and Codex parsing approaches to extract turn information func (e *CustomEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics { var metrics LogMetrics + // First try Claude-style parsing to see if the logs are Claude-format + registry := GetGlobalEngineRegistry() + claudeEngine, err := registry.GetEngine("claude") + if err == nil { + claudeMetrics := claudeEngine.ParseLogMetrics(logContent, verbose) + if claudeMetrics.Turns > 0 || claudeMetrics.TokenUsage > 0 || claudeMetrics.EstimatedCost > 0 { + // Found structured data, use Claude parsing + if verbose { + fmt.Println("Custom engine: Using Claude-style parsing for logs") + } + return claudeMetrics + } + } + + // Try Codex-style parsing if Claude didn't yield results + codexEngine, err := registry.GetEngine("codex") + if err == nil { + codexMetrics := codexEngine.ParseLogMetrics(logContent, verbose) + if codexMetrics.Turns > 0 || codexMetrics.TokenUsage > 0 { + // Found some data, use Codex parsing + if verbose { + fmt.Println("Custom engine: Using Codex-style parsing for logs") + } + return codexMetrics + } + } + + // Fall back to basic parsing if neither Claude nor Codex approaches work + if verbose { + fmt.Println("Custom engine: Using basic fallback parsing for logs") + } + lines := strings.Split(logContent, "\n") for _, line := range lines { if strings.TrimSpace(line) == "" { diff --git a/pkg/workflow/metrics.go b/pkg/workflow/metrics.go index 11a1fa87807..c1e864696d5 100644 --- a/pkg/workflow/metrics.go +++ b/pkg/workflow/metrics.go @@ -13,6 +13,7 @@ type LogMetrics struct { EstimatedCost float64 ErrorCount int WarningCount int + Turns int // Number of turns needed to complete the task // Timestamp removed - use GitHub API timestamps instead of parsing from logs }