From 30134a61e056d0d7f51f0c7eba0a3f9250a2e7f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:10:23 +0000 Subject: [PATCH 1/4] Initial plan From 6e8b9aecbe2aed1872215bef73a663b1f9ac1ba7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:18:09 +0000 Subject: [PATCH 2/4] Add handle_noop_message script and update handle_agent_failure to skip noop-only scenarios Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/handle_agent_failure.cjs | 15 ++ actions/setup/js/handle_noop_message.cjs | 175 +++++++++++++ actions/setup/js/handle_noop_message.test.cjs | 239 ++++++++++++++++++ pkg/workflow/notify_comment.go | 23 ++ 4 files changed, 452 insertions(+) create mode 100644 actions/setup/js/handle_noop_message.cjs create mode 100644 actions/setup/js/handle_noop_message.test.cjs diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 1db43e27807..4b1bce8251b 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -357,6 +357,7 @@ async function main() { // Check if agent succeeded but produced no safe outputs let hasMissingSafeOutputs = false; + let hasOnlyNoopOutputs = false; if (agentConclusion === "success") { const { loadAgentOutput } = require("./load_agent_output.cjs"); const agentOutputResult = loadAgentOutput(); @@ -364,15 +365,29 @@ async function main() { if (!agentOutputResult.success || !agentOutputResult.items || agentOutputResult.items.length === 0) { hasMissingSafeOutputs = true; core.info("Agent succeeded but produced no safe outputs"); + } else { + // Check if all outputs are noop types + const nonNoopItems = agentOutputResult.items.filter(item => item.type !== "noop"); + if (nonNoopItems.length === 0) { + hasOnlyNoopOutputs = true; + core.info("Agent succeeded with only noop outputs - this is not a failure"); + } } } // Only proceed if the agent job actually failed OR there are assignment errors OR create_discussion errors OR missing safe outputs + // BUT skip if we only have noop outputs (that's a successful no-action scenario) if (agentConclusion !== "failure" && !hasAssignmentErrors && !hasCreateDiscussionErrors && !hasMissingSafeOutputs) { core.info(`Agent job did not fail and no assignment/discussion errors and has safe outputs (conclusion: ${agentConclusion}), skipping failure handling`); return; } + // If we only have noop outputs, skip failure handling - this is a successful no-action scenario + if (hasOnlyNoopOutputs) { + core.info("Agent completed with only noop outputs - skipping failure handling"); + 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") { diff --git a/actions/setup/js/handle_noop_message.cjs b/actions/setup/js/handle_noop_message.cjs new file mode 100644 index 00000000000..8b3009a2f24 --- /dev/null +++ b/actions/setup/js/handle_noop_message.cjs @@ -0,0 +1,175 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); +const { sanitizeContent } = require("./sanitize_content.cjs"); +const { generateFooterWithExpiration } = require("./ephemerals.cjs"); +const { renderTemplate } = require("./messages_core.cjs"); + +/** + * Search for or create the parent issue for all agentic workflow no-op runs + * @returns {Promise<{number: number, node_id: string}>} Parent issue number and node ID + */ +async function ensureAgentRunsIssue() { + const { owner, repo } = context.repo; + const parentTitle = "[agentic-workflows] Agent runs"; + const parentLabel = "agentic-workflows"; + + core.info(`Searching for agent runs issue: "${parentTitle}"`); + + // Search for existing agent runs issue + const searchQuery = `repo:${owner}/${repo} is:issue is:open label:${parentLabel} in:title "${parentTitle}"`; + + try { + const searchResult = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }); + + if (searchResult.data.total_count > 0) { + const existingIssue = searchResult.data.items[0]; + core.info(`Found existing agent runs issue #${existingIssue.number}: ${existingIssue.html_url}`); + + return { + number: existingIssue.number, + node_id: existingIssue.node_id, + }; + } + } catch (error) { + core.warning(`Error searching for agent runs issue: ${getErrorMessage(error)}`); + } + + // Create agent runs issue if it doesn't exist + core.info(`No agent runs issue found, creating one`); + + let parentBodyContent = `This issue tracks all no-op runs from agentic workflows in this repository. Each workflow run that completes with a no-op message (indicating no action was needed) posts a comment here. + +### Purpose + +This issue helps you: +- Track workflows that ran but determined no action was needed +- Distinguish between failures and intentional no-ops +- Monitor workflow health by seeing when workflows decide not to act + +### What is a No-Op? + +A no-op (no operation) occurs when an agentic workflow runs successfully but determines that no action is required. For example: +- A security scanner that finds no issues +- An update checker that finds nothing to update +- A monitoring workflow that finds everything is healthy + +These are successful outcomes, not failures, and help provide transparency into workflow behavior. + +### Resources + +- [GitHub Agentic Workflows Documentation](https://github.com/github/gh-aw) +- [Safe Outputs Reference](https://github.com/github/gh-aw/blob/main/docs/reference/safe-outputs.md) + +--- + +> This issue is automatically managed by GitHub Agentic Workflows. Do not close this issue manually.`; + + // Add expiration marker (30 days from now) inside the quoted section using helper + const footer = generateFooterWithExpiration({ + footerText: parentBodyContent, + expiresHours: 24 * 30, // 30 days + }); + const parentBody = footer; + + try { + const newIssue = await github.rest.issues.create({ + owner, + repo, + title: parentTitle, + body: parentBody, + labels: [parentLabel], + }); + + core.info(`✓ Created agent runs issue #${newIssue.data.number}: ${newIssue.data.html_url}`); + return { + number: newIssue.data.number, + node_id: newIssue.data.node_id, + }; + } catch (error) { + core.error(`Failed to create agent runs issue: ${getErrorMessage(error)}`); + throw error; + } +} + +/** + * Handle posting a no-op message to the agent runs issue + * This script is called from the conclusion job when the agent produced only a noop safe-output + */ +async function main() { + try { + // Get workflow context + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "unknown"; + const runUrl = process.env.GH_AW_RUN_URL || ""; + const noopMessage = process.env.GH_AW_NOOP_MESSAGE || ""; + + core.info(`Workflow name: ${workflowName}`); + core.info(`Run URL: ${runUrl}`); + core.info(`No-op message: ${noopMessage}`); + + if (!noopMessage) { + core.info("No no-op message found, skipping"); + return; + } + + const { owner, repo } = context.repo; + + // Ensure agent runs issue exists + let agentRunsIssue; + try { + agentRunsIssue = await ensureAgentRunsIssue(); + } catch (error) { + core.warning(`Could not create agent runs issue: ${getErrorMessage(error)}`); + // Don't fail the workflow if we can't create the issue + return; + } + + // Extract run ID from URL (e.g., https://github.com/owner/repo/actions/runs/123 -> "123") + let runId = ""; + const runIdMatch = runUrl.match(/\/actions\/runs\/(\d+)/); + if (runIdMatch) { + runId = runIdMatch[1]; + } + + // Build the comment body + const timestamp = new Date().toISOString(); + let commentBody = `### No-Op Run: ${sanitizeContent(workflowName)} + +**Run ID:** [${runId}](${runUrl}) +**Timestamp:** ${timestamp} + +**Message:** + +${sanitizeContent(noopMessage)} + +--- + +*This workflow completed successfully with no action required.*`; + + // Sanitize the full comment body + const fullCommentBody = sanitizeContent(commentBody, { maxLength: 65000 }); + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: agentRunsIssue.number, + body: fullCommentBody, + }); + + core.info(`✓ Posted no-op message to agent runs issue #${agentRunsIssue.number}`); + } catch (error) { + core.warning(`Failed to post comment to agent runs issue: ${getErrorMessage(error)}`); + // Don't fail the workflow + } + } catch (error) { + core.warning(`Error in handle_noop_message: ${getErrorMessage(error)}`); + // Don't fail the workflow + } +} + +module.exports = { main, ensureAgentRunsIssue }; diff --git a/actions/setup/js/handle_noop_message.test.cjs b/actions/setup/js/handle_noop_message.test.cjs new file mode 100644 index 00000000000..c76b5d39361 --- /dev/null +++ b/actions/setup/js/handle_noop_message.test.cjs @@ -0,0 +1,239 @@ +// @ts-check + +const { describe, it, expect, beforeEach, vi } = require("vitest"); + +describe("handle_noop_message", () => { + let mockCore; + let mockGithub; + let mockContext; + let originalEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Mock core + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + + // Mock GitHub API + mockGithub = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + issues: { + create: vi.fn(), + createComment: vi.fn(), + }, + }, + }; + + // Mock context + mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + }; + + // Setup globals + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + }); + + afterEach(() => { + // Restore environment + process.env = originalEnv; + vi.clearAllMocks(); + }); + + it("should skip if no noop message is present", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = ""; + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No no-op message found, skipping")); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("should create agent runs issue if it doesn't exist", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123456"; + process.env.GH_AW_NOOP_MESSAGE = "No updates needed"; + + // Mock search to return no results + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 0, + items: [], + }, + }); + + // Mock issue creation + mockGithub.rest.issues.create.mockResolvedValue({ + data: { + number: 42, + node_id: "MDU6SXNzdWU0Mg==", + html_url: "https://github.com/test-owner/test-repo/issues/42", + }, + }); + + // Mock comment creation + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 1, + html_url: "https://github.com/test-owner/test-repo/issues/42#issuecomment-1", + }, + }); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + // Verify search was performed + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: expect.stringContaining("[agentic-workflows] Agent runs"), + per_page: 1, + }); + + // Verify issue was created with correct title + const createCall = mockGithub.rest.issues.create.mock.calls[0][0]; + expect(createCall.title).toBe("[agentic-workflows] Agent runs"); + expect(createCall.labels).toContain("agentic-workflows"); + expect(createCall.body).toContain("tracks all no-op runs"); + + // Verify comment was posted + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.issue_number).toBe(42); + expect(commentCall.body).toContain("Test Workflow"); + expect(commentCall.body).toContain("No updates needed"); + expect(commentCall.body).toContain("123456"); + }); + + it("should use existing agent runs issue if it exists", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Another Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/789"; + process.env.GH_AW_NOOP_MESSAGE = "Everything is up to date"; + + // Mock search to return existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 99, + node_id: "MDU6SXNzdWU5OQ==", + html_url: "https://github.com/test-owner/test-repo/issues/99", + }, + ], + }, + }); + + // Mock comment creation + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 2, + html_url: "https://github.com/test-owner/test-repo/issues/99#issuecomment-2", + }, + }); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + // Verify issue was not created + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + + // Verify comment was posted to existing issue + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.issue_number).toBe(99); + expect(commentCall.body).toContain("Another Workflow"); + expect(commentCall.body).toContain("Everything is up to date"); + }); + + it("should handle comment creation failure gracefully", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/456"; + process.env.GH_AW_NOOP_MESSAGE = "No action required"; + + // Mock existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ number: 10, node_id: "MDU6SXNzdWUxMA==", html_url: "https://github.com/test-owner/test-repo/issues/10" }], + }, + }); + + // Mock comment creation failure + mockGithub.rest.issues.createComment.mockRejectedValue(new Error("API rate limit exceeded")); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + // Verify warning was logged but workflow didn't fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to post comment")); + }); + + it("should handle issue creation failure gracefully", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/789"; + process.env.GH_AW_NOOP_MESSAGE = "All checks passed"; + + // Mock no existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + + // Mock issue creation failure + mockGithub.rest.issues.create.mockRejectedValue(new Error("Insufficient permissions")); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + // Verify warning was logged but workflow didn't fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not create agent runs issue")); + }); + + it("should extract run ID from URL correctly", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test"; + process.env.GH_AW_RUN_URL = "https://github.com/owner/repo/actions/runs/987654321"; + process.env.GH_AW_NOOP_MESSAGE = "Done"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.body).toContain("987654321"); + }); + + it("should sanitize workflow name in comment", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test/test/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = "Clean"; + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const { main } = require("./handle_noop_message.cjs"); + await main(); + + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + // Verify XSS attempt was sanitized (specific behavior depends on sanitizeContent implementation) + expect(commentCall.body).not.toContain(" Workflow"; process.env.GH_AW_RUN_URL = "https://github.com/test/test/actions/runs/123"; process.env.GH_AW_NOOP_MESSAGE = "Clean"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + + // Mock loadAgentOutput to return only noop outputs + mockLoadAgentOutput.mockReturnValue({ + success: true, + items: [{ type: "noop", message: "Clean" }], + }); mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, @@ -229,7 +317,7 @@ describe("handle_noop_message", () => { mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); - const { main } = await import("./handle_noop_message.cjs"); + const { main } = await import("./handle_noop_message.cjs?t=" + Date.now()); await main(); const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; diff --git a/actions/setup/js/handle_noop_message.test.cjs.bak b/actions/setup/js/handle_noop_message.test.cjs.bak new file mode 100644 index 00000000000..1879aea3b90 --- /dev/null +++ b/actions/setup/js/handle_noop_message.test.cjs.bak @@ -0,0 +1,397 @@ +// @ts-check + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// Mock load_agent_output at the module level +vi.mock("./load_agent_output.cjs", () => ({ + loadAgentOutput: vi.fn(), +})); + +describe("handle_noop_message", () => { + let mockCore; + let mockGithub; + let mockContext; + let originalEnv; + let mockLoadAgentOutput; + + beforeEach(async () => { + // Save original environment + originalEnv = { ...process.env }; + + // Get the mocked loadAgentOutput + const loadModule = await import("./load_agent_output.cjs"); + mockLoadAgentOutput = loadModule.loadAgentOutput; + + // Mock core + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }; + + // Mock GitHub API + mockGithub = { + rest: { + search: { + issuesAndPullRequests: vi.fn(), + }, + issues: { + create: vi.fn(), + createComment: vi.fn(), + }, + }, + }; + + // Mock context + mockContext = { + repo: { + owner: "test-owner", + repo: "test-repo", + }, + }; + + // Setup globals + global.core = mockCore; + global.github = mockGithub; + global.context = mockContext; + }); + + afterEach(() => { + // Restore environment + process.env = originalEnv; + vi.clearAllMocks(); + }); + + it("should skip if no noop message is present", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = ""; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No no-op message found, skipping")); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("should skip if agent did not succeed", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = "Some message"; + process.env.GH_AW_AGENT_CONCLUSION = "failure"; + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Agent did not succeed")); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("should skip if there are non-noop outputs", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = "Some message"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return noop + other outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [ + { type: "noop", message: "No action needed" }, + { type: "create_issue", title: "Some issue" }, + ], + }); + } + return originalReadFileSync(filePath); + }); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Found 1 non-noop output(s)")); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should create agent runs issue if it doesn't exist", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/123456"; + process.env.GH_AW_NOOP_MESSAGE = "No updates needed"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "No updates needed" }], + }); + } + return originalReadFileSync(filePath); + }); + + // Mock search to return no results + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 0, + items: [], + }, + }); + + // Mock issue creation + mockGithub.rest.issues.create.mockResolvedValue({ + data: { + number: 42, + node_id: "MDU6SXNzdWU0Mg==", + html_url: "https://github.com/test-owner/test-repo/issues/42", + }, + }); + + // Mock comment creation + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 1, + html_url: "https://github.com/test-owner/test-repo/issues/42#issuecomment-1", + }, + }); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + // Verify search was performed + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: expect.stringContaining("[agentic-workflows] Agent runs"), + per_page: 1, + }); + + // Verify issue was created with correct title + const createCall = mockGithub.rest.issues.create.mock.calls[0][0]; + expect(createCall.title).toBe("[agentic-workflows] Agent runs"); + expect(createCall.labels).toContain("agentic-workflows"); + expect(createCall.body).toContain("tracks all no-op runs"); + + // Verify comment was posted + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.issue_number).toBe(42); + expect(commentCall.body).toContain("Test Workflow"); + expect(commentCall.body).toContain("No updates needed"); + expect(commentCall.body).toContain("123456"); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should use existing agent runs issue if it exists", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Another Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/789"; + process.env.GH_AW_NOOP_MESSAGE = "Everything is up to date"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "Everything is up to date" }], + }); + } + return originalReadFileSync(filePath); + }); + + // Mock search to return existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 99, + node_id: "MDU6SXNzdWU5OQ==", + html_url: "https://github.com/test-owner/test-repo/issues/99", + }, + ], + }, + }); + + // Mock comment creation + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 2, + html_url: "https://github.com/test-owner/test-repo/issues/99#issuecomment-2", + }, + }); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + // Verify issue was not created + expect(mockGithub.rest.issues.create).not.toHaveBeenCalled(); + + // Verify comment was posted to existing issue + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.issue_number).toBe(99); + expect(commentCall.body).toContain("Another Workflow"); + expect(commentCall.body).toContain("Everything is up to date"); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should handle comment creation failure gracefully", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/456"; + process.env.GH_AW_NOOP_MESSAGE = "No action required"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "No action required" }], + }); + } + return originalReadFileSync(filePath); + }); + + // Mock existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + total_count: 1, + items: [{ number: 10, node_id: "MDU6SXNzdWUxMA==", html_url: "https://github.com/test-owner/test-repo/issues/10" }], + }, + }); + + // Mock comment creation failure + mockGithub.rest.issues.createComment.mockRejectedValue(new Error("API rate limit exceeded")); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + // Verify warning was logged but workflow didn't fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to post comment")); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should handle issue creation failure gracefully", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test-owner/test-repo/actions/runs/789"; + process.env.GH_AW_NOOP_MESSAGE = "All checks passed"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "All checks passed" }], + }); + } + return originalReadFileSync(filePath); + }); + + // Mock no existing issue + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 0, items: [] }, + }); + + // Mock issue creation failure + mockGithub.rest.issues.create.mockRejectedValue(new Error("Insufficient permissions")); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + // Verify warning was logged but workflow didn't fail + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not create agent runs issue")); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should extract run ID from URL correctly", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test"; + process.env.GH_AW_RUN_URL = "https://github.com/owner/repo/actions/runs/987654321"; + process.env.GH_AW_NOOP_MESSAGE = "Done"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "Done" }], + }); + } + return originalReadFileSync(filePath); + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + expect(commentCall.body).toContain("987654321"); + + // Restore + fs.readFileSync = originalReadFileSync; + }); + + it("should sanitize workflow name in comment", async () => { + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_RUN_URL = "https://github.com/test/test/actions/runs/123"; + process.env.GH_AW_NOOP_MESSAGE = "Clean"; + process.env.GH_AW_AGENT_CONCLUSION = "success"; + process.env.GH_AW_AGENT_OUTPUT = "/tmp/agent_output.json"; + + // Mock loadAgentOutput to return only noop outputs + const fs = require("fs"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = vi.fn(filePath => { + if (filePath === "/tmp/agent_output.json") { + return JSON.stringify({ + items: [{ type: "noop", message: "Clean" }], + }); + } + return originalReadFileSync(filePath); + }); + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { total_count: 1, items: [{ number: 1, node_id: "ID", html_url: "url" }] }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ data: {} }); + + const { main } = await import("./handle_noop_message.cjs"); + await main(); + + const commentCall = mockGithub.rest.issues.createComment.mock.calls[0][0]; + // Verify XSS attempt was sanitized (specific behavior depends on sanitizeContent implementation) + expect(commentCall.body).not.toContain("