From cab1e1b5b3b356d3e107073288947473a70e07d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:57:20 +0000 Subject: [PATCH 1/4] Initial plan From 451659cc3c84d34fbdc088ff3f25cb4987d4163a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:18:38 +0000 Subject: [PATCH 2/4] Implement staged flag for safe-outputs functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 45 + .github/workflows/test-staged.lock.yml | 1707 +++++++++++++++++ .github/workflows/test-staged.md | 13 + pkg/parser/schemas/main_workflow_schema.json | 4 + pkg/workflow/claude_engine.go | 10 + pkg/workflow/codex_engine.go | 5 + pkg/workflow/compiler.go | 8 + pkg/workflow/custom_engine.go | 5 + pkg/workflow/js/add_labels.cjs | 21 + .../js/create_code_scanning_alert.cjs | 21 + pkg/workflow/js/create_comment.cjs | 26 + pkg/workflow/js/create_discussion.cjs | 24 + pkg/workflow/js/create_issue.cjs | 27 + pkg/workflow/js/create_pr_review_comment.cjs | 24 + pkg/workflow/js/create_pull_request.cjs | 32 + pkg/workflow/js/push_to_pr_branch.cjs | 27 + pkg/workflow/js/update_issue.cjs | 35 + 17 files changed, 2034 insertions(+) create mode 100644 .github/workflows/test-staged.lock.yml create mode 100644 .github/workflows/test-staged.md diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 16dae4a926f..02be06bba7c 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1611,6 +1611,8 @@ jobs: with: script: | async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -1646,6 +1648,27 @@ jobs: return; } console.log(`Found ${createIssueItems.length} create-issue item(s)`); + // If in staged mode, emit step summary instead of creating issues + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Issue creation preview written to step summary"); + return; + } // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; // Parse labels from environment variable (comma-separated string) @@ -1788,6 +1811,8 @@ jobs: with: script: | async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -1823,6 +1848,26 @@ jobs: return; } console.log(`Found ${commentItems.length} add-issue-comment item(s)`); + // If in staged mode, emit step summary instead of creating comments + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; + summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; + for (let i = 0; i < commentItems.length; i++) { + const item = commentItems[i]; + summaryContent += `### Comment ${i + 1}\n`; + if (item.issue_number) { + summaryContent += `**Target Issue:** #${item.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; + summaryContent += "---\n\n"; + } + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Comment creation preview written to step summary"); + return; + } // Get the target configuration from environment variable const commentTarget = process.env.GITHUB_AW_COMMENT_TARGET || "triggering"; console.log(`Comment target configuration: ${commentTarget}`); diff --git a/.github/workflows/test-staged.lock.yml b/.github/workflows/test-staged.lock.yml new file mode 100644 index 00000000000..6f9c97ab25c --- /dev/null +++ b/.github/workflows/test-staged.lock.yml @@ -0,0 +1,1707 @@ +# 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: "Test Staged Workflow" +on: push + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Test Staged Workflow" + +jobs: + test-staged-workflow: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + outputs: + output: ${{ steps.collect_output.outputs.output }} + steps: + - name: Check team membership for workflow + id: check-team-member + uses: actions/github-script@v7 + env: + GITHUB_AW_REQUIRED_ROLES: admin,maintainer + with: + script: | + async function main() { + const { eventName } = context; + // skip check for safe events + const safeEvents = ["workflow_dispatch", "workflow_run", "schedule"]; + if (safeEvents.includes(eventName)) { + console.log(`✅ Event ${eventName} does not require validation`); + return; + } + const actor = context.actor; + const { owner, repo } = context.repo; + const requiredPermissionsEnv = process.env.GITHUB_AW_REQUIRED_ROLES; + const requiredPermissions = requiredPermissionsEnv + ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") + : []; + if (!requiredPermissions || requiredPermissions.length === 0) { + core.error( + "❌ Configuration error: Required permissions not specified. Contact repository administrator." + ); + process.exit(1); + } + // Check if the actor has the required repository permissions + try { + console.log( + `Checking if user '${actor}' has required permissions for ${owner}/${repo}` + ); + console.log(`Required permissions: ${requiredPermissions.join(", ")}`); + const repoPermission = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor, + }); + const permission = repoPermission.data.permission; + console.log(`Repository permission level: ${permission}`); + // Check if user has one of the required permission levels + for (const requiredPerm of requiredPermissions) { + if ( + permission === requiredPerm || + (requiredPerm === "maintainer" && permission === "maintain") + ) { + console.log(`✅ User has ${permission} access to repository`); + return; + } + } + console.log( + `User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}` + ); + } catch (repoError) { + const errorMessage = + repoError instanceof Error ? repoError.message : String(repoError); + core.error(`Repository permission check failed: ${errorMessage}`); + process.exit(1); + } + // Fail the job when permission check fails + core.warning( + `❌ Access denied: Only authorized users can trigger this workflow. User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` + ); + process.exit(78); + } + await main(); + - name: Checkout repository + uses: actions/checkout@v5 + - 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 + fs.mkdirSync("/tmp", { recursive: true }); + // We don't create the file, as the name is sufficiently random + // and some engines (Claude) fails first Write to the file + // if it exists and has not been read. + // Set the environment variable for subsequent steps + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", 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' + # Test Staged Workflow + + This workflow should create an issue in staged mode (preview only). + + + --- + + ## Creating an IssueReporting 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 an Issue** + + To create an issue: + 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": + ```json + {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} + ``` + 2. 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-issue", "title": "Bug Report", "body": "Found an issue with..."} + ``` + + **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 + env: + GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt + - 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: "Test Staged Workflow", + 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): + # - ExitPlanMode + # - Glob + # - Grep + # - LS + # - NotebookRead + # - Read + # - Task + # - TodoWrite + # - Write + # - mcp__github__download_workflow_run_artifact + # - mcp__github__get_code_scanning_alert + # - mcp__github__get_commit + # - mcp__github__get_dependabot_alert + # - mcp__github__get_discussion + # - mcp__github__get_discussion_comments + # - mcp__github__get_file_contents + # - mcp__github__get_issue + # - mcp__github__get_issue_comments + # - mcp__github__get_job_logs + # - mcp__github__get_me + # - mcp__github__get_notification_details + # - mcp__github__get_pull_request + # - mcp__github__get_pull_request_comments + # - mcp__github__get_pull_request_diff + # - mcp__github__get_pull_request_files + # - mcp__github__get_pull_request_reviews + # - mcp__github__get_pull_request_status + # - mcp__github__get_secret_scanning_alert + # - mcp__github__get_tag + # - mcp__github__get_workflow_run + # - mcp__github__get_workflow_run_logs + # - mcp__github__get_workflow_run_usage + # - mcp__github__list_branches + # - mcp__github__list_code_scanning_alerts + # - mcp__github__list_commits + # - mcp__github__list_dependabot_alerts + # - mcp__github__list_discussion_categories + # - mcp__github__list_discussions + # - mcp__github__list_issues + # - mcp__github__list_notifications + # - mcp__github__list_pull_requests + # - mcp__github__list_secret_scanning_alerts + # - mcp__github__list_tags + # - mcp__github__list_workflow_jobs + # - mcp__github__list_workflow_run_artifacts + # - mcp__github__list_workflow_runs + # - mcp__github__list_workflows + # - mcp__github__search_code + # - mcp__github__search_issues + # - mcp__github__search_orgs + # - mcp__github__search_pull_requests + # - mcp__github__search_repositories + # - mcp__github__search_users + allowed_tools: "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_env: | + GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" + mcp_config: /tmp/mcp-config/mcp-servers.json + prompt_file: /tmp/aw-prompts/prompt.txt + 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/test-staged-workflow.log + else + echo "No execution file output found from Agentic Action" >> /tmp/test-staged-workflow.log + fi + + # Ensure log file exists + touch /tmp/test-staged-workflow.log + - name: Print Agent output + 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 + - name: Upload agentic output file + if: always() + uses: actions/upload-artifact@v4 + with: + name: safe_output.jsonl + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest 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-issue\":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-pr-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-code-scanning-alert": + return 1000; // Allow many repository security advisories (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(); + // remove invalid control characters like + // U+0014 (DC4) — represented here as "\u0014" + // Escape control characters not allowed in JSON strings (U+0000 through U+001F) + // Preserve common JSON escapes for \b, \f, \n, \r, \t and use \uXXXX for the rest. + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + // 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, throw the error + console.log(`invalid input json: ${jsonStr}`); + throw new Error( + `JSON parsing failed. Original: ${originalError.message}. After attempted repair: ${repairError.message}` + ); + } + } + } + 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-pr-branch": + // Validate message if provided (optional) + if (item.message !== undefined) { + if (typeof item.message !== "string") { + errors.push( + `Line ${i + 1}: push-to-pr-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-pr-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-code-scanning-alert": + // Validate required fields + if (!item.file || typeof item.file !== "string") { + errors.push( + `Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)` + ); + continue; + } + if ( + item.line === undefined || + item.line === null || + (typeof item.line !== "number" && typeof item.line !== "string") + ) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert requires a 'line' field (number or string)` + ); + continue; + } + // Additional validation: line must be parseable as a positive integer + const parsedLine = parseInt(item.line, 10); + if (isNaN(parsedLine) || parsedLine <= 0) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${item.line})` + ); + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push( + `Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)` + ); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push( + `Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)` + ); + continue; + } + // Validate severity level + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}` + ); + continue; + } + // Validate optional column field + if (item.column !== undefined) { + if ( + typeof item.column !== "number" && + typeof item.column !== "string" + ) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'column' must be a number or string` + ); + continue; + } + // Additional validation: must be parseable as a positive integer + const parsedColumn = parseInt(item.column, 10); + if (isNaN(parsedColumn) || parsedColumn <= 0) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${item.column})` + ); + continue; + } + } + // Validate optional ruleIdSuffix field + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string` + ); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + // Normalize severity to lowercase and sanitize string fields + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + 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}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + // 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) { + core.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 sanitized agent output + run: | + 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 sanitized agent output + 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/test-staged-workflow.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: test-staged-workflow.log + path: /tmp/test-staged-workflow.log + if-no-files-found: warn + + create_issue: + needs: test-staged-workflow + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.create_issue.outputs.issue_number }} + issue_url: ${{ steps.create_issue.outputs.issue_url }} + steps: + - name: Check team membership for workflow + id: check-team-member + uses: actions/github-script@v7 + env: + GITHUB_AW_REQUIRED_ROLES: admin,maintainer + with: + script: | + async function main() { + const { eventName } = context; + // skip check for safe events + const safeEvents = ["workflow_dispatch", "workflow_run", "schedule"]; + if (safeEvents.includes(eventName)) { + console.log(`✅ Event ${eventName} does not require validation`); + return; + } + const actor = context.actor; + const { owner, repo } = context.repo; + const requiredPermissionsEnv = process.env.GITHUB_AW_REQUIRED_ROLES; + const requiredPermissions = requiredPermissionsEnv + ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") + : []; + if (!requiredPermissions || requiredPermissions.length === 0) { + core.error( + "❌ Configuration error: Required permissions not specified. Contact repository administrator." + ); + process.exit(1); + } + // Check if the actor has the required repository permissions + try { + console.log( + `Checking if user '${actor}' has required permissions for ${owner}/${repo}` + ); + console.log(`Required permissions: ${requiredPermissions.join(", ")}`); + const repoPermission = + await github.rest.repos.getCollaboratorPermissionLevel({ + owner: owner, + repo: repo, + username: actor, + }); + const permission = repoPermission.data.permission; + console.log(`Repository permission level: ${permission}`); + // Check if user has one of the required permission levels + for (const requiredPerm of requiredPermissions) { + if ( + permission === requiredPerm || + (requiredPerm === "maintainer" && permission === "maintain") + ) { + console.log(`✅ User has ${permission} access to repository`); + return; + } + } + console.log( + `User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}` + ); + } catch (repoError) { + const errorMessage = + repoError instanceof Error ? repoError.message : String(repoError); + core.error(`Repository permission check failed: ${errorMessage}`); + process.exit(1); + } + // Fail the job when permission check fails + core.warning( + `❌ Access denied: Only authorized users can trigger this workflow. User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` + ); + process.exit(78); + } + await main(); + - name: Create Output Issue + id: create_issue + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-staged-workflow.outputs.output }} + with: + script: | + async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Read the validated output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + console.log("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + console.log("Agent output content is empty"); + return; + } + console.log("Agent output content length:", outputContent.length); + // 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 all create-issue items + const createIssueItems = validatedOutput.items.filter( + /** @param {any} item */ item => item.type === "create-issue" + ); + if (createIssueItems.length === 0) { + console.log("No create-issue items found in agent output"); + return; + } + console.log(`Found ${createIssueItems.length} create-issue item(s)`); + // If in staged mode, emit step summary instead of creating issues + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Issue creation preview written to step summary"); + return; + } + // Check if we're in an issue context (triggered by an issue event) + const parentIssueNumber = context.payload?.issue?.number; + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(/** @param {string} label */ label => label.trim()) + .filter(/** @param {string} label */ label => label) + : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log( + `Processing create-issue item ${i + 1}/${createIssueItems.length}:`, + { title: createIssueItem.title, bodyLength: createIssueItem.body.length } + ); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + console.log("Detected issue context, parent issue #" + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push( + ``, + ``, + `> Generated by Agentic Workflow [Run](${runUrl})`, + "" + ); + // Prepare the body content + const body = bodyLines.join("\n").trim(); + console.log("Creating issue with title:", title); + console.log("Labels:", labels); + console.log("Body length:", body.length); + try { + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + console.log("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + console.log("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + console.log( + "Warning: Could not add comment to parent issue:", + error instanceof Error ? error.message : String(error) + ); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + // Special handling for disabled issues repository + if ( + errorMessage.includes("Issues has been disabled in this repository") + ) { + console.log( + `⚠ Cannot create issue "${title}": Issues are disabled for this repository` + ); + console.log( + "Consider enabling issues in repository settings if you want to create issues automatically" + ); + continue; // Skip this issue but continue processing others + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + console.log(`Successfully created ${createdIssues.length} issue(s)`); + } + await main(); + diff --git a/.github/workflows/test-staged.md b/.github/workflows/test-staged.md new file mode 100644 index 00000000000..94ec17b688e --- /dev/null +++ b/.github/workflows/test-staged.md @@ -0,0 +1,13 @@ +--- +on: push +permissions: + contents: read + issues: write +safe-outputs: + create-issue: + staged: true +--- + +# Test Staged Workflow + +This workflow should create an issue in staged mode (preview only). \ No newline at end of file diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 2c0c11d5cb2..2f0c6c8f28c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1500,6 +1500,10 @@ "description": "Enable missing tool reporting with default configuration" } ] + }, + "staged": { + "type": "boolean", + "description": "If true, emit step summary messages instead of making GitHub API calls (preview mode)" } }, "additionalProperties": false diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 5d98d62fcc6..a6071dac2aa 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -88,6 +88,16 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str claudeEnv := "" if hasOutput { claudeEnv += " GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + + // Add staged flag if specified + if workflowData.SafeOutputs.Staged != nil { + if *workflowData.SafeOutputs.Staged { + if claudeEnv != "" { + claudeEnv += "\n" + } + claudeEnv += " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"" + } + } } // Add custom environment variables from engine config diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 7409d5523d1..d2c862c1989 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -116,6 +116,11 @@ codex exec \ hasOutput := workflowData.SafeOutputs != nil if hasOutput { env["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + + // Add staged flag if specified + if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { + env["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" + } } // Add custom environment variables from engine config diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index d96b281fbca..f2fcaffa3df 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -164,6 +164,7 @@ type SafeOutputsConfig struct { PushToPullRequestBranch *PushToPullRequestBranchConfig `yaml:"push-to-pr-branch,omitempty"` MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality AllowedDomains []string `yaml:"allowed-domains,omitempty"` + Staged *bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls } // CreateIssuesConfig holds configuration for creating GitHub issues from agent output @@ -3503,6 +3504,13 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut if missingToolConfig != nil { config.MissingTool = missingToolConfig } + + // Handle staged flag + if staged, exists := outputMap["staged"]; exists { + if stagedBool, ok := staged.(bool); ok { + config.Staged = &stagedBool + } + } } } diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 2b8a9579adc..94d3f611850 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -52,6 +52,11 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Add GITHUB_AW_SAFE_OUTPUTS if safe-outputs feature is used if workflowData.SafeOutputs != nil { envVars["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + + // Add staged flag if specified + if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { + envVars["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" + } } // Add GITHUB_AW_MAX_TURNS if max-turns is configured diff --git a/pkg/workflow/js/add_labels.cjs b/pkg/workflow/js/add_labels.cjs index 0959482bd2e..5dd54f96be8 100644 --- a/pkg/workflow/js/add_labels.cjs +++ b/pkg/workflow/js/add_labels.cjs @@ -43,6 +43,27 @@ async function main() { labelsCount: labelsItem.labels.length, }); + // If in staged mode, emit step summary instead of adding labels + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Label addition preview written to step summary"); + return; + } + // Read the allowed labels from environment variable (optional) const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED; let allowedLabels = null; diff --git a/pkg/workflow/js/create_code_scanning_alert.cjs b/pkg/workflow/js/create_code_scanning_alert.cjs index ec7b9a61658..f4f8df2d8dd 100644 --- a/pkg/workflow/js/create_code_scanning_alert.cjs +++ b/pkg/workflow/js/create_code_scanning_alert.cjs @@ -46,6 +46,27 @@ async function main() { `Found ${securityItems.length} create-code-scanning-alert item(s)` ); + // If in staged mode, emit step summary instead of creating code scanning alerts + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Code Scanning Alerts Preview\n\n"; + summaryContent += "The following code scanning alerts would be created if staged mode was disabled:\n\n"; + + for (let i = 0; i < securityItems.length; i++) { + const item = securityItems[i]; + summaryContent += `### Security Finding ${i + 1}\n`; + summaryContent += `**File:** ${item.file || "No file provided"}\n\n`; + summaryContent += `**Line:** ${item.line || "No line provided"}\n\n`; + summaryContent += `**Severity:** ${item.severity || "No severity provided"}\n\n`; + summaryContent += `**Message:**\n${item.message || "No message provided"}\n\n`; + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Code scanning alert creation preview written to step summary"); + return; + } + // Get the max configuration from environment variable const maxFindings = process.env.GITHUB_AW_SECURITY_REPORT_MAX ? parseInt(process.env.GITHUB_AW_SECURITY_REPORT_MAX) diff --git a/pkg/workflow/js/create_comment.cjs b/pkg/workflow/js/create_comment.cjs index 080f64da4f7..c2d5fcaaf1c 100644 --- a/pkg/workflow/js/create_comment.cjs +++ b/pkg/workflow/js/create_comment.cjs @@ -1,4 +1,7 @@ async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -41,6 +44,29 @@ async function main() { console.log(`Found ${commentItems.length} add-issue-comment item(s)`); + // If in staged mode, emit step summary instead of creating comments + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; + summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; + + for (let i = 0; i < commentItems.length; i++) { + const item = commentItems[i]; + summaryContent += `### Comment ${i + 1}\n`; + if (item.issue_number) { + summaryContent += `**Target Issue:** #${item.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Comment creation preview written to step summary"); + return; + } + // Get the target configuration from environment variable const commentTarget = process.env.GITHUB_AW_COMMENT_TARGET || "triggering"; console.log(`Comment target configuration: ${commentTarget}`); diff --git a/pkg/workflow/js/create_discussion.cjs b/pkg/workflow/js/create_discussion.cjs index 85f34fb6443..116b3af365a 100644 --- a/pkg/workflow/js/create_discussion.cjs +++ b/pkg/workflow/js/create_discussion.cjs @@ -42,6 +42,30 @@ async function main() { `Found ${createDiscussionItems.length} create-discussion item(s)` ); + // If in staged mode, emit step summary instead of creating discussions + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Discussion creation preview written to step summary"); + return; + } + // Get repository ID and discussion categories using GraphQL API let discussionCategories = []; let repositoryId = null; diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index f0f0811cd97..1f884352ac6 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -1,4 +1,7 @@ async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -40,6 +43,30 @@ async function main() { console.log(`Found ${createIssueItems.length} create-issue item(s)`); + // If in staged mode, emit step summary instead of creating issues + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Issue creation preview written to step summary"); + return; + } + // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; diff --git a/pkg/workflow/js/create_pr_review_comment.cjs b/pkg/workflow/js/create_pr_review_comment.cjs index 732b1517de7..37cba08b718 100644 --- a/pkg/workflow/js/create_pr_review_comment.cjs +++ b/pkg/workflow/js/create_pr_review_comment.cjs @@ -46,6 +46,30 @@ async function main() { `Found ${reviewCommentItems.length} create-pull-request-review-comment item(s)` ); + // If in staged mode, emit step summary instead of creating review comments + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create PR Review Comments Preview\n\n"; + summaryContent += "The following review comments would be created if staged mode was disabled:\n\n"; + + for (let i = 0; i < reviewCommentItems.length; i++) { + const item = reviewCommentItems[i]; + summaryContent += `### Review Comment ${i + 1}\n`; + summaryContent += `**File:** ${item.path || "No path provided"}\n\n`; + summaryContent += `**Line:** ${item.line || "No line provided"}\n\n`; + if (item.start_line) { + summaryContent += `**Start Line:** ${item.start_line}\n\n`; + } + summaryContent += `**Side:** ${item.side || "RIGHT"}\n\n`; + summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 PR review comment creation preview written to step summary"); + return; + } + // Get the side configuration from environment variable const defaultSide = process.env.GITHUB_AW_PR_REVIEW_COMMENT_SIDE || "RIGHT"; console.log(`Default comment side configuration: ${defaultSide}`); diff --git a/pkg/workflow/js/create_pull_request.cjs b/pkg/workflow/js/create_pull_request.cjs index d1fc84df678..8761b5dda80 100644 --- a/pkg/workflow/js/create_pull_request.cjs +++ b/pkg/workflow/js/create_pull_request.cjs @@ -5,6 +5,9 @@ const crypto = require("crypto"); const { execSync } = require("child_process"); async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Environment validation - fail early if required variables are missing const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { @@ -120,6 +123,35 @@ async function main() { bodyLength: pullRequestItem.body.length, }); + // If in staged mode, emit step summary instead of creating PR + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; + summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; + + summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; + summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; + summaryContent += `**Base:** ${baseBranch}\n\n`; + + if (pullRequestItem.body) { + summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; + } + + if (fs.existsSync("/tmp/aw.patch")) { + const patchStats = fs.readFileSync("/tmp/aw.patch", "utf8"); + if (patchStats.trim()) { + summaryContent += `**Changes:** Patch file exists with ${patchStats.split('\n').length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? '\n... (truncated)' : ''}\n\`\`\`\n\n
\n\n`; + } else { + summaryContent += `**Changes:** No changes (empty patch)\n\n`; + } + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Pull request creation preview written to step summary"); + return; + } + // Extract title, body, and branch from the JSON item let title = pullRequestItem.title.trim(); let bodyLines = pullRequestItem.body.split("\n"); diff --git a/pkg/workflow/js/push_to_pr_branch.cjs b/pkg/workflow/js/push_to_pr_branch.cjs index 12c533904e4..f4ec4f096b4 100644 --- a/pkg/workflow/js/push_to_pr_branch.cjs +++ b/pkg/workflow/js/push_to_pr_branch.cjs @@ -108,6 +108,33 @@ async function main() { console.log("Found push-to-pr-branch item"); + // If in staged mode, emit step summary instead of pushing changes + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Push to PR Branch Preview\n\n"; + summaryContent += "The following changes would be pushed if staged mode was disabled:\n\n"; + + summaryContent += `**Target:** ${target}\n\n`; + + if (pushItem.commit_message) { + summaryContent += `**Commit Message:** ${pushItem.commit_message}\n\n`; + } + + if (fs.existsSync("/tmp/aw.patch")) { + const patchStats = fs.readFileSync("/tmp/aw.patch", "utf8"); + if (patchStats.trim()) { + summaryContent += `**Changes:** Patch file exists with ${patchStats.split('\n').length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? '\n... (truncated)' : ''}\n\`\`\`\n\n
\n\n`; + } else { + summaryContent += `**Changes:** No changes (empty patch)\n\n`; + } + } + + // Write to step summary + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Push to PR branch preview written to step summary"); + return; + } + // Validate target configuration for pull request context if (target !== "*" && target !== "triggering") { // If target is a specific number, validate it's a valid pull request number diff --git a/pkg/workflow/js/update_issue.cjs b/pkg/workflow/js/update_issue.cjs index 0a001224ef8..f48a2e07734 100644 --- a/pkg/workflow/js/update_issue.cjs +++ b/pkg/workflow/js/update_issue.cjs @@ -1,4 +1,7 @@ async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -41,6 +44,38 @@ async function main() { console.log(`Found ${updateItems.length} update-issue item(s)`); + // If in staged mode, emit step summary instead of updating issues + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Update Issues Preview\n\n"; + summaryContent += "The following issue updates would be applied if staged mode was disabled:\n\n"; + + for (let i = 0; i < updateItems.length; i++) { + const item = updateItems[i]; + summaryContent += `### Issue Update ${i + 1}\n`; + if (item.issue_number) { + summaryContent += `**Target Issue:** #${item.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue\n\n`; + } + + if (item.title !== undefined) { + summaryContent += `**New Title:** ${item.title}\n\n`; + } + if (item.body !== undefined) { + summaryContent += `**New Body:**\n${item.body}\n\n`; + } + if (item.status !== undefined) { + summaryContent += `**New Status:** ${item.status}\n\n`; + } + summaryContent += "---\n\n"; + } + + // Write to step summary + require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + console.log("📝 Issue update preview written to step summary"); + return; + } + // Get the configuration from environment variables const updateTarget = process.env.GITHUB_AW_UPDATE_TARGET || "triggering"; const canUpdateStatus = process.env.GITHUB_AW_UPDATE_STATUS === "true"; From 8f735c152915f519389804243e12380f9d58037c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:25:14 +0000 Subject: [PATCH 3/4] Add comprehensive tests for staged flag functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 16 +- .github/workflows/test-staged.lock.yml | 1707 ----------------- .github/workflows/test-staged.md | 13 - pkg/workflow/claude_engine.go | 2 +- pkg/workflow/codex_engine.go | 2 +- pkg/workflow/custom_engine.go | 2 +- pkg/workflow/js/add_labels.cjs | 12 +- .../js/create_code_scanning_alert.cjs | 26 +- .../js/create_code_scanning_alert.test.cjs | 50 +- pkg/workflow/js/create_comment.cjs | 10 +- pkg/workflow/js/create_discussion.cjs | 8 +- pkg/workflow/js/create_issue.cjs | 10 +- pkg/workflow/js/create_pr_review_comment.cjs | 15 +- pkg/workflow/js/create_pull_request.cjs | 22 +- pkg/workflow/js/push_to_pr_branch.cjs | 13 +- pkg/workflow/js/update_issue.cjs | 12 +- pkg/workflow/staged_test.go | 164 ++ 17 files changed, 270 insertions(+), 1814 deletions(-) delete mode 100644 .github/workflows/test-staged.lock.yml delete mode 100644 .github/workflows/test-staged.md create mode 100644 pkg/workflow/staged_test.go diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 02be06bba7c..94c96c511a5 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1651,7 +1651,8 @@ jobs: // If in staged mode, emit step summary instead of creating issues if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + summaryContent += + "The following issues would be created if staged mode was disabled:\n\n"; for (let i = 0; i < createIssueItems.length; i++) { const item = createIssueItems[i]; summaryContent += `### Issue ${i + 1}\n`; @@ -1665,7 +1666,10 @@ jobs: summaryContent += "---\n\n"; } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Issue creation preview written to step summary"); return; } @@ -1851,7 +1855,8 @@ jobs: // If in staged mode, emit step summary instead of creating comments if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; + summaryContent += + "The following comments would be added if staged mode was disabled:\n\n"; for (let i = 0; i < commentItems.length; i++) { const item = commentItems[i]; summaryContent += `### Comment ${i + 1}\n`; @@ -1864,7 +1869,10 @@ jobs: summaryContent += "---\n\n"; } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Comment creation preview written to step summary"); return; } diff --git a/.github/workflows/test-staged.lock.yml b/.github/workflows/test-staged.lock.yml deleted file mode 100644 index 6f9c97ab25c..00000000000 --- a/.github/workflows/test-staged.lock.yml +++ /dev/null @@ -1,1707 +0,0 @@ -# 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: "Test Staged Workflow" -on: push - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "Test Staged Workflow" - -jobs: - test-staged-workflow: - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - outputs: - output: ${{ steps.collect_output.outputs.output }} - steps: - - name: Check team membership for workflow - id: check-team-member - uses: actions/github-script@v7 - env: - GITHUB_AW_REQUIRED_ROLES: admin,maintainer - with: - script: | - async function main() { - const { eventName } = context; - // skip check for safe events - const safeEvents = ["workflow_dispatch", "workflow_run", "schedule"]; - if (safeEvents.includes(eventName)) { - console.log(`✅ Event ${eventName} does not require validation`); - return; - } - const actor = context.actor; - const { owner, repo } = context.repo; - const requiredPermissionsEnv = process.env.GITHUB_AW_REQUIRED_ROLES; - const requiredPermissions = requiredPermissionsEnv - ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") - : []; - if (!requiredPermissions || requiredPermissions.length === 0) { - core.error( - "❌ Configuration error: Required permissions not specified. Contact repository administrator." - ); - process.exit(1); - } - // Check if the actor has the required repository permissions - try { - console.log( - `Checking if user '${actor}' has required permissions for ${owner}/${repo}` - ); - console.log(`Required permissions: ${requiredPermissions.join(", ")}`); - const repoPermission = - await github.rest.repos.getCollaboratorPermissionLevel({ - owner: owner, - repo: repo, - username: actor, - }); - const permission = repoPermission.data.permission; - console.log(`Repository permission level: ${permission}`); - // Check if user has one of the required permission levels - for (const requiredPerm of requiredPermissions) { - if ( - permission === requiredPerm || - (requiredPerm === "maintainer" && permission === "maintain") - ) { - console.log(`✅ User has ${permission} access to repository`); - return; - } - } - console.log( - `User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}` - ); - } catch (repoError) { - const errorMessage = - repoError instanceof Error ? repoError.message : String(repoError); - core.error(`Repository permission check failed: ${errorMessage}`); - process.exit(1); - } - // Fail the job when permission check fails - core.warning( - `❌ Access denied: Only authorized users can trigger this workflow. User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` - ); - process.exit(78); - } - await main(); - - name: Checkout repository - uses: actions/checkout@v5 - - 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 - fs.mkdirSync("/tmp", { recursive: true }); - // We don't create the file, as the name is sufficiently random - // and some engines (Claude) fails first Write to the file - // if it exists and has not been read. - // Set the environment variable for subsequent steps - core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", 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' - # Test Staged Workflow - - This workflow should create an issue in staged mode (preview only). - - - --- - - ## Creating an IssueReporting 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 an Issue** - - To create an issue: - 1. Write an entry to "${{ env.GITHUB_AW_SAFE_OUTPUTS }}": - ```json - {"type": "create-issue", "title": "Issue title", "body": "Issue body in markdown", "labels": ["optional", "labels"]} - ``` - 2. 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-issue", "title": "Bug Report", "body": "Found an issue with..."} - ``` - - **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 - env: - GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt - - 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: "Test Staged Workflow", - 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): - # - ExitPlanMode - # - Glob - # - Grep - # - LS - # - NotebookRead - # - Read - # - Task - # - TodoWrite - # - Write - # - mcp__github__download_workflow_run_artifact - # - mcp__github__get_code_scanning_alert - # - mcp__github__get_commit - # - mcp__github__get_dependabot_alert - # - mcp__github__get_discussion - # - mcp__github__get_discussion_comments - # - mcp__github__get_file_contents - # - mcp__github__get_issue - # - mcp__github__get_issue_comments - # - mcp__github__get_job_logs - # - mcp__github__get_me - # - mcp__github__get_notification_details - # - mcp__github__get_pull_request - # - mcp__github__get_pull_request_comments - # - mcp__github__get_pull_request_diff - # - mcp__github__get_pull_request_files - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_secret_scanning_alert - # - mcp__github__get_tag - # - mcp__github__get_workflow_run - # - mcp__github__get_workflow_run_logs - # - mcp__github__get_workflow_run_usage - # - mcp__github__list_branches - # - mcp__github__list_code_scanning_alerts - # - mcp__github__list_commits - # - mcp__github__list_dependabot_alerts - # - mcp__github__list_discussion_categories - # - mcp__github__list_discussions - # - mcp__github__list_issues - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_tags - # - mcp__github__list_workflow_jobs - # - mcp__github__list_workflow_run_artifacts - # - mcp__github__list_workflow_runs - # - mcp__github__list_workflows - # - mcp__github__search_code - # - mcp__github__search_issues - # - mcp__github__search_orgs - # - mcp__github__search_pull_requests - # - mcp__github__search_repositories - # - mcp__github__search_users - allowed_tools: "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - claude_env: | - GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" - mcp_config: /tmp/mcp-config/mcp-servers.json - prompt_file: /tmp/aw-prompts/prompt.txt - 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/test-staged-workflow.log - else - echo "No execution file output found from Agentic Action" >> /tmp/test-staged-workflow.log - fi - - # Ensure log file exists - touch /tmp/test-staged-workflow.log - - name: Print Agent output - 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 - - name: Upload agentic output file - if: always() - uses: actions/upload-artifact@v4 - with: - name: safe_output.jsonl - path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest 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-issue\":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-pr-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-code-scanning-alert": - return 1000; // Allow many repository security advisories (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(); - // remove invalid control characters like - // U+0014 (DC4) — represented here as "\u0014" - // Escape control characters not allowed in JSON strings (U+0000 through U+001F) - // Preserve common JSON escapes for \b, \f, \n, \r, \t and use \uXXXX for the rest. - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - // 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, throw the error - console.log(`invalid input json: ${jsonStr}`); - throw new Error( - `JSON parsing failed. Original: ${originalError.message}. After attempted repair: ${repairError.message}` - ); - } - } - } - 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-pr-branch": - // Validate message if provided (optional) - if (item.message !== undefined) { - if (typeof item.message !== "string") { - errors.push( - `Line ${i + 1}: push-to-pr-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-pr-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-code-scanning-alert": - // Validate required fields - if (!item.file || typeof item.file !== "string") { - errors.push( - `Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)` - ); - continue; - } - if ( - item.line === undefined || - item.line === null || - (typeof item.line !== "number" && typeof item.line !== "string") - ) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert requires a 'line' field (number or string)` - ); - continue; - } - // Additional validation: line must be parseable as a positive integer - const parsedLine = parseInt(item.line, 10); - if (isNaN(parsedLine) || parsedLine <= 0) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${item.line})` - ); - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push( - `Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)` - ); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push( - `Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)` - ); - continue; - } - // Validate severity level - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}` - ); - continue; - } - // Validate optional column field - if (item.column !== undefined) { - if ( - typeof item.column !== "number" && - typeof item.column !== "string" - ) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'column' must be a number or string` - ); - continue; - } - // Additional validation: must be parseable as a positive integer - const parsedColumn = parseInt(item.column, 10); - if (isNaN(parsedColumn) || parsedColumn <= 0) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${item.column})` - ); - continue; - } - } - // Validate optional ruleIdSuffix field - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string` - ); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - // Normalize severity to lowercase and sanitize string fields - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - 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}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - // 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) { - core.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 sanitized agent output - run: | - 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 sanitized agent output - 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/test-staged-workflow.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: test-staged-workflow.log - path: /tmp/test-staged-workflow.log - if-no-files-found: warn - - create_issue: - needs: test-staged-workflow - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - timeout-minutes: 10 - outputs: - issue_number: ${{ steps.create_issue.outputs.issue_number }} - issue_url: ${{ steps.create_issue.outputs.issue_url }} - steps: - - name: Check team membership for workflow - id: check-team-member - uses: actions/github-script@v7 - env: - GITHUB_AW_REQUIRED_ROLES: admin,maintainer - with: - script: | - async function main() { - const { eventName } = context; - // skip check for safe events - const safeEvents = ["workflow_dispatch", "workflow_run", "schedule"]; - if (safeEvents.includes(eventName)) { - console.log(`✅ Event ${eventName} does not require validation`); - return; - } - const actor = context.actor; - const { owner, repo } = context.repo; - const requiredPermissionsEnv = process.env.GITHUB_AW_REQUIRED_ROLES; - const requiredPermissions = requiredPermissionsEnv - ? requiredPermissionsEnv.split(",").filter(p => p.trim() !== "") - : []; - if (!requiredPermissions || requiredPermissions.length === 0) { - core.error( - "❌ Configuration error: Required permissions not specified. Contact repository administrator." - ); - process.exit(1); - } - // Check if the actor has the required repository permissions - try { - console.log( - `Checking if user '${actor}' has required permissions for ${owner}/${repo}` - ); - console.log(`Required permissions: ${requiredPermissions.join(", ")}`); - const repoPermission = - await github.rest.repos.getCollaboratorPermissionLevel({ - owner: owner, - repo: repo, - username: actor, - }); - const permission = repoPermission.data.permission; - console.log(`Repository permission level: ${permission}`); - // Check if user has one of the required permission levels - for (const requiredPerm of requiredPermissions) { - if ( - permission === requiredPerm || - (requiredPerm === "maintainer" && permission === "maintain") - ) { - console.log(`✅ User has ${permission} access to repository`); - return; - } - } - console.log( - `User permission '${permission}' does not meet requirements: ${requiredPermissions.join(", ")}` - ); - } catch (repoError) { - const errorMessage = - repoError instanceof Error ? repoError.message : String(repoError); - core.error(`Repository permission check failed: ${errorMessage}`); - process.exit(1); - } - // Fail the job when permission check fails - core.warning( - `❌ Access denied: Only authorized users can trigger this workflow. User '${actor}' is not authorized. Required permissions: ${requiredPermissions.join(", ")}` - ); - process.exit(78); - } - await main(); - - name: Create Output Issue - id: create_issue - uses: actions/github-script@v7 - env: - GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-staged-workflow.outputs.output }} - with: - script: | - async function main() { - // Check if we're in staged mode - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - // Read the validated output content from environment variable - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - console.log("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - console.log("Agent output content is empty"); - return; - } - console.log("Agent output content length:", outputContent.length); - // 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 all create-issue items - const createIssueItems = validatedOutput.items.filter( - /** @param {any} item */ item => item.type === "create-issue" - ); - if (createIssueItems.length === 0) { - console.log("No create-issue items found in agent output"); - return; - } - console.log(`Found ${createIssueItems.length} create-issue item(s)`); - // If in staged mode, emit step summary instead of creating issues - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); - console.log("📝 Issue creation preview written to step summary"); - return; - } - // Check if we're in an issue context (triggered by an issue event) - const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(/** @param {string} label */ label => label.trim()) - .filter(/** @param {string} label */ label => label) - : []; - const createdIssues = []; - // Process each create-issue item - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - console.log( - `Processing create-issue item ${i + 1}/${createIssueItems.length}:`, - { title: createIssueItem.title, bodyLength: createIssueItem.body.length } - ); - // Merge environment labels with item-specific labels - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - console.log("Detected issue context, parent issue #" + parentIssueNumber); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push( - ``, - ``, - `> Generated by Agentic Workflow [Run](${runUrl})`, - "" - ); - // Prepare the body content - const body = bodyLines.join("\n").trim(); - console.log("Creating issue with title:", title); - console.log("Labels:", labels); - console.log("Body length:", body.length); - try { - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - console.log("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - console.log("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - console.log( - "Warning: Could not add comment to parent issue:", - error instanceof Error ? error.message : String(error) - ); - } - } - // Set output for the last created issue (for backward compatibility) - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - // Special handling for disabled issues repository - if ( - errorMessage.includes("Issues has been disabled in this repository") - ) { - console.log( - `⚠ Cannot create issue "${title}": Issues are disabled for this repository` - ); - console.log( - "Consider enabling issues in repository settings if you want to create issues automatically" - ); - continue; // Skip this issue but continue processing others - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; - } - } - // Write summary for all created issues - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - console.log(`Successfully created ${createdIssues.length} issue(s)`); - } - await main(); - diff --git a/.github/workflows/test-staged.md b/.github/workflows/test-staged.md deleted file mode 100644 index 94ec17b688e..00000000000 --- a/.github/workflows/test-staged.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -on: push -permissions: - contents: read - issues: write -safe-outputs: - create-issue: - staged: true ---- - -# Test Staged Workflow - -This workflow should create an issue in staged mode (preview only). \ No newline at end of file diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index a6071dac2aa..67997d09e64 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -88,7 +88,7 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str claudeEnv := "" if hasOutput { claudeEnv += " GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}" - + // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil { if *workflowData.SafeOutputs.Staged { diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index d2c862c1989..cc3d1ed69c9 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -116,7 +116,7 @@ codex exec \ hasOutput := workflowData.SafeOutputs != nil if hasOutput { env["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" - + // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { env["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 94d3f611850..2c14812958b 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -52,7 +52,7 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Add GITHUB_AW_SAFE_OUTPUTS if safe-outputs feature is used if workflowData.SafeOutputs != nil { envVars["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" - + // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { envVars["GITHUB_AW_SAFE_OUTPUTS_STAGED"] = "true" diff --git a/pkg/workflow/js/add_labels.cjs b/pkg/workflow/js/add_labels.cjs index 5dd54f96be8..753a4bc4ed6 100644 --- a/pkg/workflow/js/add_labels.cjs +++ b/pkg/workflow/js/add_labels.cjs @@ -46,20 +46,24 @@ async function main() { // If in staged mode, emit step summary instead of adding labels if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - + summaryContent += + "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; } else { summaryContent += `**Target:** Current issue/PR\n\n`; } - + if (labelsItem.labels && labelsItem.labels.length > 0) { summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Label addition preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_code_scanning_alert.cjs b/pkg/workflow/js/create_code_scanning_alert.cjs index f4f8df2d8dd..a22dd8dc01f 100644 --- a/pkg/workflow/js/create_code_scanning_alert.cjs +++ b/pkg/workflow/js/create_code_scanning_alert.cjs @@ -32,13 +32,10 @@ async function main() { // Find all create-code-scanning-alert items const securityItems = validatedOutput.items.filter( - /** @param {any} item */ item => - item.type === "create-code-scanning-alert" + /** @param {any} item */ item => item.type === "create-code-scanning-alert" ); if (securityItems.length === 0) { - console.log( - "No create-code-scanning-alert items found in agent output" - ); + console.log("No create-code-scanning-alert items found in agent output"); return; } @@ -48,8 +45,10 @@ async function main() { // If in staged mode, emit step summary instead of creating code scanning alerts if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Code Scanning Alerts Preview\n\n"; - summaryContent += "The following code scanning alerts would be created if staged mode was disabled:\n\n"; + let summaryContent = + "## 🎭 Staged Mode: Create Code Scanning Alerts Preview\n\n"; + summaryContent += + "The following code scanning alerts would be created if staged mode was disabled:\n\n"; for (let i = 0; i < securityItems.length; i++) { const item = securityItems[i]; @@ -62,8 +61,13 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); - console.log("📝 Code scanning alert creation preview written to step summary"); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); + console.log( + "📝 Code scanning alert creation preview written to step summary" + ); return; } @@ -106,9 +110,7 @@ async function main() { // Validate required fields if (!securityItem.file) { - console.log( - 'Missing required field "file" in code scanning alert item' - ); + console.log('Missing required field "file" in code scanning alert item'); continue; } diff --git a/pkg/workflow/js/create_code_scanning_alert.test.cjs b/pkg/workflow/js/create_code_scanning_alert.test.cjs index cc27d51c55c..45cbc542cbf 100644 --- a/pkg/workflow/js/create_code_scanning_alert.test.cjs +++ b/pkg/workflow/js/create_code_scanning_alert.test.cjs @@ -54,10 +54,7 @@ describe("create_code_scanning_alert.cjs", () => { afterEach(() => { // Clean up any created files try { - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); if (fs.existsSync(sarifFile)) { fs.unlinkSync(sarifFile); } @@ -160,10 +157,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); // Check that SARIF file was created - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); expect(fs.existsSync(sarifFile)).toBe(true); // Check SARIF content @@ -235,10 +229,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); // Check that SARIF file was created with only 1 finding - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); expect(fs.existsSync(sarifFile)).toBe(true); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); @@ -300,10 +291,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); // Check that SARIF file was created with only the 1 valid finding - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); expect(fs.existsSync(sarifFile)).toBe(true); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); @@ -339,10 +327,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); // Check driver name @@ -376,10 +361,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); // Check default driver name @@ -422,10 +404,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); // Check first result has custom column @@ -487,10 +466,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); // Only the first valid finding should be processed - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); expect(sarifContent.runs[0].results).toHaveLength(1); expect(sarifContent.runs[0].results[0].message.text).toBe( @@ -541,10 +517,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); // Check first result has custom rule ID @@ -617,10 +590,7 @@ describe("create_code_scanning_alert.cjs", () => { await eval(`(async () => { ${securityReportScript} })()`); // Only the first valid finding should be processed - const sarifFile = path.join( - process.cwd(), - "code-scanning-alert.sarif" - ); + const sarifFile = path.join(process.cwd(), "code-scanning-alert.sarif"); const sarifContent = JSON.parse(fs.readFileSync(sarifFile, "utf8")); expect(sarifContent.runs[0].results).toHaveLength(1); expect(sarifContent.runs[0].results[0].message.text).toBe( diff --git a/pkg/workflow/js/create_comment.cjs b/pkg/workflow/js/create_comment.cjs index c2d5fcaaf1c..c7387477942 100644 --- a/pkg/workflow/js/create_comment.cjs +++ b/pkg/workflow/js/create_comment.cjs @@ -1,7 +1,7 @@ async function main() { // Check if we're in staged mode const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -47,7 +47,8 @@ async function main() { // If in staged mode, emit step summary instead of creating comments if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; + summaryContent += + "The following comments would be added if staged mode was disabled:\n\n"; for (let i = 0; i < commentItems.length; i++) { const item = commentItems[i]; @@ -62,7 +63,10 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Comment creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_discussion.cjs b/pkg/workflow/js/create_discussion.cjs index 116b3af365a..3b0688426aa 100644 --- a/pkg/workflow/js/create_discussion.cjs +++ b/pkg/workflow/js/create_discussion.cjs @@ -45,7 +45,8 @@ async function main() { // If in staged mode, emit step summary instead of creating discussions if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + summaryContent += + "The following discussions would be created if staged mode was disabled:\n\n"; for (let i = 0; i < createDiscussionItems.length; i++) { const item = createDiscussionItems[i]; @@ -61,7 +62,10 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Discussion creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index 1f884352ac6..9285ed75015 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -1,7 +1,7 @@ async function main() { // Check if we're in staged mode const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -46,7 +46,8 @@ async function main() { // If in staged mode, emit step summary instead of creating issues if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + summaryContent += + "The following issues would be created if staged mode was disabled:\n\n"; for (let i = 0; i < createIssueItems.length; i++) { const item = createIssueItems[i]; @@ -62,7 +63,10 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Issue creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_pr_review_comment.cjs b/pkg/workflow/js/create_pr_review_comment.cjs index 37cba08b718..709fd5e67dc 100644 --- a/pkg/workflow/js/create_pr_review_comment.cjs +++ b/pkg/workflow/js/create_pr_review_comment.cjs @@ -48,8 +48,10 @@ async function main() { // If in staged mode, emit step summary instead of creating review comments if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create PR Review Comments Preview\n\n"; - summaryContent += "The following review comments would be created if staged mode was disabled:\n\n"; + let summaryContent = + "## 🎭 Staged Mode: Create PR Review Comments Preview\n\n"; + summaryContent += + "The following review comments would be created if staged mode was disabled:\n\n"; for (let i = 0; i < reviewCommentItems.length; i++) { const item = reviewCommentItems[i]; @@ -65,8 +67,13 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); - console.log("📝 PR review comment creation preview written to step summary"); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); + console.log( + "📝 PR review comment creation preview written to step summary" + ); return; } diff --git a/pkg/workflow/js/create_pull_request.cjs b/pkg/workflow/js/create_pull_request.cjs index 8761b5dda80..10dfa57eff0 100644 --- a/pkg/workflow/js/create_pull_request.cjs +++ b/pkg/workflow/js/create_pull_request.cjs @@ -7,7 +7,7 @@ const { execSync } = require("child_process"); async function main() { // Check if we're in staged mode const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - + // Environment validation - fail early if required variables are missing const workflowId = process.env.GITHUB_AW_WORKFLOW_ID; if (!workflowId) { @@ -126,28 +126,32 @@ async function main() { // If in staged mode, emit step summary instead of creating PR if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - + summaryContent += + "The following pull request would be created if staged mode was disabled:\n\n"; + summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; summaryContent += `**Base:** ${baseBranch}\n\n`; - + if (pullRequestItem.body) { summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; } - + if (fs.existsSync("/tmp/aw.patch")) { const patchStats = fs.readFileSync("/tmp/aw.patch", "utf8"); if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split('\n').length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? '\n... (truncated)' : ''}\n\`\`\`\n\n
\n\n`; + summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; } else { summaryContent += `**Changes:** No changes (empty patch)\n\n`; } } - // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + // Write to step summary + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Pull request creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/push_to_pr_branch.cjs b/pkg/workflow/js/push_to_pr_branch.cjs index f4ec4f096b4..81bea88fbc4 100644 --- a/pkg/workflow/js/push_to_pr_branch.cjs +++ b/pkg/workflow/js/push_to_pr_branch.cjs @@ -111,19 +111,20 @@ async function main() { // If in staged mode, emit step summary instead of pushing changes if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { let summaryContent = "## 🎭 Staged Mode: Push to PR Branch Preview\n\n"; - summaryContent += "The following changes would be pushed if staged mode was disabled:\n\n"; - + summaryContent += + "The following changes would be pushed if staged mode was disabled:\n\n"; + summaryContent += `**Target:** ${target}\n\n`; - + if (pushItem.commit_message) { summaryContent += `**Commit Message:** ${pushItem.commit_message}\n\n`; } - + if (fs.existsSync("/tmp/aw.patch")) { const patchStats = fs.readFileSync("/tmp/aw.patch", "utf8"); if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split('\n').length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? '\n... (truncated)' : ''}\n\`\`\`\n\n
\n\n`; + summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; + summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; } else { summaryContent += `**Changes:** No changes (empty patch)\n\n`; } diff --git a/pkg/workflow/js/update_issue.cjs b/pkg/workflow/js/update_issue.cjs index f48a2e07734..e453cdc472b 100644 --- a/pkg/workflow/js/update_issue.cjs +++ b/pkg/workflow/js/update_issue.cjs @@ -1,7 +1,7 @@ async function main() { // Check if we're in staged mode const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - + // Read the validated output content from environment variable const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; if (!outputContent) { @@ -47,7 +47,8 @@ async function main() { // If in staged mode, emit step summary instead of updating issues if (isStaged) { let summaryContent = "## 🎭 Staged Mode: Update Issues Preview\n\n"; - summaryContent += "The following issue updates would be applied if staged mode was disabled:\n\n"; + summaryContent += + "The following issue updates would be applied if staged mode was disabled:\n\n"; for (let i = 0; i < updateItems.length; i++) { const item = updateItems[i]; @@ -57,7 +58,7 @@ async function main() { } else { summaryContent += `**Target:** Current issue\n\n`; } - + if (item.title !== undefined) { summaryContent += `**New Title:** ${item.title}\n\n`; } @@ -71,7 +72,10 @@ async function main() { } // Write to step summary - require("fs").appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + require("fs").appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + summaryContent + ); console.log("📝 Issue update preview written to step summary"); return; } diff --git a/pkg/workflow/staged_test.go b/pkg/workflow/staged_test.go new file mode 100644 index 00000000000..d67c8f77461 --- /dev/null +++ b/pkg/workflow/staged_test.go @@ -0,0 +1,164 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestStagedFlag(t *testing.T) { + // Create a compiler instance + c := NewCompiler(false, "", "test") + + // Test frontmatter with staged: true + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": nil, + "staged": true, + }, + } + + // Extract the safe outputs config + config := c.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Verify staged flag is correctly parsed + if config.Staged == nil { + t.Fatal("Expected staged flag to be parsed") + } + + if !*config.Staged { + t.Fatal("Expected staged flag to be true") + } + + // Test that CreateIssues config is also present + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues config to be present") + } +} + +func TestStagedFlagDefault(t *testing.T) { + // Create a compiler instance + c := NewCompiler(false, "", "test") + + // Test frontmatter without staged flag + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": nil, + }, + } + + // Extract the safe outputs config + config := c.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Verify staged flag is nil (not specified) + if config.Staged != nil { + t.Fatal("Expected staged flag to be nil when not specified") + } +} + +func TestStagedFlagFalse(t *testing.T) { + // Create a compiler instance + c := NewCompiler(false, "", "test") + + // Test frontmatter with staged: false + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": nil, + "staged": false, + }, + } + + // Extract the safe outputs config + config := c.extractSafeOutputsConfig(frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Verify staged flag is correctly parsed as false + if config.Staged == nil { + t.Fatal("Expected staged flag to be parsed") + } + + if *config.Staged { + t.Fatal("Expected staged flag to be false") + } +} + +func TestClaudeEngineWithStagedFlag(t *testing.T) { + engine := NewClaudeEngine() + + // Test with staged flag true + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Max: 1}, + Staged: &[]bool{true}[0], // pointer to true + }, + } + + steps := engine.GetExecutionSteps(workflowData, "test-log") + if len(steps) == 0 { + t.Fatalf("Expected at least one step, got none") + } + + // Convert first step to YAML string for testing + stepContent := strings.Join([]string(steps[0]), "\n") + + // Check that GITHUB_AW_SAFE_OUTPUTS_STAGED is included + if !strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"") { + t.Error("Expected GITHUB_AW_SAFE_OUTPUTS_STAGED environment variable to be set to true") + } + + // Test with staged flag false + workflowData.SafeOutputs.Staged = &[]bool{false}[0] // pointer to false + + steps = engine.GetExecutionSteps(workflowData, "test-log") + stepContent = strings.Join([]string(steps[0]), "\n") + + // Check that GITHUB_AW_SAFE_OUTPUTS_STAGED is not included when false + if strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS_STAGED") { + t.Error("Expected GITHUB_AW_SAFE_OUTPUTS_STAGED environment variable not to be set when staged is false") + } + + // Test with staged flag nil (not specified) + workflowData.SafeOutputs.Staged = nil + + steps = engine.GetExecutionSteps(workflowData, "test-log") + stepContent = strings.Join([]string(steps[0]), "\n") + + // Check that GITHUB_AW_SAFE_OUTPUTS_STAGED is not included when nil + if strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS_STAGED") { + t.Error("Expected GITHUB_AW_SAFE_OUTPUTS_STAGED environment variable not to be set when staged is nil") + } +} + +func TestCodexEngineWithStagedFlag(t *testing.T) { + engine := NewCodexEngine() + + // Test with staged flag true + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{Max: 1}, + Staged: &[]bool{true}[0], // pointer to true + }, + } + + steps := engine.GetExecutionSteps(workflowData, "test-log") + if len(steps) == 0 { + t.Fatalf("Expected at least one step, got none") + } + + // Convert first step to YAML string for testing + stepContent := strings.Join([]string(steps[0]), "\n") + + // Check that GITHUB_AW_SAFE_OUTPUTS_STAGED is included in the env section + if !strings.Contains(stepContent, "GITHUB_AW_SAFE_OUTPUTS_STAGED: true") { + t.Error("Expected GITHUB_AW_SAFE_OUTPUTS_STAGED environment variable to be set to true in Codex engine") + } +} From aa285fd0f584f9b1a72f9a28f7f738dcc9089440 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:02:57 +0000 Subject: [PATCH 4/4] Replace manual file writing with @actions/core summary APIs in staged mode - Updated all 9 JavaScript action scripts to use core.summary.addRaw().write() instead of manually writing to GITHUB_STEP_SUMMARY file - Removed unnecessary require("@actions/core") statements since core is available as global - Maintained backward compatibility and all existing functionality - All tests passing (301 JS tests, all Go tests) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 10 ++-------- pkg/workflow/js/add_labels.cjs | 5 +---- pkg/workflow/js/create_code_scanning_alert.cjs | 5 +---- pkg/workflow/js/create_comment.cjs | 5 +---- pkg/workflow/js/create_discussion.cjs | 5 +---- pkg/workflow/js/create_issue.cjs | 5 +---- pkg/workflow/js/create_pr_review_comment.cjs | 5 +---- pkg/workflow/js/create_pull_request.cjs | 5 +---- pkg/workflow/js/push_to_pr_branch.cjs | 2 +- pkg/workflow/js/update_issue.cjs | 5 +---- 10 files changed, 11 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 94c96c511a5..45d0912261d 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1666,10 +1666,7 @@ jobs: summaryContent += "---\n\n"; } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Issue creation preview written to step summary"); return; } @@ -1869,10 +1866,7 @@ jobs: summaryContent += "---\n\n"; } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Comment creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/add_labels.cjs b/pkg/workflow/js/add_labels.cjs index 753a4bc4ed6..6868949ca5b 100644 --- a/pkg/workflow/js/add_labels.cjs +++ b/pkg/workflow/js/add_labels.cjs @@ -60,10 +60,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Label addition preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_code_scanning_alert.cjs b/pkg/workflow/js/create_code_scanning_alert.cjs index a22dd8dc01f..248f6f877c0 100644 --- a/pkg/workflow/js/create_code_scanning_alert.cjs +++ b/pkg/workflow/js/create_code_scanning_alert.cjs @@ -61,10 +61,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log( "📝 Code scanning alert creation preview written to step summary" ); diff --git a/pkg/workflow/js/create_comment.cjs b/pkg/workflow/js/create_comment.cjs index c7387477942..7ed0fe27663 100644 --- a/pkg/workflow/js/create_comment.cjs +++ b/pkg/workflow/js/create_comment.cjs @@ -63,10 +63,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Comment creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_discussion.cjs b/pkg/workflow/js/create_discussion.cjs index 3b0688426aa..673561a2940 100644 --- a/pkg/workflow/js/create_discussion.cjs +++ b/pkg/workflow/js/create_discussion.cjs @@ -62,10 +62,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Discussion creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index 9285ed75015..fa4deef9ec5 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -63,10 +63,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Issue creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/create_pr_review_comment.cjs b/pkg/workflow/js/create_pr_review_comment.cjs index 709fd5e67dc..727a6a87561 100644 --- a/pkg/workflow/js/create_pr_review_comment.cjs +++ b/pkg/workflow/js/create_pr_review_comment.cjs @@ -67,10 +67,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log( "📝 PR review comment creation preview written to step summary" ); diff --git a/pkg/workflow/js/create_pull_request.cjs b/pkg/workflow/js/create_pull_request.cjs index 10dfa57eff0..1ce0664c201 100644 --- a/pkg/workflow/js/create_pull_request.cjs +++ b/pkg/workflow/js/create_pull_request.cjs @@ -148,10 +148,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Pull request creation preview written to step summary"); return; } diff --git a/pkg/workflow/js/push_to_pr_branch.cjs b/pkg/workflow/js/push_to_pr_branch.cjs index 81bea88fbc4..7dbc722f17f 100644 --- a/pkg/workflow/js/push_to_pr_branch.cjs +++ b/pkg/workflow/js/push_to_pr_branch.cjs @@ -131,7 +131,7 @@ async function main() { } // Write to step summary - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summaryContent); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Push to PR branch preview written to step summary"); return; } diff --git a/pkg/workflow/js/update_issue.cjs b/pkg/workflow/js/update_issue.cjs index e453cdc472b..3ba6e75142d 100644 --- a/pkg/workflow/js/update_issue.cjs +++ b/pkg/workflow/js/update_issue.cjs @@ -72,10 +72,7 @@ async function main() { } // Write to step summary - require("fs").appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - summaryContent - ); + await core.summary.addRaw(summaryContent).write(); console.log("📝 Issue update preview written to step summary"); return; }