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
}