diff --git a/.github/workflows/format-and-commit.yml b/.github/workflows/format-and-commit.yml index 7256824d8b..499103277b 100644 --- a/.github/workflows/format-and-commit.yml +++ b/.github/workflows/format-and-commit.yml @@ -31,6 +31,8 @@ jobs: run: make deps-dev - name: Format code run: make fmt + - name: Format code js + run: make fmt-cjs - name: Lint code run: make lint - name: Build code diff --git a/.github/workflows/test-claude-add-issue-comment.lock.yml b/.github/workflows/test-claude-add-issue-comment.lock.yml index 6b1770917f..1b14187970 100644 --- a/.github/workflows/test-claude-add-issue-comment.lock.yml +++ b/.github/workflows/test-claude-add-issue-comment.lock.yml @@ -719,6 +719,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1137,6 +1141,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-add-issue-labels.lock.yml b/.github/workflows/test-claude-add-issue-labels.lock.yml index 4acd8a692e..7e6079c269 100644 --- a/.github/workflows/test-claude-add-issue-labels.lock.yml +++ b/.github/workflows/test-claude-add-issue-labels.lock.yml @@ -719,6 +719,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1137,6 +1141,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-command.lock.yml b/.github/workflows/test-claude-command.lock.yml index 8f70a65820..ff85cede56 100644 --- a/.github/workflows/test-claude-command.lock.yml +++ b/.github/workflows/test-claude-command.lock.yml @@ -995,6 +995,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1413,6 +1417,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-create-issue.lock.yml b/.github/workflows/test-claude-create-issue.lock.yml index e55317b8fb..41c22953cf 100644 --- a/.github/workflows/test-claude-create-issue.lock.yml +++ b/.github/workflows/test-claude-create-issue.lock.yml @@ -529,6 +529,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -947,6 +951,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1477,10 +1543,21 @@ jobs: core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } diff --git a/.github/workflows/test-claude-create-pull-request-review-comment.lock.yml b/.github/workflows/test-claude-create-pull-request-review-comment.lock.yml index ced334ef69..21fb4c7700 100644 --- a/.github/workflows/test-claude-create-pull-request-review-comment.lock.yml +++ b/.github/workflows/test-claude-create-pull-request-review-comment.lock.yml @@ -733,6 +733,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1151,6 +1155,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index 37ea69ad57..4dfdcab89c 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -548,6 +548,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -966,6 +970,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1500,6 +1566,7 @@ jobs: GITHUB_AW_PR_TITLE_PREFIX: "[claude-test] " GITHUB_AW_PR_LABELS: "claude,automation,bot" GITHUB_AW_PR_DRAFT: "true" + GITHUB_AW_PR_IF_NO_CHANGES: "warn" with: script: | /** @type {typeof import("fs")} */ @@ -1521,24 +1588,65 @@ jobs: if (outputContent.trim() === "") { console.log("Agent output content is empty"); } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - throw new Error( - "No patch file found - cannot create pull request without changes" - ); + const message = + "No patch file found - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - throw new Error( - "Patch file is empty or contains error message - cannot create pull request without changes" - ); + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to push - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } else { + console.log("Patch file is empty - processing noop operation"); + } // Parse the validated output JSON let validatedOutput; try { @@ -1647,16 +1755,56 @@ jobs: execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); console.log("Created and checked out new branch:", branchName); } - // Apply the patch using git CLI - console.log("Applying patch..."); - // Apply the patch using git apply - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } else { + console.log("Skipping patch application (empty patch)"); + } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); - execSync(`git commit -m "Add agent output: ${title}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed"); + // Check if there are changes to commit + let hasChanges = false; + let gitError = null; + try { + execSync("git diff --cached --exit-code", { stdio: "ignore" }); + // No changes - exit code 0 + hasChanges = false; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + if (!hasChanges) { + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to commit - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + if (hasChanges) { + execSync(`git commit -m "Add agent output: ${title}"`, { + stdio: "inherit", + }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed"); + } else { + // This should not happen due to the early return above, but keeping for safety + console.log("No changes to commit"); + return; + } // Create the pull request const { data: pullRequest } = await github.rest.pulls.create({ owner: context.repo.owner, diff --git a/.github/workflows/test-claude-create-security-report.lock.yml b/.github/workflows/test-claude-create-security-report.lock.yml index 3d018a0ae3..6ffd567758 100644 --- a/.github/workflows/test-claude-create-security-report.lock.yml +++ b/.github/workflows/test-claude-create-security-report.lock.yml @@ -725,6 +725,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1143,6 +1147,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-claude-mcp.lock.yml b/.github/workflows/test-claude-mcp.lock.yml index 8f0eed57c5..313ab5ad07 100644 --- a/.github/workflows/test-claude-mcp.lock.yml +++ b/.github/workflows/test-claude-mcp.lock.yml @@ -741,6 +741,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1159,6 +1163,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1687,10 +1753,21 @@ jobs: core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } diff --git a/.github/workflows/test-claude-push-to-branch.lock.yml b/.github/workflows/test-claude-push-to-branch.lock.yml index 74824f9bef..d527fbbfe8 100644 --- a/.github/workflows/test-claude-push-to-branch.lock.yml +++ b/.github/workflows/test-claude-push-to-branch.lock.yml @@ -635,6 +635,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1053,6 +1057,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1585,6 +1651,7 @@ jobs: GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-claude-push-to-branch.outputs.output }} GITHUB_AW_PUSH_BRANCH: "claude-test-branch" GITHUB_AW_PUSH_TARGET: "*" + GITHUB_AW_PUSH_IF_NO_CHANGES: "warn" with: script: | async function main() { @@ -1603,24 +1670,65 @@ jobs: return; } const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + const ifNoChanges = process.env.GITHUB_AW_PUSH_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - core.setFailed("No patch file found - cannot push without changes"); - return; + const message = "No patch file found - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - core.setFailed( - "Patch file is empty or contains error message - cannot push without changes" - ); - return; + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to push - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } console.log("Target branch:", branchName); console.log("Target configuration:", target); // Parse the validated output JSON @@ -1684,35 +1792,63 @@ jobs: console.log("Branch does not exist, creating new branch:", branchName); execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); } - // Apply the patch using git CLI - console.log("Applying patch..."); - try { - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); - } catch (error) { - console.error( - "Failed to apply patch:", - error instanceof Error ? error.message : String(error) - ); - core.setFailed("Failed to apply patch"); - return; + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + try { + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } catch (error) { + console.error( + "Failed to apply patch:", + error instanceof Error ? error.message : String(error) + ); + core.setFailed("Failed to apply patch"); + return; + } + } else { + console.log("Skipping patch application (empty patch)"); } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); // Check if there are changes to commit + let hasChanges = false; try { execSync("git diff --cached --exit-code", { stdio: "ignore" }); - console.log("No changes to commit"); - return; + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to commit - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } + hasChanges = false; } catch (error) { // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; } - const commitMessage = pushItem.message || "Apply agent changes"; - execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed to branch:", branchName); - // Get commit SHA - const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + let commitSha; + if (hasChanges) { + const commitMessage = pushItem.message || "Apply agent changes"; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed to branch:", branchName); + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } else { + // For noop operations, get the current HEAD commit + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } + // Get commit SHA and push URL const pushUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; @@ -1721,16 +1857,23 @@ jobs: core.setOutput("commit_sha", commitSha); core.setOutput("push_url", pushUrl); // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` - ## Push to Branch + const summaryTitle = hasChanges + ? "Push to Branch" + : "Push to Branch (No Changes)"; + const summaryContent = hasChanges + ? ` + ## ${summaryTitle} - **Branch**: \`${branchName}\` - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) - **URL**: [${pushUrl}](${pushUrl}) ` - ) - .write(); + : ` + ## ${summaryTitle} + - **Branch**: \`${branchName}\` + - **Status**: No changes to apply (noop operation) + - **URL**: [${pushUrl}](${pushUrl}) + `; + await core.summary.addRaw(summaryContent).write(); } await main(); diff --git a/.github/workflows/test-claude-update-issue.lock.yml b/.github/workflows/test-claude-update-issue.lock.yml index 0cad95ed8d..47f83fb562 100644 --- a/.github/workflows/test-claude-update-issue.lock.yml +++ b/.github/workflows/test-claude-update-issue.lock.yml @@ -722,6 +722,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1140,6 +1144,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-add-issue-comment.lock.yml b/.github/workflows/test-codex-add-issue-comment.lock.yml index fc16477a54..d69f47893a 100644 --- a/.github/workflows/test-codex-add-issue-comment.lock.yml +++ b/.github/workflows/test-codex-add-issue-comment.lock.yml @@ -551,6 +551,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -969,6 +973,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-add-issue-labels.lock.yml b/.github/workflows/test-codex-add-issue-labels.lock.yml index dc971150b2..791dcbb842 100644 --- a/.github/workflows/test-codex-add-issue-labels.lock.yml +++ b/.github/workflows/test-codex-add-issue-labels.lock.yml @@ -551,6 +551,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -969,6 +973,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-command.lock.yml b/.github/workflows/test-codex-command.lock.yml index 39d33adfc2..2fe547a1fe 100644 --- a/.github/workflows/test-codex-command.lock.yml +++ b/.github/workflows/test-codex-command.lock.yml @@ -995,6 +995,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1413,6 +1417,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-create-issue.lock.yml b/.github/workflows/test-codex-create-issue.lock.yml index 4be58e103f..044764058f 100644 --- a/.github/workflows/test-codex-create-issue.lock.yml +++ b/.github/workflows/test-codex-create-issue.lock.yml @@ -361,6 +361,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -779,6 +783,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1239,10 +1305,21 @@ jobs: core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } diff --git a/.github/workflows/test-codex-create-pull-request-review-comment.lock.yml b/.github/workflows/test-codex-create-pull-request-review-comment.lock.yml index cbebbc4b1d..080c1e2245 100644 --- a/.github/workflows/test-codex-create-pull-request-review-comment.lock.yml +++ b/.github/workflows/test-codex-create-pull-request-review-comment.lock.yml @@ -565,6 +565,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -983,6 +987,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-create-pull-request.lock.yml b/.github/workflows/test-codex-create-pull-request.lock.yml index e013ba6bf4..e803526017 100644 --- a/.github/workflows/test-codex-create-pull-request.lock.yml +++ b/.github/workflows/test-codex-create-pull-request.lock.yml @@ -368,6 +368,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -786,6 +790,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1250,6 +1316,7 @@ jobs: GITHUB_AW_PR_TITLE_PREFIX: "[codex-test] " GITHUB_AW_PR_LABELS: "codex,automation,bot" GITHUB_AW_PR_DRAFT: "true" + GITHUB_AW_PR_IF_NO_CHANGES: "warn" with: script: | /** @type {typeof import("fs")} */ @@ -1271,24 +1338,65 @@ jobs: if (outputContent.trim() === "") { console.log("Agent output content is empty"); } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - throw new Error( - "No patch file found - cannot create pull request without changes" - ); + const message = + "No patch file found - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - throw new Error( - "Patch file is empty or contains error message - cannot create pull request without changes" - ); + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to push - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } else { + console.log("Patch file is empty - processing noop operation"); + } // Parse the validated output JSON let validatedOutput; try { @@ -1397,16 +1505,56 @@ jobs: execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); console.log("Created and checked out new branch:", branchName); } - // Apply the patch using git CLI - console.log("Applying patch..."); - // Apply the patch using git apply - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } else { + console.log("Skipping patch application (empty patch)"); + } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); - execSync(`git commit -m "Add agent output: ${title}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed"); + // Check if there are changes to commit + let hasChanges = false; + let gitError = null; + try { + execSync("git diff --cached --exit-code", { stdio: "ignore" }); + // No changes - exit code 0 + hasChanges = false; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + if (!hasChanges) { + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to commit - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + if (hasChanges) { + execSync(`git commit -m "Add agent output: ${title}"`, { + stdio: "inherit", + }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed"); + } else { + // This should not happen due to the early return above, but keeping for safety + console.log("No changes to commit"); + return; + } // Create the pull request const { data: pullRequest } = await github.rest.pulls.create({ owner: context.repo.owner, diff --git a/.github/workflows/test-codex-create-security-report.lock.yml b/.github/workflows/test-codex-create-security-report.lock.yml index 652230fc34..796544f1c9 100644 --- a/.github/workflows/test-codex-create-security-report.lock.yml +++ b/.github/workflows/test-codex-create-security-report.lock.yml @@ -557,6 +557,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -975,6 +979,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-codex-mcp.lock.yml b/.github/workflows/test-codex-mcp.lock.yml index c3d4c41b1c..5374df0d8e 100644 --- a/.github/workflows/test-codex-mcp.lock.yml +++ b/.github/workflows/test-codex-mcp.lock.yml @@ -570,6 +570,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -988,6 +992,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1446,10 +1512,21 @@ jobs: core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } diff --git a/.github/workflows/test-codex-push-to-branch.lock.yml b/.github/workflows/test-codex-push-to-branch.lock.yml index e6a15f4b04..1d59c5864e 100644 --- a/.github/workflows/test-codex-push-to-branch.lock.yml +++ b/.github/workflows/test-codex-push-to-branch.lock.yml @@ -457,6 +457,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -875,6 +879,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1337,6 +1403,7 @@ jobs: GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-codex-push-to-branch.outputs.output }} GITHUB_AW_PUSH_BRANCH: "codex-test-branch" GITHUB_AW_PUSH_TARGET: "*" + GITHUB_AW_PUSH_IF_NO_CHANGES: "warn" with: script: | async function main() { @@ -1355,24 +1422,65 @@ jobs: return; } const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + const ifNoChanges = process.env.GITHUB_AW_PUSH_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - core.setFailed("No patch file found - cannot push without changes"); - return; + const message = "No patch file found - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - core.setFailed( - "Patch file is empty or contains error message - cannot push without changes" - ); - return; + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to push - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } console.log("Target branch:", branchName); console.log("Target configuration:", target); // Parse the validated output JSON @@ -1436,35 +1544,63 @@ jobs: console.log("Branch does not exist, creating new branch:", branchName); execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); } - // Apply the patch using git CLI - console.log("Applying patch..."); - try { - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); - } catch (error) { - console.error( - "Failed to apply patch:", - error instanceof Error ? error.message : String(error) - ); - core.setFailed("Failed to apply patch"); - return; + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + try { + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } catch (error) { + console.error( + "Failed to apply patch:", + error instanceof Error ? error.message : String(error) + ); + core.setFailed("Failed to apply patch"); + return; + } + } else { + console.log("Skipping patch application (empty patch)"); } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); // Check if there are changes to commit + let hasChanges = false; try { execSync("git diff --cached --exit-code", { stdio: "ignore" }); - console.log("No changes to commit"); - return; + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to commit - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } + hasChanges = false; } catch (error) { // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; } - const commitMessage = pushItem.message || "Apply agent changes"; - execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed to branch:", branchName); - // Get commit SHA - const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + let commitSha; + if (hasChanges) { + const commitMessage = pushItem.message || "Apply agent changes"; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed to branch:", branchName); + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } else { + // For noop operations, get the current HEAD commit + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } + // Get commit SHA and push URL const pushUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; @@ -1473,16 +1609,23 @@ jobs: core.setOutput("commit_sha", commitSha); core.setOutput("push_url", pushUrl); // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` - ## Push to Branch + const summaryTitle = hasChanges + ? "Push to Branch" + : "Push to Branch (No Changes)"; + const summaryContent = hasChanges + ? ` + ## ${summaryTitle} - **Branch**: \`${branchName}\` - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) - **URL**: [${pushUrl}](${pushUrl}) ` - ) - .write(); + : ` + ## ${summaryTitle} + - **Branch**: \`${branchName}\` + - **Status**: No changes to apply (noop operation) + - **URL**: [${pushUrl}](${pushUrl}) + `; + await core.summary.addRaw(summaryContent).write(); } await main(); diff --git a/.github/workflows/test-codex-update-issue.lock.yml b/.github/workflows/test-codex-update-issue.lock.yml index 5fef4244b1..66ebae2f51 100644 --- a/.github/workflows/test-codex-update-issue.lock.yml +++ b/.github/workflows/test-codex-update-issue.lock.yml @@ -554,6 +554,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -972,6 +976,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml index c43af6b7a4..27387b5e52 100644 --- a/.github/workflows/test-proxy.lock.yml +++ b/.github/workflows/test-proxy.lock.yml @@ -707,6 +707,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -1125,6 +1129,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/.github/workflows/test-safe-outputs-custom-engine.lock.yml b/.github/workflows/test-safe-outputs-custom-engine.lock.yml index 36fefcd78a..b9f5cc1664 100644 --- a/.github/workflows/test-safe-outputs-custom-engine.lock.yml +++ b/.github/workflows/test-safe-outputs-custom-engine.lock.yml @@ -546,6 +546,10 @@ jobs: return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -964,6 +968,68 @@ jobs: item.title = sanitizeContent(item.title); item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; @@ -1287,10 +1353,21 @@ jobs: core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } @@ -1902,6 +1979,7 @@ jobs: GITHUB_AW_PR_TITLE_PREFIX: "[Custom Engine Test] " GITHUB_AW_PR_LABELS: "test-safe-outputs,automation,custom-engine" GITHUB_AW_PR_DRAFT: "true" + GITHUB_AW_PR_IF_NO_CHANGES: "warn" with: script: | /** @type {typeof import("fs")} */ @@ -1923,24 +2001,65 @@ jobs: if (outputContent.trim() === "") { console.log("Agent output content is empty"); } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - throw new Error( - "No patch file found - cannot create pull request without changes" - ); + const message = + "No patch file found - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - throw new Error( - "Patch file is empty or contains error message - cannot create pull request without changes" - ); + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot create pull request without changes"; + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to push - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } else { + console.log("Patch file is empty - processing noop operation"); + } // Parse the validated output JSON let validatedOutput; try { @@ -2049,16 +2168,56 @@ jobs: execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); console.log("Created and checked out new branch:", branchName); } - // Apply the patch using git CLI - console.log("Applying patch..."); - // Apply the patch using git apply - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } else { + console.log("Skipping patch application (empty patch)"); + } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); - execSync(`git commit -m "Add agent output: ${title}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed"); + // Check if there are changes to commit + let hasChanges = false; + let gitError = null; + try { + execSync("git diff --cached --exit-code", { stdio: "ignore" }); + // No changes - exit code 0 + hasChanges = false; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + if (!hasChanges) { + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to commit - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + if (hasChanges) { + execSync(`git commit -m "Add agent output: ${title}"`, { + stdio: "inherit", + }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed"); + } else { + // This should not happen due to the early return above, but keeping for safety + console.log("No changes to commit"); + return; + } // Create the pull request const { data: pullRequest } = await github.rest.pulls.create({ owner: context.repo.owner, @@ -2539,6 +2698,7 @@ jobs: GITHUB_AW_AGENT_OUTPUT: ${{ needs.test-safe-outputs-custom-engine.outputs.output }} GITHUB_AW_PUSH_BRANCH: "triggering" GITHUB_AW_PUSH_TARGET: "*" + GITHUB_AW_PUSH_IF_NO_CHANGES: "warn" with: script: | async function main() { @@ -2557,24 +2717,65 @@ jobs: return; } const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + const ifNoChanges = process.env.GITHUB_AW_PUSH_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - core.setFailed("No patch file found - cannot push without changes"); - return; + const message = "No patch file found - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - core.setFailed( - "Patch file is empty or contains error message - cannot push without changes" - ); - return; + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot push without changes"; + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to push - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } console.log("Target branch:", branchName); console.log("Target configuration:", target); // Parse the validated output JSON @@ -2638,35 +2839,63 @@ jobs: console.log("Branch does not exist, creating new branch:", branchName); execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); } - // Apply the patch using git CLI - console.log("Applying patch..."); - try { - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); - } catch (error) { - console.error( - "Failed to apply patch:", - error instanceof Error ? error.message : String(error) - ); - core.setFailed("Failed to apply patch"); - return; + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + try { + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } catch (error) { + console.error( + "Failed to apply patch:", + error instanceof Error ? error.message : String(error) + ); + core.setFailed("Failed to apply patch"); + return; + } + } else { + console.log("Skipping patch application (empty patch)"); } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); // Check if there are changes to commit + let hasChanges = false; try { execSync("git diff --cached --exit-code", { stdio: "ignore" }); - console.log("No changes to commit"); - return; + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to commit - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } + hasChanges = false; } catch (error) { // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + let commitSha; + if (hasChanges) { + const commitMessage = pushItem.message || "Apply agent changes"; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed to branch:", branchName); + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } else { + // For noop operations, get the current HEAD commit + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); } - const commitMessage = pushItem.message || "Apply agent changes"; - execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed to branch:", branchName); - // Get commit SHA - const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + // Get commit SHA and push URL const pushUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; @@ -2675,16 +2904,23 @@ jobs: core.setOutput("commit_sha", commitSha); core.setOutput("push_url", pushUrl); // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` - ## Push to Branch + const summaryTitle = hasChanges + ? "Push to Branch" + : "Push to Branch (No Changes)"; + const summaryContent = hasChanges + ? ` + ## ${summaryTitle} - **Branch**: \`${branchName}\` - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) - **URL**: [${pushUrl}](${pushUrl}) ` - ) - .write(); + : ` + ## ${summaryTitle} + - **Branch**: \`${branchName}\` + - **Status**: No changes to apply (noop operation) + - **URL**: [${pushUrl}](${pushUrl}) + `; + await core.summary.addRaw(summaryContent).write(); } await main(); diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 856fc1f71d..34e80ffa0a 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -154,6 +154,34 @@ safe-outputs: title-prefix: "[ai] " # Optional: prefix for PR titles labels: [automation, agentic] # Optional: labels to attach to PRs draft: true # Optional: create as draft PR (defaults to true) + if-no-changes: "warn" # Optional: behavior when no changes to commit (defaults to "warn") +``` + +**`if-no-changes` Configuration Options:** +- **`"warn"` (default)**: Logs a warning message but the workflow succeeds +- **`"error"`**: Fails the workflow with an error message if no changes are detected +- **`"ignore"`**: Silent success with no console output when no changes are detected + +**Examples:** +```yaml +# Default behavior - warn but succeed when no changes +safe-outputs: + create-pull-request: + if-no-changes: "warn" +``` + +```yaml +# Strict mode - fail if no changes to commit +safe-outputs: + create-pull-request: + if-no-changes: "error" +``` + +```yaml +# Silent mode - no output on empty changesets +safe-outputs: + create-pull-request: + if-no-changes: "ignore" ``` At most one pull request is currently supported. @@ -368,6 +396,10 @@ safe-outputs: # "triggering" (default) - only push in triggering PR context # "*" - allow pushes to any pull request (requires pull_request_number in agent output) # explicit number - push for specific pull request number + if-no-changes: "warn" # Optional: behavior when no changes to push + # "warn" (default) - log warning but succeed + # "error" - fail the action + # "ignore" - silent success ``` The agentic part of your workflow should describe the changes to be pushed and optionally provide a commit message. @@ -383,13 +415,47 @@ Analyze the pull request and make necessary code improvements. 2. Push changes to the feature branch with a descriptive commit message ``` +**Examples with different error level configurations:** + +```yaml +# Always succeed, warn when no changes (default behavior) +safe-outputs: + push-to-branch: + branch: feature-branch + if-no-changes: "warn" +``` + +```yaml +# Fail when no changes are made (strict mode) +safe-outputs: + push-to-branch: + branch: feature-branch + if-no-changes: "error" +``` + +```yaml +# Silent success, no output when no changes +safe-outputs: + push-to-branch: + branch: feature-branch + if-no-changes: "ignore" +``` + **Safety Features:** - Changes are applied via git patches generated from the workflow's modifications - Only the specified branch can be modified - Target configuration controls which pull requests can trigger pushes for security - Push operations are limited to one per workflow execution -- Requires valid patch content to proceed (empty patches are rejected) +- Configurable error handling for empty changesets via `if-no-changes` option + +**Error Level Configuration:** + +Similar to GitHub's `actions/upload-artifact` action, you can configure how the action behaves when there are no changes to push: + +- **`warn` (default)**: Logs a warning message but the workflow succeeds. This is the recommended setting for most use cases. +- **`error`**: Fails the workflow with an error message when no changes are detected. Useful when you always expect changes to be made. +- **`ignore`**: Silent success with no console output. The workflow completes successfully but quietly. **Safety Features:** diff --git a/package-lock.json b/package-lock.json index 822941ba4b..e3b562eb91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@actions/github": "^6.0.1", "@actions/glob": "^0.4.0", "@actions/io": "^1.1.3", - "@types/node": "^24.3.0", + "@types/node": "^24.3.1", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "prettier": "^3.4.2", @@ -1165,9 +1165,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "devOptional": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c688fbff32..e204380644 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "@actions/github": "^6.0.1", "@actions/glob": "^0.4.0", "@actions/io": "^1.1.3", - "@types/node": "^24.3.0", + "@types/node": "^24.3.1", "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "prettier": "^3.4.2", diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 91c6f82de5..937fe9caa6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1257,6 +1257,11 @@ "draft": { "type": "boolean", "description": "Whether to create pull request as draft (defaults to true)" + }, + "if-no-changes": { + "type": "string", + "enum": ["warn", "error", "ignore"], + "description": "Behavior when no changes to push: 'warn' (default - log warning but succeed), 'error' (fail the action), or 'ignore' (silent success)" } }, "additionalProperties": false @@ -1389,7 +1394,7 @@ "oneOf": [ { "type": "null", - "description": "Use default configuration (branch: 'triggering')" + "description": "Use default configuration (branch: 'triggering', if-no-changes: 'warn')" }, { "type": "object", @@ -1402,6 +1407,11 @@ "target": { "type": "string", "description": "Target for push operations: 'triggering' (default), '*' (any pull request), or explicit pull request number" + }, + "if-no-changes": { + "type": "string", + "enum": ["warn", "error", "ignore"], + "description": "Behavior when no changes to push: 'warn' (default - log warning but succeed), 'error' (fail the action), or 'ignore' (silent success)" } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 89d310aa0a..9785700a29 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -185,8 +185,9 @@ type AddIssueCommentsConfig struct { type CreatePullRequestsConfig struct { TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` - Draft *bool `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil) and explicitly false - Max int `yaml:"max,omitempty"` // Maximum number of pull requests to create + Draft *bool `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil) and explicitly false + Max int `yaml:"max,omitempty"` // Maximum number of pull requests to create + IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" } // CreatePullRequestReviewCommentsConfig holds configuration for creating GitHub pull request review comments from agent output @@ -218,8 +219,9 @@ type UpdateIssuesConfig struct { // PushToBranchConfig holds configuration for pushing changes to a specific branch from agent output type PushToBranchConfig struct { - Branch string `yaml:"branch"` // The branch to push changes to (defaults to "triggering") - Target string `yaml:"target,omitempty"` // Target for push-to-branch: like add-issue-comment but for pull requests + Branch string `yaml:"branch"` // The branch to push changes to (defaults to "triggering") + Target string `yaml:"target,omitempty"` // Target for push-to-branch: like add-issue-comment but for pull requests + IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn") } // MissingToolConfig holds configuration for reporting missing tools or functionality @@ -2249,6 +2251,13 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa } steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_DRAFT: %q\n", fmt.Sprintf("%t", draftValue))) + // Pass the if-no-changes configuration + ifNoChanges := data.SafeOutputs.CreatePullRequests.IfNoChanges + if ifNoChanges == "" { + ifNoChanges = "warn" // Default value + } + steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_IF_NO_CHANGES: %q\n", ifNoChanges)) + steps = append(steps, " with:\n") steps = append(steps, " script: |\n") @@ -3257,6 +3266,13 @@ func (c *Compiler) parsePullRequestsConfig(outputMap map[string]any) *CreatePull } } + // Parse if-no-changes + if ifNoChanges, exists := configMap["if-no-changes"]; exists { + if ifNoChangesStr, ok := ifNoChanges.(string); ok { + pullRequestsConfig.IfNoChanges = ifNoChangesStr + } + } + // Note: max parameter is not supported for pull requests (always limited to 1) // If max is specified, it will be ignored as pull requests are singular only } @@ -3387,7 +3403,8 @@ func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssu func (c *Compiler) parsePushToBranchConfig(outputMap map[string]any) *PushToBranchConfig { if configData, exists := outputMap["push-to-branch"]; exists { pushToBranchConfig := &PushToBranchConfig{ - Branch: "triggering", // Default branch value + Branch: "triggering", // Default branch value + IfNoChanges: "warn", // Default behavior: warn when no changes } // Handle the case where configData is nil (push-to-branch: with no value) @@ -3409,6 +3426,23 @@ func (c *Compiler) parsePushToBranchConfig(outputMap map[string]any) *PushToBran pushToBranchConfig.Target = targetStr } } + + // Parse if-no-changes (optional, defaults to "warn") + if ifNoChanges, exists := configMap["if-no-changes"]; exists { + if ifNoChangesStr, ok := ifNoChanges.(string); ok { + // Validate the value + switch ifNoChangesStr { + case "warn", "error", "ignore": + pushToBranchConfig.IfNoChanges = ifNoChangesStr + default: + // Invalid value, use default and log warning + if c.verbose { + fmt.Printf("Warning: invalid if-no-changes value '%s', using default 'warn'\n", ifNoChangesStr) + } + pushToBranchConfig.IfNoChanges = "warn" + } + } + } } return pushToBranchConfig diff --git a/pkg/workflow/js/collect_ndjson_output.cjs b/pkg/workflow/js/collect_ndjson_output.cjs index 6ac8066a45..137e9fde9e 100644 --- a/pkg/workflow/js/collect_ndjson_output.cjs +++ b/pkg/workflow/js/collect_ndjson_output.cjs @@ -181,6 +181,10 @@ async function main() { return 1; // Only one push to branch allowed case "create-discussion": return 1; // Only one discussion allowed + case "missing-tool": + return 1000; // Allow many missing tool reports (default: unlimited) + case "create-security-report": + return 1000; // Allow many security reports (default: unlimited) default: return 1; // Default to single item for unknown types } @@ -631,6 +635,70 @@ async function main() { item.body = sanitizeContent(item.body); break; + case "missing-tool": + // Validate required tool field + if (!item.tool || typeof item.tool !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'tool' string field` + ); + continue; + } + // Validate required reason field + if (!item.reason || typeof item.reason !== "string") { + errors.push( + `Line ${i + 1}: missing-tool requires a 'reason' string field` + ); + continue; + } + // Sanitize text content + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + // Validate optional alternatives field + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push( + `Line ${i + 1}: missing-tool 'alternatives' must be a string` + ); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + + case "create-security-report": + // Validate required sarif field + if (!item.sarif) { + errors.push( + `Line ${i + 1}: create-security-report requires a 'sarif' field` + ); + continue; + } + // SARIF content can be object or string + if ( + typeof item.sarif !== "object" && + typeof item.sarif !== "string" + ) { + errors.push( + `Line ${i + 1}: create-security-report 'sarif' must be an object or string` + ); + continue; + } + // If SARIF is a string, sanitize it + if (typeof item.sarif === "string") { + item.sarif = sanitizeContent(item.sarif); + } + // Validate optional category field + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push( + `Line ${i + 1}: create-security-report 'category' must be a string` + ); + continue; + } + item.category = sanitizeContent(item.category); + } + break; + default: errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); continue; diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index ff240dea16..907b2b7bfd 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -147,10 +147,23 @@ async function main() { core.setOutput("issue_url", issue.html_url); } } catch (error) { - console.error( - `✗ Failed to create issue "${title}":`, - error instanceof Error ? error.message : String(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 + } + + console.error(`✗ Failed to create issue "${title}":`, errorMessage); throw error; } } diff --git a/pkg/workflow/js/create_issue.test.cjs b/pkg/workflow/js/create_issue.test.cjs index bbd6b35cba..dbba32f3ef 100644 --- a/pkg/workflow/js/create_issue.test.cjs +++ b/pkg/workflow/js/create_issue.test.cjs @@ -353,4 +353,144 @@ describe("create_issue.cjs", () => { consoleSpy.mockRestore(); }); + + it("should handle disabled issues repository gracefully", async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [ + { + type: "create-issue", + title: "Test issue", + body: "This should fail gracefully", + }, + ], + }); + + // Mock GitHub API to throw the specific error for disabled issues + const disabledError = new Error( + "Issues has been disabled in this repository." + ); + mockGithub.rest.issues.create.mockRejectedValue(disabledError); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + // Execute the script - should not throw error + await eval(`(async () => { ${createIssueScript} })()`); + + // Should log warning message instead of error + expect(consoleSpy).toHaveBeenCalledWith( + '⚠ Cannot create issue "Test issue": Issues are disabled for this repository' + ); + expect(consoleSpy).toHaveBeenCalledWith( + "Consider enabling issues in repository settings if you want to create issues automatically" + ); + + // Should not have called console.error for this specific error + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("✗ Failed to create issue") + ); + + // Should still log successful completion with 0 issues + expect(consoleSpy).toHaveBeenCalledWith("Successfully created 0 issue(s)"); + + // Should not set outputs since no issues were created + expect(mockCore.setOutput).not.toHaveBeenCalled(); + + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it("should continue processing other issues when one fails due to disabled repository", async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [ + { + type: "create-issue", + title: "First issue", + body: "This will fail", + }, + { + type: "create-issue", + title: "Second issue", + body: "This should succeed", + }, + ], + }); + + const disabledError = new Error( + "Issues has been disabled in this repository." + ); + const mockIssue = { + number: 505, + html_url: "https://github.com/testowner/testrepo/issues/505", + }; + + // First call fails with disabled error, second call succeeds + mockGithub.rest.issues.create + .mockRejectedValueOnce(disabledError) + .mockResolvedValueOnce({ data: mockIssue }); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + // Execute the script + await eval(`(async () => { ${createIssueScript} })()`); + + // Should log warning for first issue + expect(consoleSpy).toHaveBeenCalledWith( + '⚠ Cannot create issue "First issue": Issues are disabled for this repository' + ); + + // Should log success for second issue + expect(consoleSpy).toHaveBeenCalledWith( + "Created issue #" + mockIssue.number + ": " + mockIssue.html_url + ); + + // Should report 1 issue created successfully + expect(consoleSpy).toHaveBeenCalledWith("Successfully created 1 issue(s)"); + + // Should set outputs for the successful issue + expect(mockCore.setOutput).toHaveBeenCalledWith("issue_number", 505); + expect(mockCore.setOutput).toHaveBeenCalledWith( + "issue_url", + mockIssue.html_url + ); + + consoleSpy.mockRestore(); + }); + + it("should still throw error for other API errors", async () => { + process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [ + { + type: "create-issue", + title: "Test issue", + body: "This should fail with different error", + }, + ], + }); + + // Mock GitHub API to throw a different error + const otherError = new Error("API rate limit exceeded"); + mockGithub.rest.issues.create.mockRejectedValue(otherError); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + // Execute the script - should throw error for non-disabled-issues errors + await expect( + eval(`(async () => { ${createIssueScript} })()`) + ).rejects.toThrow("API rate limit exceeded"); + + // Should log error message for other errors + expect(consoleErrorSpy).toHaveBeenCalledWith( + '✗ Failed to create issue "Test issue":', + "API rate limit exceeded" + ); + + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); }); diff --git a/pkg/workflow/js/create_pull_request.cjs b/pkg/workflow/js/create_pull_request.cjs index 1ac4626af4..e56fa25740 100644 --- a/pkg/workflow/js/create_pull_request.cjs +++ b/pkg/workflow/js/create_pull_request.cjs @@ -21,26 +21,73 @@ async function main() { console.log("Agent output content is empty"); } + const ifNoChanges = process.env.GITHUB_AW_PR_IF_NO_CHANGES || "warn"; + // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - throw new Error( - "No patch file found - cannot create pull request without changes" - ); + const message = + "No patch file found - cannot create pull request without changes"; + + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - throw new Error( - "Patch file is empty or contains error message - cannot create pull request without changes" - ); + + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot create pull request without changes"; + + switch (ifNoChanges) { + case "error": + throw new Error(message); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to push - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } else { + console.log("Patch file is empty - processing noop operation"); + } // Parse the validated output JSON let validatedOutput; @@ -167,18 +214,62 @@ async function main() { console.log("Created and checked out new branch:", branchName); } - // Apply the patch using git CLI - console.log("Applying patch..."); - - // Apply the patch using git apply - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } else { + console.log("Skipping patch application (empty patch)"); + } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); - execSync(`git commit -m "Add agent output: ${title}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed"); + + // Check if there are changes to commit + let hasChanges = false; + let gitError = null; + + try { + execSync("git diff --cached --exit-code", { stdio: "ignore" }); + // No changes - exit code 0 + hasChanges = false; + } catch (error) { + // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; + } + + if (!hasChanges) { + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + + switch (ifNoChanges) { + case "error": + throw new Error( + "No changes to commit - failing as configured by if-no-changes: error" + ); + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + + if (hasChanges) { + execSync(`git commit -m "Add agent output: ${title}"`, { + stdio: "inherit", + }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed"); + } else { + // This should not happen due to the early return above, but keeping for safety + console.log("No changes to commit"); + return; + } // Create the pull request const { data: pullRequest } = await github.rest.pulls.create({ diff --git a/pkg/workflow/js/create_pull_request.test.cjs b/pkg/workflow/js/create_pull_request.test.cjs index c8bf75a888..91cfdcb44d 100644 --- a/pkg/workflow/js/create_pull_request.test.cjs +++ b/pkg/workflow/js/create_pull_request.test.cjs @@ -109,28 +109,34 @@ describe("create_pull_request.cjs", () => { ); }); - it("should throw error when patch file does not exist", async () => { + it("should handle missing patch file with default warn behavior", async () => { mockDependencies.process.env.GITHUB_AW_WORKFLOW_ID = "test-workflow"; mockDependencies.process.env.GITHUB_AW_BASE_BRANCH = "main"; mockDependencies.fs.existsSync.mockReturnValue(false); const mainFunction = createMainFunction(mockDependencies); - await expect(mainFunction()).rejects.toThrow( + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( "No patch file found - cannot create pull request without changes" ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); }); - it("should throw error when patch file is empty", async () => { + it("should handle empty patch with default warn behavior when patch file is empty", async () => { mockDependencies.process.env.GITHUB_AW_WORKFLOW_ID = "test-workflow"; mockDependencies.process.env.GITHUB_AW_BASE_BRANCH = "main"; mockDependencies.fs.readFileSync.mockReturnValue(" "); const mainFunction = createMainFunction(mockDependencies); - await expect(mainFunction()).rejects.toThrow( - "Patch file is empty or contains error message - cannot create pull request without changes" + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "Patch file is empty - no changes to apply (noop operation)" ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); }); it("should create pull request successfully with valid input", async () => { @@ -146,6 +152,21 @@ describe("create_pull_request.cjs", () => { ], }); + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + if (command === "git rev-parse HEAD") { + return "abc123456"; + } + // For all other git commands, just return normally + return ""; + }); + const mockPullRequest = { number: 123, html_url: "https://github.com/testowner/testrepo/pull/123", @@ -179,6 +200,10 @@ describe("create_pull_request.cjs", () => { expect(mockDependencies.execSync).toHaveBeenCalledWith("git add .", { stdio: "inherit", }); + expect(mockDependencies.execSync).toHaveBeenCalledWith( + "git diff --cached --exit-code", + { stdio: "ignore" } + ); expect(mockDependencies.execSync).toHaveBeenCalledWith( 'git commit -m "Add agent output: New Feature"', { stdio: "inherit" } @@ -228,6 +253,17 @@ describe("create_pull_request.cjs", () => { mockDependencies.process.env.GITHUB_AW_PR_LABELS = "enhancement, automated, needs-review"; + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + return ""; + }); + const mockPullRequest = { number: 456, html_url: "https://github.com/testowner/testrepo/pull/456", @@ -265,6 +301,17 @@ describe("create_pull_request.cjs", () => { }); mockDependencies.process.env.GITHUB_AW_PR_DRAFT = "false"; + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + return ""; + }); + const mockPullRequest = { number: 789, html_url: "https://github.com/testowner/testrepo/pull/789", @@ -295,6 +342,17 @@ describe("create_pull_request.cjs", () => { ], }); + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + return ""; + }); + const mockPullRequest = { number: 202, html_url: "https://github.com/testowner/testrepo/pull/202", @@ -334,6 +392,17 @@ describe("create_pull_request.cjs", () => { }); mockDependencies.process.env.GITHUB_AW_PR_TITLE_PREFIX = "[BOT] "; + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + return ""; + }); + const mockPullRequest = { number: 987, html_url: "https://github.com/testowner/testrepo/pull/987", @@ -365,6 +434,17 @@ describe("create_pull_request.cjs", () => { }); mockDependencies.process.env.GITHUB_AW_PR_TITLE_PREFIX = "[BOT] "; + // Mock execSync to simulate git behavior with changes + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Throw to indicate changes are present (non-zero exit code) + const error = new Error("Changes exist"); + error.status = 1; + throw error; + } + return ""; + }); + const mockPullRequest = { number: 988, html_url: "https://github.com/testowner/testrepo/pull/988", @@ -381,4 +461,179 @@ describe("create_pull_request.cjs", () => { const callArgs = mockDependencies.github.rest.pulls.create.mock.calls[0][0]; expect(callArgs.title).toBe("[BOT] PR title already prefixed"); // Should not be duplicated }); + + describe("if-no-changes configuration", () => { + beforeEach(() => { + mockDependencies.process.env.GITHUB_AW_WORKFLOW_ID = "test-workflow"; + mockDependencies.process.env.GITHUB_AW_BASE_BRANCH = "main"; + mockDependencies.process.env.GITHUB_AW_AGENT_OUTPUT = JSON.stringify({ + items: [ + { + type: "create-pull-request", + title: "Test PR", + body: "Test PR body", + }, + ], + }); + }); + + it("should handle empty patch with warn (default) behavior", async () => { + mockDependencies.fs.readFileSync.mockReturnValue(""); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "warn"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "Patch file is empty - no changes to apply (noop operation)" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle empty patch with ignore behavior", async () => { + mockDependencies.fs.readFileSync.mockReturnValue(""); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "ignore"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).not.toHaveBeenCalledWith( + expect.stringContaining("Patch file is empty") + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle empty patch with error behavior", async () => { + mockDependencies.fs.readFileSync.mockReturnValue(""); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "error"; + + const mainFunction = createMainFunction(mockDependencies); + + await expect(mainFunction()).rejects.toThrow( + "No changes to push - failing as configured by if-no-changes: error" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle missing patch file with warn behavior", async () => { + mockDependencies.fs.existsSync.mockReturnValue(false); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "warn"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "No patch file found - cannot create pull request without changes" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle missing patch file with ignore behavior", async () => { + mockDependencies.fs.existsSync.mockReturnValue(false); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "ignore"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).not.toHaveBeenCalledWith( + expect.stringContaining("No patch file found") + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle missing patch file with error behavior", async () => { + mockDependencies.fs.existsSync.mockReturnValue(false); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "error"; + + const mainFunction = createMainFunction(mockDependencies); + + await expect(mainFunction()).rejects.toThrow( + "No patch file found - cannot create pull request without changes" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle patch with error message with warn behavior", async () => { + mockDependencies.fs.readFileSync.mockReturnValue( + "Failed to generate patch: some error" + ); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "warn"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "Patch file contains error message - cannot create pull request without changes" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle no changes to commit with warn behavior", async () => { + // Mock valid patch content but no changes after git add + mockDependencies.fs.readFileSync.mockReturnValue( + "diff --git a/file.txt b/file.txt\n+content" + ); + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Return with exit code 0 (no changes) + return ""; + } + if (command.includes("git commit")) { + throw new Error("Should not reach commit"); + } + }); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "warn"; + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "No changes to commit - noop operation completed successfully" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should handle no changes to commit with error behavior", async () => { + // Mock valid patch content but no changes after git add + mockDependencies.fs.readFileSync.mockReturnValue( + "diff --git a/file.txt b/file.txt\n+content" + ); + mockDependencies.execSync.mockImplementation(command => { + if (command === "git diff --cached --exit-code") { + // Return with exit code 0 (no changes) - don't throw an error + return ""; + } + // For other git commands, return normally + return ""; + }); + mockDependencies.process.env.GITHUB_AW_PR_IF_NO_CHANGES = "error"; + + const mainFunction = createMainFunction(mockDependencies); + + await expect(mainFunction()).rejects.toThrow( + "No changes to commit - failing as configured by if-no-changes: error" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + + it("should default to warn when if-no-changes is not specified", async () => { + mockDependencies.fs.readFileSync.mockReturnValue(""); + // Don't set GITHUB_AW_PR_IF_NO_CHANGES env var + + const mainFunction = createMainFunction(mockDependencies); + + await mainFunction(); + + expect(mockDependencies.console.log).toHaveBeenCalledWith( + "Patch file is empty - no changes to apply (noop operation)" + ); + expect(mockDependencies.github.rest.pulls.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/pkg/workflow/js/push_to_branch.cjs b/pkg/workflow/js/push_to_branch.cjs index 8c79fbd192..10751b4104 100644 --- a/pkg/workflow/js/push_to_branch.cjs +++ b/pkg/workflow/js/push_to_branch.cjs @@ -17,27 +17,73 @@ async function main() { } const target = process.env.GITHUB_AW_PUSH_TARGET || "triggering"; + const ifNoChanges = process.env.GITHUB_AW_PUSH_IF_NO_CHANGES || "warn"; // Check if patch file exists and has valid content if (!fs.existsSync("/tmp/aw.patch")) { - core.setFailed("No patch file found - cannot push without changes"); - return; + const message = "No patch file found - cannot push without changes"; + + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } } const patchContent = fs.readFileSync("/tmp/aw.patch", "utf8"); - if ( - !patchContent || - !patchContent.trim() || - patchContent.includes("Failed to generate patch") - ) { - core.setFailed( - "Patch file is empty or contains error message - cannot push without changes" - ); - return; + + // Check for actual error conditions (but allow empty patches as valid noop) + if (patchContent.includes("Failed to generate patch")) { + const message = + "Patch file contains error message - cannot push without changes"; + + switch (ifNoChanges) { + case "error": + core.setFailed(message); + return; + case "ignore": + // Silent success - no console output + return; + case "warn": + default: + console.log(message); + return; + } + } + + // Empty patch is valid - behavior depends on if-no-changes configuration + const isEmpty = !patchContent || !patchContent.trim(); + if (isEmpty) { + const message = + "Patch file is empty - no changes to apply (noop operation)"; + + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to push - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } } console.log("Agent output content length:", outputContent.length); - console.log("Patch content validation passed"); + if (!isEmpty) { + console.log("Patch content validation passed"); + } console.log("Target branch:", branchName); console.log("Target configuration:", target); @@ -110,39 +156,70 @@ async function main() { execSync(`git checkout -b ${branchName}`, { stdio: "inherit" }); } - // Apply the patch using git CLI - console.log("Applying patch..."); - try { - execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); - console.log("Patch applied successfully"); - } catch (error) { - console.error( - "Failed to apply patch:", - error instanceof Error ? error.message : String(error) - ); - core.setFailed("Failed to apply patch"); - return; + // Apply the patch using git CLI (skip if empty) + if (!isEmpty) { + console.log("Applying patch..."); + try { + execSync("git apply /tmp/aw.patch", { stdio: "inherit" }); + console.log("Patch applied successfully"); + } catch (error) { + console.error( + "Failed to apply patch:", + error instanceof Error ? error.message : String(error) + ); + core.setFailed("Failed to apply patch"); + return; + } + } else { + console.log("Skipping patch application (empty patch)"); } // Commit and push the changes execSync("git add .", { stdio: "inherit" }); // Check if there are changes to commit + let hasChanges = false; try { execSync("git diff --cached --exit-code", { stdio: "ignore" }); - console.log("No changes to commit"); - return; + + // No changes to commit - apply if-no-changes configuration + const message = + "No changes to commit - noop operation completed successfully"; + + switch (ifNoChanges) { + case "error": + core.setFailed( + "No changes to commit - failing as configured by if-no-changes: error" + ); + return; + case "ignore": + // Silent success - no console output + break; + case "warn": + default: + console.log(message); + break; + } + + hasChanges = false; } catch (error) { // Exit code != 0 means there are changes to commit, which is what we want + hasChanges = true; } - const commitMessage = pushItem.message || "Apply agent changes"; - execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); - execSync(`git push origin ${branchName}`, { stdio: "inherit" }); - console.log("Changes committed and pushed to branch:", branchName); + let commitSha; + if (hasChanges) { + const commitMessage = pushItem.message || "Apply agent changes"; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + console.log("Changes committed and pushed to branch:", branchName); + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } else { + // For noop operations, get the current HEAD commit + commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + } - // Get commit SHA - const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(); + // Get commit SHA and push URL const pushUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; @@ -153,16 +230,24 @@ async function main() { core.setOutput("push_url", pushUrl); // Write summary to GitHub Actions summary - await core.summary - .addRaw( - ` -## Push to Branch + const summaryTitle = hasChanges + ? "Push to Branch" + : "Push to Branch (No Changes)"; + const summaryContent = hasChanges + ? ` +## ${summaryTitle} - **Branch**: \`${branchName}\` - **Commit**: [${commitSha.substring(0, 7)}](${pushUrl}) - **URL**: [${pushUrl}](${pushUrl}) ` - ) - .write(); + : ` +## ${summaryTitle} +- **Branch**: \`${branchName}\` +- **Status**: No changes to apply (noop operation) +- **URL**: [${pushUrl}](${pushUrl}) +`; + + await core.summary.addRaw(summaryContent).write(); } await main(); diff --git a/pkg/workflow/js/push_to_branch.test.cjs b/pkg/workflow/js/push_to_branch.test.cjs index 83b851a2b6..39901fe9c1 100644 --- a/pkg/workflow/js/push_to_branch.test.cjs +++ b/pkg/workflow/js/push_to_branch.test.cjs @@ -70,6 +70,9 @@ describe("push_to_branch.cjs", () => { expect(scriptContent).toContain("process.env.GITHUB_AW_PUSH_BRANCH"); expect(scriptContent).toContain("process.env.GITHUB_AW_AGENT_OUTPUT"); expect(scriptContent).toContain("process.env.GITHUB_AW_PUSH_TARGET"); + expect(scriptContent).toContain( + "process.env.GITHUB_AW_PUSH_IF_NO_CHANGES" + ); }); it("should handle patch file operations", () => { @@ -93,5 +96,38 @@ describe("push_to_branch.cjs", () => { expect(scriptContent).toContain("git fetch"); expect(scriptContent).toContain("git config"); }); + + it("should handle empty patches as noop operations", () => { + const scriptPath = path.join(__dirname, "push_to_branch.cjs"); + const scriptContent = fs.readFileSync(scriptPath, "utf8"); + + // Check that empty patches are handled gracefully + expect(scriptContent).toContain("noop operation"); + expect(scriptContent).toContain("Patch file is empty"); + expect(scriptContent).toContain( + "No changes to commit - noop operation completed successfully" + ); + }); + + it("should handle if-no-changes configuration options", () => { + const scriptPath = path.join(__dirname, "push_to_branch.cjs"); + const scriptContent = fs.readFileSync(scriptPath, "utf8"); + + // Check that environment variable is read + expect(scriptContent).toContain("GITHUB_AW_PUSH_IF_NO_CHANGES"); + expect(scriptContent).toContain("switch (ifNoChanges)"); + expect(scriptContent).toContain('case "error":'); + expect(scriptContent).toContain('case "ignore":'); + expect(scriptContent).toContain('case "warn":'); + }); + + it("should still fail on actual error conditions", () => { + const scriptPath = path.join(__dirname, "push_to_branch.cjs"); + const scriptContent = fs.readFileSync(scriptPath, "utf8"); + + // Check that actual errors still cause failures + expect(scriptContent).toContain("Failed to generate patch"); + expect(scriptContent).toContain("core.setFailed"); + }); }); }); diff --git a/pkg/workflow/output_push_to_branch.go b/pkg/workflow/output_push_to_branch.go index b4dc937264..d16eea07c0 100644 --- a/pkg/workflow/output_push_to_branch.go +++ b/pkg/workflow/output_push_to_branch.go @@ -45,6 +45,8 @@ func (c *Compiler) buildCreateOutputPushToBranchJob(data *WorkflowData, mainJobN if data.SafeOutputs.PushToBranch.Target != "" { steps = append(steps, fmt.Sprintf(" GITHUB_AW_PUSH_TARGET: %q\n", data.SafeOutputs.PushToBranch.Target)) } + // Pass the if-no-changes configuration + steps = append(steps, fmt.Sprintf(" GITHUB_AW_PUSH_IF_NO_CHANGES: %q\n", data.SafeOutputs.PushToBranch.IfNoChanges)) steps = append(steps, " with:\n") steps = append(steps, " script: |\n") diff --git a/pkg/workflow/output_push_to_branch_test.go b/pkg/workflow/output_push_to_branch_test.go index 3893b207c0..e4b520b2cc 100644 --- a/pkg/workflow/output_push_to_branch_test.go +++ b/pkg/workflow/output_push_to_branch_test.go @@ -309,6 +309,150 @@ This workflow has minimal push-to-branch configuration. } } +func TestPushToBranchWithIfNoChangesError(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Create a test markdown file with if-no-changes: error + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-branch: + branch: feature-updates + target: "triggering" + if-no-changes: "error" +--- + +# Test Push to Branch with if-no-changes: error + +This workflow fails when there are no changes. +` + + // Write the test file + mdFile := filepath.Join(tmpDir, "test-push-to-branch-error.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + // Create compiler and compile the workflow + compiler := NewCompiler(false, "", "test") + + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated .lock.yml file + lockFile := strings.TrimSuffix(mdFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify that if-no-changes configuration is passed correctly + if !strings.Contains(lockContentStr, "GITHUB_AW_PUSH_IF_NO_CHANGES: \"error\"") { + t.Errorf("Generated workflow should contain if-no-changes configuration") + } +} + +func TestPushToBranchWithIfNoChangesIgnore(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Create a test markdown file with if-no-changes: ignore + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-branch: + branch: feature-updates + if-no-changes: "ignore" +--- + +# Test Push to Branch with if-no-changes: ignore + +This workflow ignores when there are no changes. +` + + // Write the test file + mdFile := filepath.Join(tmpDir, "test-push-to-branch-ignore.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + // Create compiler and compile the workflow + compiler := NewCompiler(false, "", "test") + + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated .lock.yml file + lockFile := strings.TrimSuffix(mdFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify that if-no-changes configuration is passed correctly + if !strings.Contains(lockContentStr, "GITHUB_AW_PUSH_IF_NO_CHANGES: \"ignore\"") { + t.Errorf("Generated workflow should contain if-no-changes ignore configuration") + } +} + +func TestPushToBranchDefaultIfNoChanges(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Create a test markdown file without if-no-changes (should default to "warn") + testMarkdown := `--- +on: + pull_request: + types: [opened, synchronize] +safe-outputs: + push-to-branch: + branch: feature-updates +--- + +# Test Push to Branch Default if-no-changes + +This workflow uses default if-no-changes behavior. +` + + // Write the test file + mdFile := filepath.Join(tmpDir, "test-push-to-branch-default-if-no-changes.md") + if err := os.WriteFile(mdFile, []byte(testMarkdown), 0644); err != nil { + t.Fatalf("Failed to write test markdown file: %v", err) + } + + // Create compiler and compile the workflow + compiler := NewCompiler(false, "", "test") + + if err := compiler.CompileWorkflow(mdFile); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated .lock.yml file + lockFile := strings.TrimSuffix(mdFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + + // Verify that default if-no-changes configuration ("warn") is passed correctly + if !strings.Contains(lockContentStr, "GITHUB_AW_PUSH_IF_NO_CHANGES: \"warn\"") { + t.Errorf("Generated workflow should contain default if-no-changes configuration (warn)") + } +} + func TestPushToBranchExplicitTriggering(t *testing.T) { // Create a temporary directory for the test tmpDir := t.TempDir() diff --git a/pkg/workflow/output_test.go b/pkg/workflow/output_test.go index 5b43fc9b1c..f396f6e1f6 100644 --- a/pkg/workflow/output_test.go +++ b/pkg/workflow/output_test.go @@ -1696,3 +1696,109 @@ This workflow tests that missing allowed field is now optional. t.Fatal("Expected lock file to be created") } } + +func TestCreatePullRequestIfNoChangesConfig(t *testing.T) { + // Create temporary directory for test files + tmpDir, err := os.MkdirTemp("", "create-pr-if-no-changes-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test case with create-pull-request if-no-changes configuration + testContent := `--- +on: push +permissions: + contents: read + pull-requests: write +engine: claude +safe-outputs: + create-pull-request: + title-prefix: "[agent] " + labels: [automation] + if-no-changes: "error" +--- + +# Test Create Pull Request If-No-Changes Configuration + +This workflow tests the create-pull-request if-no-changes configuration parsing. +` + + testFile := filepath.Join(tmpDir, "test-create-pr-if-no-changes.md") + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatal(err) + } + + compiler := NewCompiler(false, "", "test") + + // Parse the workflow data + workflowData, err := compiler.parseWorkflowFile(testFile) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with create-pull-request if-no-changes config: %v", err) + } + + // Verify create-pull-request configuration is parsed correctly + if workflowData.SafeOutputs == nil { + t.Fatal("Expected safe-outputs configuration to be present") + } + + if workflowData.SafeOutputs.CreatePullRequests == nil { + t.Fatal("Expected create-pull-request configuration to be parsed") + } + + if workflowData.SafeOutputs.CreatePullRequests.IfNoChanges != "error" { + t.Errorf("Expected if-no-changes to be 'error', got '%s'", workflowData.SafeOutputs.CreatePullRequests.IfNoChanges) + } + + // Test with default value + testContentDefault := `--- +on: push +permissions: + contents: read + pull-requests: write +engine: claude +safe-outputs: + create-pull-request: + title-prefix: "[agent] " +--- + +# Test Create Pull Request Default If-No-Changes + +This workflow tests the default if-no-changes behavior. +` + + testFileDefault := filepath.Join(tmpDir, "test-create-pr-if-no-changes-default.md") + if err := os.WriteFile(testFileDefault, []byte(testContentDefault), 0644); err != nil { + t.Fatal(err) + } + + // Parse the workflow data for default case + workflowDataDefault, err := compiler.parseWorkflowFile(testFileDefault) + if err != nil { + t.Fatalf("Unexpected error parsing workflow with default if-no-changes config: %v", err) + } + + // Verify default if-no-changes is empty (will default to "warn" at runtime) + if workflowDataDefault.SafeOutputs.CreatePullRequests.IfNoChanges != "" { + t.Errorf("Expected default if-no-changes to be empty, got '%s'", workflowDataDefault.SafeOutputs.CreatePullRequests.IfNoChanges) + } + + // Test compilation with the if-no-changes configuration + err = compiler.CompileWorkflow(testFile) + if err != nil { + t.Fatalf("Unexpected error compiling workflow with if-no-changes config: %v", err) + } + + // Read the generated lock file + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + // Verify the if-no-changes configuration is passed to the environment + lockContentStr := string(lockContent) + if !strings.Contains(lockContentStr, "GITHUB_AW_PR_IF_NO_CHANGES: \"error\"") { + t.Error("Expected GITHUB_AW_PR_IF_NO_CHANGES environment variable to be set in generated workflow") + } +}