diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index da0212aa524..0098654360c 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -58,6 +58,7 @@ # create-issue: # add-labels: # allowed: [smoke-codex] +# minimize-comment: # messages: # footer: "> 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*" # run-started: "🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}..." @@ -76,6 +77,7 @@ # conclusion["conclusion"] # create_issue["create_issue"] # detection["detection"] +# minimize_comment["minimize_comment"] # pre_activation["pre_activation"] # update_cache_memory["update_cache_memory"] # activation --> agent @@ -87,6 +89,7 @@ # agent --> conclusion # agent --> create_issue # agent --> detection +# agent --> minimize_comment # agent --> update_cache_memory # create_issue --> add_comment # create_issue --> conclusion @@ -94,7 +97,9 @@ # detection --> add_labels # detection --> conclusion # detection --> create_issue +# detection --> minimize_comment # detection --> update_cache_memory +# minimize_comment --> conclusion # pre_activation --> activation # update_cache_memory --> conclusion # ``` @@ -2098,6 +2103,23 @@ jobs: "type": "object" }, "name": "noop" + }, + { + "description": "Minimize (hide) a comment on a GitHub issue, pull request, or discussion. This collapses the comment as spam or off-topic. Use this for inappropriate, off-topic, or outdated comments. The comment_id must be a GraphQL node ID (string like 'IC_kwDOABCD123456'), not a numeric REST API comment ID.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "comment_id": { + "description": "GraphQL node ID of the comment to minimize (e.g., 'IC_kwDOABCD123456'). This is the GraphQL node ID, not the numeric comment ID from REST API. Can be obtained from GraphQL queries or comment API responses.", + "type": "string" + } + }, + "required": [ + "comment_id" + ], + "type": "object" + }, + "name": "minimize_comment" } ] EOF @@ -6415,6 +6437,7 @@ jobs: - agent - create_issue - detection + - minimize_comment - update_cache_memory if: ((always()) && (needs.agent.result != 'skipped')) && (!(needs.add_comment.outputs.comment_id)) runs-on: ubuntu-slim @@ -7732,6 +7755,156 @@ jobs: path: /tmp/gh-aw/threat-detection/detection.log if-no-files-found: ignore + minimize_comment: + needs: + - agent + - detection + if: > + (((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'minimize_comment'))) && + (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 10 + outputs: + comment_id: ${{ steps.minimize_comment.outputs.comment_id }} + is_minimized: ${{ steps.minimize_comment.outputs.is_minimized }} + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Minimize Comment + id: minimize_comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_MINIMIZE_COMMENT_MAX_COUNT: 5 + GH_AW_WORKFLOW_NAME: "Smoke Codex" + GH_AW_ENGINE_ID: "codex" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*\",\"runStarted\":\"🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}...\",\"runSuccess\":\"✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟\",\"runFailure\":\"🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation...\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); + } catch (error) { + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + async function minimizeComment(github, nodeId) { + const query = ` + mutation ($nodeId: ID!) { + minimizeComment(input: { subjectId: $nodeId, classifier: SPAM }) { + minimizedComment { + isMinimized + } + } + } + `; + const result = await github.graphql(query, { nodeId }); + return { + id: nodeId, + isMinimized: result.minimizeComment.minimizedComment.isMinimized, + }; + } + async function main() { + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + return; + } + const minimizeCommentItems = result.items.filter( item => item.type === "minimize_comment"); + if (minimizeCommentItems.length === 0) { + core.info("No minimize-comment items found in agent output"); + return; + } + core.info(`Found ${minimizeCommentItems.length} minimize-comment item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Minimize Comments Preview\n\n"; + summaryContent += "The following comments would be minimized if staged mode was disabled:\n\n"; + for (let i = 0; i < minimizeCommentItems.length; i++) { + const item = minimizeCommentItems[i]; + summaryContent += `### Comment ${i + 1}\n`; + summaryContent += `**Node ID**: ${item.comment_id}\n`; + summaryContent += `**Action**: Would be minimized as SPAM\n`; + summaryContent += "\n"; + } + core.summary.addRaw(summaryContent).write(); + return; + } + for (const item of minimizeCommentItems) { + try { + const commentId = item.comment_id; + if (!commentId || typeof commentId !== "string") { + throw new Error("comment_id is required and must be a string (GraphQL node ID)"); + } + core.info(`Minimizing comment: ${commentId}`); + const minimizeResult = await minimizeComment(github, commentId); + if (minimizeResult.isMinimized) { + core.info(`Successfully minimized comment: ${commentId}`); + core.setOutput("comment_id", commentId); + core.setOutput("is_minimized", "true"); + } else { + throw new Error(`Failed to minimize comment: ${commentId}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to minimize comment: ${errorMessage}`); + core.setFailed(`Failed to minimize comment: ${errorMessage}`); + return; + } + } + } + await main(); + pre_activation: if: > ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md index 9a5f4783721..7574f2be2e1 100644 --- a/.github/workflows/smoke-codex.md +++ b/.github/workflows/smoke-codex.md @@ -35,6 +35,7 @@ safe-outputs: create-issue: add-labels: allowed: [smoke-codex] + minimize-comment: messages: footer: "> 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*" run-started: "🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}..." diff --git a/pkg/cli/workflows/test-claude-minimize-comment.md b/pkg/cli/workflows/test-claude-minimize-comment.md new file mode 100644 index 00000000000..089a812f251 --- /dev/null +++ b/pkg/cli/workflows/test-claude-minimize-comment.md @@ -0,0 +1,19 @@ +--- +on: + workflow_dispatch: +engine: claude +safe-outputs: + minimize-comment: + max: 3 +timeout-minutes: 5 +--- + +# Test Claude Minimize Comment + +This is a test workflow to verify that Claude can minimize (hide) comments on GitHub issues. + +Test the minimize_comment safe output by minimizing a comment with the following node ID: + +- comment_id: "IC_kwDOABCD123456" + +Output the minimize-comment action as JSONL format using the minimize_comment tool. diff --git a/pkg/cli/workflows/test-codex-minimize-comment.md b/pkg/cli/workflows/test-codex-minimize-comment.md new file mode 100644 index 00000000000..249cf394fbe --- /dev/null +++ b/pkg/cli/workflows/test-codex-minimize-comment.md @@ -0,0 +1,19 @@ +--- +on: + workflow_dispatch: +engine: codex +safe-outputs: + minimize-comment: + max: 3 +timeout-minutes: 5 +--- + +# Test Codex Minimize Comment + +This is a test workflow to verify that Codex can minimize (hide) comments on GitHub issues. + +Test the minimize_comment safe output by minimizing a comment with the following node ID: + +- comment_id: "IC_kwDOABCD123456" + +Output the minimize-comment action as JSONL format using the minimize_comment tool. diff --git a/pkg/cli/workflows/test-copilot-minimize-comment.md b/pkg/cli/workflows/test-copilot-minimize-comment.md new file mode 100644 index 00000000000..97b9c56e928 --- /dev/null +++ b/pkg/cli/workflows/test-copilot-minimize-comment.md @@ -0,0 +1,19 @@ +--- +on: + workflow_dispatch: +engine: copilot +safe-outputs: + minimize-comment: + max: 3 +timeout-minutes: 5 +--- + +# Test Copilot Minimize Comment + +This is a test workflow to verify that Copilot can minimize (hide) comments on GitHub issues. + +Test the minimize_comment safe output by minimizing a comment with the following node ID: + +- comment_id: "IC_kwDOABCD123456" + +Output the minimize-comment action as JSONL format using the minimize_comment tool. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index f3f90ec282d..4e5dfb424c4 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2988,7 +2988,7 @@ "safe-outputs": { "type": "object", "description": "Safe output processing configuration that automatically creates GitHub issues, comments, and pull requests from AI workflow output without requiring write permissions in the main job", - "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-task, create-code-scanning-alert, create-discussion, create-issue, create-pull-request, create-pull-request-review-comment, link-sub-issue, missing-tool, noop, push-to-pull-request-branch, threat-detection, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", + "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-task, create-code-scanning-alert, create-discussion, create-issue, create-pull-request, create-pull-request-review-comment, link-sub-issue, minimize-comment, missing-tool, noop, push-to-pull-request-branch, threat-detection, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", "properties": { "allowed-domains": { "type": "array", @@ -4013,6 +4013,35 @@ } ] }, + "minimize-comment": { + "oneOf": [ + { + "type": "null", + "description": "Enable comment minimization with default configuration" + }, + { + "type": "object", + "description": "Configuration for minimizing (hiding) comments on GitHub issues, pull requests, or discussions from agentic workflow output", + "properties": { + "max": { + "type": "integer", + "description": "Maximum number of comments to minimize (default: 5)", + "minimum": 1, + "maximum": 100 + }, + "target-repo": { + "type": "string", + "description": "Target repository in format 'owner/repo' for cross-repository comment minimization. Takes precedence over trial target repo settings." + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + } + }, + "additionalProperties": false + } + ] + }, "missing-tool": { "oneOf": [ { diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 50162994eb2..7868099008a 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -657,6 +657,24 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat safeOutputJobNames = append(safeOutputJobNames, linkSubIssueJob.Name) } + // Build minimize_comment job if safe-outputs.minimize-comment is configured + if data.SafeOutputs.MinimizeComment != nil { + minimizeCommentJob, err := c.buildMinimizeCommentJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build minimize_comment job: %w", err) + } + // Safe-output jobs should depend on agent job (always) AND detection job (if enabled) + if threatDetectionEnabled { + minimizeCommentJob.Needs = append(minimizeCommentJob.Needs, constants.DetectionJobName) + // Add detection success check to the job condition + minimizeCommentJob.If = AddDetectionSuccessCheck(minimizeCommentJob.If) + } + if err := c.jobManager.AddJob(minimizeCommentJob); err != nil { + return fmt.Errorf("failed to add minimize_comment job: %w", err) + } + safeOutputJobNames = append(safeOutputJobNames, minimizeCommentJob.Name) + } + // Build create_agent_task job if output.create-agent-task is configured if data.SafeOutputs.CreateAgentTasks != nil { createAgentTaskJob, err := c.buildCreateOutputAgentTaskJob(data, jobName) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index d5bc36cda88..8063b0dff6c 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -255,6 +255,7 @@ type SafeOutputsConfig struct { CreateAgentTasks *CreateAgentTaskConfig `yaml:"create-agent-task,omitempty"` // Create GitHub Copilot agent tasks UpdateProjects *UpdateProjectConfig `yaml:"update-project,omitempty"` // Smart project board management (create/add/update) LinkSubIssue *LinkSubIssueConfig `yaml:"link-sub-issue,omitempty"` // Link issues as sub-issues + MinimizeComment *MinimizeCommentConfig `yaml:"minimize-comment,omitempty"` // Minimize (hide) comments MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality NoOp *NoOpConfig `yaml:"noop,omitempty"` // No-op output for logging only (always available as fallback) ThreatDetection *ThreatDetectionConfig `yaml:"threat-detection,omitempty"` // Threat detection configuration diff --git a/pkg/workflow/js/minimize_comment.cjs b/pkg/workflow/js/minimize_comment.cjs new file mode 100644 index 00000000000..575110ed001 --- /dev/null +++ b/pkg/workflow/js/minimize_comment.cjs @@ -0,0 +1,95 @@ +// @ts-check +/// + +const { loadAgentOutput } = require("./load_agent_output.cjs"); + +/** + * Minimize (hide) a comment using the GraphQL API. + * @param {any} github - GitHub GraphQL instance + * @param {string} nodeId - Comment node ID (e.g., 'IC_kwDOABCD123456') + * @returns {Promise<{id: string, isMinimized: boolean}>} Minimized comment details + */ +async function minimizeComment(github, nodeId) { + const query = /* GraphQL */ ` + mutation ($nodeId: ID!) { + minimizeComment(input: { subjectId: $nodeId, classifier: SPAM }) { + minimizedComment { + isMinimized + } + } + } + `; + + const result = await github.graphql(query, { nodeId }); + + return { + id: nodeId, + isMinimized: result.minimizeComment.minimizedComment.isMinimized, + }; +} + +async function main() { + // Check if we're in staged mode + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + + const result = loadAgentOutput(); + if (!result.success) { + return; + } + + // Find all minimize-comment items + const minimizeCommentItems = result.items.filter(/** @param {any} item */ item => item.type === "minimize_comment"); + if (minimizeCommentItems.length === 0) { + core.info("No minimize-comment items found in agent output"); + return; + } + + core.info(`Found ${minimizeCommentItems.length} minimize-comment item(s)`); + + // If in staged mode, emit step summary instead of minimizing comments + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Minimize Comments Preview\n\n"; + summaryContent += "The following comments would be minimized if staged mode was disabled:\n\n"; + + for (let i = 0; i < minimizeCommentItems.length; i++) { + const item = minimizeCommentItems[i]; + summaryContent += `### Comment ${i + 1}\n`; + summaryContent += `**Node ID**: ${item.comment_id}\n`; + summaryContent += `**Action**: Would be minimized as SPAM\n`; + summaryContent += "\n"; + } + + core.summary.addRaw(summaryContent).write(); + return; + } + + // Process each minimize-comment item + for (const item of minimizeCommentItems) { + try { + const commentId = item.comment_id; + if (!commentId || typeof commentId !== "string") { + throw new Error("comment_id is required and must be a string (GraphQL node ID)"); + } + + core.info(`Minimizing comment: ${commentId}`); + + const minimizeResult = await minimizeComment(github, commentId); + + if (minimizeResult.isMinimized) { + core.info(`Successfully minimized comment: ${commentId}`); + core.setOutput("comment_id", commentId); + core.setOutput("is_minimized", "true"); + } else { + throw new Error(`Failed to minimize comment: ${commentId}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to minimize comment: ${errorMessage}`); + core.setFailed(`Failed to minimize comment: ${errorMessage}`); + return; + } + } +} + +// Call the main function +await main(); diff --git a/pkg/workflow/js/minimize_comment.test.cjs b/pkg/workflow/js/minimize_comment.test.cjs new file mode 100644 index 00000000000..3a3e9a10dfd --- /dev/null +++ b/pkg/workflow/js/minimize_comment.test.cjs @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import fs from "fs"; +import path from "path"; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + debug: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(), + }, +}; + +const mockGithub = { + rest: {}, + graphql: vi.fn(), +}; + +const mockContext = { + eventName: "issue_comment", + runId: 12345, + repo: { + owner: "testowner", + repo: "testrepo", + }, + payload: { + issue: { + number: 42, + }, + repository: { + html_url: "https://github.com/testowner/testrepo", + }, + }, +}; + +// Set up global mocks before importing the module +global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +describe("minimize_comment", () => { + let minimizeCommentScript; + let tempFilePath; + + // Helper function to set agent output via file + const setAgentOutput = data => { + tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`); + const content = typeof data === "string" ? data : JSON.stringify(data); + fs.writeFileSync(tempFilePath, content); + process.env.GH_AW_AGENT_OUTPUT = tempFilePath; + }; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Reset environment variables + delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; + delete process.env.GH_AW_AGENT_OUTPUT; + delete process.env.GITHUB_SERVER_URL; + + // Reset context to default state + global.context.eventName = "issue_comment"; + global.context.payload.issue = { number: 42 }; + + // Read the script content + const scriptPath = path.join(process.cwd(), "minimize_comment.cjs"); + minimizeCommentScript = fs.readFileSync(scriptPath, "utf8"); + }); + + afterEach(() => { + // Clean up temp files + if (tempFilePath && fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + tempFilePath = undefined; + } + }); + + it("should handle empty agent output", async () => { + setAgentOutput({ items: [], errors: [] }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.info).toHaveBeenCalledWith("No minimize-comment items found in agent output"); + }); + + it("should handle missing agent output", async () => { + // Don't set GH_AW_AGENT_OUTPUT + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"); + }); + + it("should minimize a comment successfully", async () => { + const commentNodeId = "IC_kwDOABCD123456"; + + setAgentOutput({ + items: [ + { + type: "minimize_comment", + comment_id: commentNodeId, + }, + ], + errors: [], + }); + + // Mock GraphQL response for minimize comment + mockGithub.graphql.mockResolvedValueOnce({ + minimizeComment: { + minimizedComment: { + isMinimized: true, + }, + }, + }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.info).toHaveBeenCalledWith("Found 1 minimize-comment item(s)"); + expect(mockCore.info).toHaveBeenCalledWith(`Minimizing comment: ${commentNodeId}`); + expect(mockCore.info).toHaveBeenCalledWith(`Successfully minimized comment: ${commentNodeId}`); + expect(mockGithub.graphql).toHaveBeenCalledWith( + expect.stringContaining("minimizeComment"), + expect.objectContaining({ nodeId: commentNodeId }) + ); + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", commentNodeId); + expect(mockCore.setOutput).toHaveBeenCalledWith("is_minimized", "true"); + }); + + it("should handle GraphQL errors", async () => { + const commentNodeId = "IC_kwDOABCD123456"; + + setAgentOutput({ + items: [ + { + type: "minimize_comment", + comment_id: commentNodeId, + }, + ], + errors: [], + }); + + // Mock GraphQL error + const errorMessage = "Comment not found"; + mockGithub.graphql.mockRejectedValueOnce(new Error(errorMessage)); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining(errorMessage)); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining(errorMessage)); + }); + + it("should preview minimization in staged mode", async () => { + process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"; + + const commentNodeId = "IC_kwDOABCD123456"; + + setAgentOutput({ + items: [ + { + type: "minimize_comment", + comment_id: commentNodeId, + }, + ], + errors: [], + }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.info).toHaveBeenCalledWith("Found 1 minimize-comment item(s)"); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("Staged Mode: Minimize Comments Preview")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining(commentNodeId)); + expect(mockCore.summary.write).toHaveBeenCalled(); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + + it("should handle multiple minimize-comment items", async () => { + const commentNodeId1 = "IC_kwDOABCD111111"; + const commentNodeId2 = "IC_kwDOABCD222222"; + + setAgentOutput({ + items: [ + { + type: "minimize_comment", + comment_id: commentNodeId1, + }, + { + type: "minimize_comment", + comment_id: commentNodeId2, + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + .mockResolvedValueOnce({ + minimizeComment: { + minimizedComment: { + isMinimized: true, + }, + }, + }) + .mockResolvedValueOnce({ + minimizeComment: { + minimizedComment: { + isMinimized: true, + }, + }, + }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.info).toHaveBeenCalledWith("Found 2 minimize-comment item(s)"); + expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + expect(mockCore.info).toHaveBeenCalledWith(`Successfully minimized comment: ${commentNodeId1}`); + expect(mockCore.info).toHaveBeenCalledWith(`Successfully minimized comment: ${commentNodeId2}`); + }); + + it("should fail when comment_id is missing", async () => { + setAgentOutput({ + items: [ + { + type: "minimize_comment", + // Missing comment_id + }, + ], + errors: [], + }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("comment_id is required")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("comment_id is required")); + }); + + it("should fail when minimize returns false", async () => { + const commentNodeId = "IC_kwDOABCD123456"; + + setAgentOutput({ + items: [ + { + type: "minimize_comment", + comment_id: commentNodeId, + }, + ], + errors: [], + }); + + // Mock GraphQL response where minimize fails + mockGithub.graphql.mockResolvedValueOnce({ + minimizeComment: { + minimizedComment: { + isMinimized: false, + }, + }, + }); + + // Execute the script + await eval(`(async () => { ${minimizeCommentScript} })()`); + + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to minimize comment")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to minimize comment")); + }); +}); diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 89069a558a8..e9d9d6fd69f 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -530,5 +530,20 @@ }, "additionalProperties": false } + }, + { + "name": "minimize_comment", + "description": "Minimize (hide) a comment on a GitHub issue, pull request, or discussion. This collapses the comment as spam or off-topic. Use this for inappropriate, off-topic, or outdated comments. The comment_id must be a GraphQL node ID (string like 'IC_kwDOABCD123456'), not a numeric REST API comment ID.", + "inputSchema": { + "type": "object", + "required": ["comment_id"], + "properties": { + "comment_id": { + "type": "string", + "description": "GraphQL node ID of the comment to minimize (e.g., 'IC_kwDOABCD123456'). This is the GraphQL node ID, not the numeric comment ID from REST API. Can be obtained from GraphQL queries or comment API responses." + } + }, + "additionalProperties": false + } } ] diff --git a/pkg/workflow/js/types/safe-outputs.d.ts b/pkg/workflow/js/types/safe-outputs.d.ts index 07c412f1153..7cfa8710036 100644 --- a/pkg/workflow/js/types/safe-outputs.d.ts +++ b/pkg/workflow/js/types/safe-outputs.d.ts @@ -276,6 +276,15 @@ interface LinkSubIssueItem extends BaseSafeOutputItem { sub_issue_number: number | string; } +/** + * JSONL item for minimizing (hiding) a comment + */ +interface MinimizeCommentItem extends BaseSafeOutputItem { + type: "minimize_comment"; + /** GraphQL node ID of the comment to minimize (e.g., 'IC_kwDOABCD123456') */ + comment_id: string; +} + /** * Union type of all possible safe output items */ @@ -300,7 +309,8 @@ type SafeOutputItem = | AssignToAgentItem | UpdateReleaseItem | NoOpItem - | LinkSubIssueItem; + | LinkSubIssueItem + | MinimizeCommentItem; /** * Sanitized safe output items @@ -334,6 +344,7 @@ export { UpdateReleaseItem, NoOpItem, LinkSubIssueItem, + MinimizeCommentItem, SafeOutputItem, SafeOutputItems, }; diff --git a/pkg/workflow/minimize_comment.go b/pkg/workflow/minimize_comment.go new file mode 100644 index 00000000000..828f070d84b --- /dev/null +++ b/pkg/workflow/minimize_comment.go @@ -0,0 +1,97 @@ +package workflow + +import ( + "fmt" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var minimizeCommentLog = logger.New("workflow:minimize_comment") + +// MinimizeCommentConfig holds configuration for minimizing (hiding) comments from agent output +type MinimizeCommentConfig struct { + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` +} + +// parseMinimizeCommentConfig handles minimize-comment configuration +func (c *Compiler) parseMinimizeCommentConfig(outputMap map[string]any) *MinimizeCommentConfig { + minimizeCommentLog.Print("Parsing minimize-comment configuration") + if configData, exists := outputMap["minimize-comment"]; exists { + minimizeCommentConfig := &MinimizeCommentConfig{} + + if configMap, ok := configData.(map[string]any); ok { + minimizeCommentLog.Print("Found minimize-comment config map") + + // Parse target config (target-repo) with validation + targetConfig, isInvalid := ParseTargetConfig(configMap) + if isInvalid { + return nil // Invalid configuration (e.g., wildcard target-repo), return nil to cause validation error + } + minimizeCommentConfig.SafeOutputTargetConfig = targetConfig + + // Parse common base fields with default max of 5 + c.parseBaseSafeOutputConfig(configMap, &minimizeCommentConfig.BaseSafeOutputConfig, 5) + + minimizeCommentLog.Printf("Parsed minimize-comment config: max=%d, target_repo=%s", + minimizeCommentConfig.Max, minimizeCommentConfig.TargetRepoSlug) + } else { + // If configData is nil or not a map, still set the default max + minimizeCommentConfig.Max = 5 + } + + return minimizeCommentConfig + } + + return nil +} + +// buildMinimizeCommentJob creates the minimize_comment job +func (c *Compiler) buildMinimizeCommentJob(data *WorkflowData, mainJobName string) (*Job, error) { + minimizeCommentLog.Printf("Building minimize_comment job: main_job=%s", mainJobName) + if data.SafeOutputs == nil || data.SafeOutputs.MinimizeComment == nil { + return nil, fmt.Errorf("safe-outputs.minimize-comment configuration is required") + } + + cfg := data.SafeOutputs.MinimizeComment + + maxCount := 5 + if cfg.Max > 0 { + maxCount = cfg.Max + } + + // Build custom environment variables specific to minimize-comment + var customEnvVars []string + + // Pass the max limit + customEnvVars = append(customEnvVars, BuildMaxCountEnvVar("GH_AW_MINIMIZE_COMMENT_MAX_COUNT", maxCount)...) + + // Add standard environment variables (metadata + staged/target repo) + customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, cfg.TargetRepoSlug)...) + + // Create outputs for the job + outputs := map[string]string{ + "comment_id": "${{ steps.minimize_comment.outputs.comment_id }}", + "is_minimized": "${{ steps.minimize_comment.outputs.is_minimized }}", + } + + // Use the shared builder function to create the job + return c.buildSafeOutputJob(data, SafeOutputJobConfig{ + JobName: "minimize_comment", + StepName: "Minimize Comment", + StepID: "minimize_comment", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: getMinimizeCommentScript(), + Permissions: NewPermissionsContentsReadIssuesWritePRWriteDiscussionsWrite(), + Outputs: outputs, + Token: cfg.GitHubToken, + Condition: BuildSafeOutputType("minimize_comment"), + TargetRepoSlug: cfg.TargetRepoSlug, + }) +} + +// getMinimizeCommentScript returns the JavaScript implementation +func getMinimizeCommentScript() string { + return DefaultScriptRegistry.GetWithMode("minimize_comment", RuntimeModeGitHubScript) +} diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index e854b979ad9..4c5f200e15c 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -50,6 +50,7 @@ func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { safeOutputs.MissingTool != nil || safeOutputs.NoOp != nil || safeOutputs.LinkSubIssue != nil || + safeOutputs.MinimizeComment != nil || len(safeOutputs.Jobs) > 0 if safeOutputsLog.Enabled() { @@ -218,6 +219,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.LinkSubIssue = linkSubIssueConfig } + // Handle minimize-comment + minimizeCommentConfig := c.parseMinimizeCommentConfig(outputMap) + if minimizeCommentConfig != nil { + config.MinimizeComment = minimizeCommentConfig + } + // Handle missing-tool (parse configuration if present, or enable by default) missingToolConfig := c.parseMissingToolConfig(outputMap) if missingToolConfig != nil { @@ -1105,6 +1112,9 @@ func generateFilteredToolsJSON(data *WorkflowData) (string, error) { if data.SafeOutputs.LinkSubIssue != nil { enabledTools["link_sub_issue"] = true } + if data.SafeOutputs.MinimizeComment != nil { + enabledTools["minimize_comment"] = true + } // Filter tools to only include enabled ones and enhance descriptions var filteredTools []map[string]any diff --git a/pkg/workflow/safe_outputs_tools_test.go b/pkg/workflow/safe_outputs_tools_test.go index bae78ef1abe..8477f57fcc1 100644 --- a/pkg/workflow/safe_outputs_tools_test.go +++ b/pkg/workflow/safe_outputs_tools_test.go @@ -291,6 +291,7 @@ func TestGetSafeOutputsToolsJSON(t *testing.T) { "upload_asset", "update_release", "link_sub_issue", + "minimize_comment", "missing_tool", "noop", } diff --git a/pkg/workflow/scripts.go b/pkg/workflow/scripts.go index 3c9b264a755..82a32f175e6 100644 --- a/pkg/workflow/scripts.go +++ b/pkg/workflow/scripts.go @@ -52,6 +52,9 @@ var assignCopilotToCreatedIssuesScriptSource string //go:embed js/link_sub_issue.cjs var linkSubIssueScriptSource string +//go:embed js/minimize_comment.cjs +var minimizeCommentScriptSource string + //go:embed js/create_discussion.cjs var createDiscussionScriptSource string @@ -147,6 +150,7 @@ func init() { DefaultScriptRegistry.Register("assign_to_user", assignToUserScriptSource) DefaultScriptRegistry.Register("assign_copilot_to_created_issues", assignCopilotToCreatedIssuesScriptSource) DefaultScriptRegistry.Register("link_sub_issue", linkSubIssueScriptSource) + DefaultScriptRegistry.Register("minimize_comment", minimizeCommentScriptSource) DefaultScriptRegistry.Register("create_discussion", createDiscussionScriptSource) DefaultScriptRegistry.Register("close_discussion", closeDiscussionScriptSource) DefaultScriptRegistry.Register("close_expired_discussions", closeExpiredDiscussionsScriptSource) diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 4cc6fd80f79..f3f0e5a7a37 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -47,7 +47,8 @@ {"$ref": "#/$defs/AssignMilestoneOutput"}, {"$ref": "#/$defs/AssignToAgentOutput"}, {"$ref": "#/$defs/NoOpOutput"}, - {"$ref": "#/$defs/LinkSubIssueOutput"} + {"$ref": "#/$defs/LinkSubIssueOutput"}, + {"$ref": "#/$defs/MinimizeCommentOutput"} ] }, "CreateIssueOutput": { @@ -622,6 +623,23 @@ }, "required": ["type", "parent_issue_number", "sub_issue_number"], "additionalProperties": false + }, + "MinimizeCommentOutput": { + "title": "Minimize Comment Output", + "description": "Output for minimizing (hiding) a comment on a GitHub issue, pull request, or discussion", + "type": "object", + "properties": { + "type": { + "const": "minimize_comment" + }, + "comment_id": { + "type": "string", + "description": "Node ID of the comment to minimize (e.g., 'IC_kwDOABCD123456')", + "minLength": 1 + } + }, + "required": ["type", "comment_id"], + "additionalProperties": false } } } \ No newline at end of file