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;
}