diff --git a/.github/workflows/security-guard.lock.yml b/.github/workflows/security-guard.lock.yml index 1e9bde7812..cd518c78a7 100644 --- a/.github/workflows/security-guard.lock.yml +++ b/.github/workflows/security-guard.lock.yml @@ -90,6 +90,7 @@ jobs: GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} has_patch: ${{ steps.collect_output.outputs.has_patch }} model: ${{ steps.generate_aw_info.outputs.model }} output: ${{ steps.collect_output.outputs.output }} @@ -124,6 +125,7 @@ jobs: git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" - name: Checkout PR branch + id: checkout-pr if: | github.event.pull_request uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -796,6 +798,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🛡️ *Security posture analysis by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔒 [{workflow_name}]({run_url}) is analyzing this pull request for security posture changes...\",\"runSuccess\":\"🛡️ [{workflow_name}]({run_url}) completed security posture analysis.\",\"runFailure\":\"⚠️ [{workflow_name}]({run_url}) {status} during security analysis.\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/actions/setup/js/checkout_pr_branch.cjs b/actions/setup/js/checkout_pr_branch.cjs index a3310b3568..45a09d806f 100644 --- a/actions/setup/js/checkout_pr_branch.cjs +++ b/actions/setup/js/checkout_pr_branch.cjs @@ -16,6 +16,7 @@ async function main() { if (!pullRequest) { core.info("No pull request context available, skipping checkout"); + core.setOutput("checkout_pr_success", "true"); return; } @@ -41,9 +42,15 @@ async function main() { core.info(`✅ Successfully checked out PR #${prNumber}`); } + + // Set output to indicate successful checkout + core.setOutput("checkout_pr_success", "true"); } catch (error) { const errorMsg = getErrorMessage(error); + // Set output to indicate checkout failure + core.setOutput("checkout_pr_success", "false"); + // Load and render step summary template const templatePath = "/opt/gh-aw/prompts/pr_checkout_failure.md"; const template = fs.readFileSync(templatePath, "utf8"); diff --git a/actions/setup/js/checkout_pr_branch.test.cjs b/actions/setup/js/checkout_pr_branch.test.cjs index c4deb67c05..5a27a90685 100644 --- a/actions/setup/js/checkout_pr_branch.test.cjs +++ b/actions/setup/js/checkout_pr_branch.test.cjs @@ -10,6 +10,7 @@ describe("checkout_pr_branch.cjs", () => { mockCore = { info: vi.fn(), setFailed: vi.fn(), + setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue(undefined), @@ -327,4 +328,40 @@ If the pull request is still open, verify that: expect(mockExec.exec).toHaveBeenCalledWith("git", ["fetch", "origin", longBranchName]); }); }); + + describe("checkout output", () => { + it("should set output to true on successful checkout (pull_request event)", async () => { + await runScript(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should set output to true on successful checkout (comment event)", async () => { + mockContext.eventName = "issue_comment"; + + await runScript(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + + it("should set output to false on checkout failure", async () => { + mockExec.exec.mockRejectedValueOnce(new Error("checkout failed")); + + await runScript(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "false"); + expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to checkout PR branch: checkout failed"); + }); + + it("should set output to true when no PR context", async () => { + mockContext.payload.pull_request = null; + + await runScript(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("checkout_pr_success", "true"); + expect(mockCore.setFailed).not.toHaveBeenCalled(); + }); + }); }); diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 31483ef4db..16162a21c4 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -252,11 +252,13 @@ async function main() { const secretVerificationResult = process.env.GH_AW_SECRET_VERIFICATION_RESULT || ""; const assignmentErrors = process.env.GH_AW_ASSIGNMENT_ERRORS || ""; const assignmentErrorCount = process.env.GH_AW_ASSIGNMENT_ERROR_COUNT || "0"; + const checkoutPRSuccess = process.env.GH_AW_CHECKOUT_PR_SUCCESS || ""; core.info(`Agent conclusion: ${agentConclusion}`); core.info(`Workflow name: ${workflowName}`); core.info(`Secret verification result: ${secretVerificationResult}`); core.info(`Assignment error count: ${assignmentErrorCount}`); + core.info(`Checkout PR success: ${checkoutPRSuccess}`); // Check if there are assignment errors (regardless of agent job status) const hasAssignmentErrors = parseInt(assignmentErrorCount, 10) > 0; @@ -279,6 +281,13 @@ async function main() { return; } + // Check if the failure was due to PR checkout (e.g., PR was merged and branch deleted) + // If checkout_pr_success is "false", skip creating an issue as this is expected behavior + if (agentConclusion === "failure" && checkoutPRSuccess === "false") { + core.info("Skipping failure handling - failure was due to PR checkout (likely PR merged)"); + return; + } + const { owner, repo } = context.repo; // Try to find a pull request for the current branch diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index 6fbd08aefe..048c4ea278 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -1111,4 +1111,96 @@ When prompted, instruct the agent to debug this workflow failure.`; expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found existing parent issue #5")); }); }); + + describe("checkout PR failure via output", () => { + it("should skip issue creation when checkout_pr_success is false", async () => { + // Set the checkout PR failure environment variable + process.env.GH_AW_CHECKOUT_PR_SUCCESS = "false"; + + await main(); + + // Verify that no issue was created + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Skipping failure handling - failure was due to PR checkout")); + }); + + it("should create issue when checkout_pr_success is true", async () => { + // Set the checkout PR success environment variable + process.env.GH_AW_CHECKOUT_PR_SUCCESS = "true"; + + mockGithub.rest.search.issuesAndPullRequests + .mockResolvedValueOnce({ + // First search: PR search (no PR found) + data: { total_count: 0, items: [] }, + }) + .mockResolvedValueOnce({ + // Second search: parent issue + data: { total_count: 0, items: [] }, + }) + .mockResolvedValueOnce({ + // Third search: failure issue + data: { total_count: 0, items: [] }, + }); + + mockGithub.rest.issues.create + .mockResolvedValueOnce({ + data: { number: 1, html_url: "https://example.com/1", node_id: "I_1" }, + }) + .mockResolvedValueOnce({ + data: { number: 2, html_url: "https://example.com/2", node_id: "I_2" }, + }); + + mockGithub.graphql = vi.fn().mockResolvedValue({ + addSubIssue: { + issue: { id: "I_1", number: 1 }, + subIssue: { id: "I_2", number: 2 }, + }, + }); + + await main(); + + // Verify issue was created + expect(mockGithub.rest.issues.create).toHaveBeenCalled(); + }); + + it("should create issue when checkout_pr_success is not set", async () => { + // Don't set GH_AW_CHECKOUT_PR_SUCCESS (workflow without PR checkout) + delete process.env.GH_AW_CHECKOUT_PR_SUCCESS; + + mockGithub.rest.search.issuesAndPullRequests + .mockResolvedValueOnce({ + // First search: PR search (no PR found) + data: { total_count: 0, items: [] }, + }) + .mockResolvedValueOnce({ + // Second search: parent issue + data: { total_count: 0, items: [] }, + }) + .mockResolvedValueOnce({ + // Third search: failure issue + data: { total_count: 0, items: [] }, + }); + + mockGithub.rest.issues.create + .mockResolvedValueOnce({ + data: { number: 1, html_url: "https://example.com/1", node_id: "I_1" }, + }) + .mockResolvedValueOnce({ + data: { number: 2, html_url: "https://example.com/2", node_id: "I_2" }, + }); + + mockGithub.graphql = vi.fn().mockResolvedValue({ + addSubIssue: { + issue: { id: "I_1", number: 1 }, + subIssue: { id: "I_2", number: 2 }, + }, + }); + + await main(); + + // Verify issue was created (normal failure handling) + expect(mockGithub.rest.issues.create).toHaveBeenCalled(); + }); + }); }); diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index bd4166bb7e..ccd80bc719 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -734,6 +734,12 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( outputs["has_patch"] = "${{ steps.collect_output.outputs.has_patch }}" } + // Add checkout_pr_success output to track PR checkout status + // This is used by the conclusion job to skip failure handling when checkout fails + // (e.g., when PR is merged and branch is deleted) + outputs["checkout_pr_success"] = "${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}" + compilerActivationJobsLog.Print("Added checkout_pr_success output") + // Build job-level environment variables for safe outputs var env map[string]string if data.SafeOutputs != nil { diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 19e23f3c97..17a7cc44f7 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -162,6 +162,9 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.%s.outputs.secret_verification_result }}\n", mainJobName)) } + // Add checkout_pr_success to detect PR checkout failures (e.g., PR merged and branch deleted) + agentFailureEnvVars = append(agentFailureEnvVars, fmt.Sprintf(" GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.%s.outputs.checkout_pr_success }}\n", mainJobName)) + // Pass assignment error outputs from safe_outputs job if assign-to-agent is configured if data.SafeOutputs != nil && data.SafeOutputs.AssignToAgent != nil { agentFailureEnvVars = append(agentFailureEnvVars, " GH_AW_ASSIGNMENT_ERRORS: ${{ needs.safe_outputs.outputs.assign_to_agent_assignment_errors }}\n") diff --git a/pkg/workflow/pr.go b/pkg/workflow/pr.go index 4d18e77ce9..63a094827c 100644 --- a/pkg/workflow/pr.go +++ b/pkg/workflow/pr.go @@ -25,6 +25,7 @@ func (c *Compiler) generatePRReadyForReviewCheckout(yaml *strings.Builder, data // Always add the step with a condition that checks if PR context is available yaml.WriteString(" - name: Checkout PR branch\n") + yaml.WriteString(" id: checkout-pr\n") // Build condition that checks if github.event.pull_request exists // This will be true for pull_request events and comment events on PRs