diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index d6ea4e3142f..fb168fe885b 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -7316,6 +7316,7 @@ jobs: GH_AW_WORKFLOW_NAME: "Dev" GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e πŸ“ Poetry generated by [{workflow_name}]({run_url})\",\"footerInstall\":\"\\u003e Want to add poems to your discussions? Install with `gh aw add {workflow_source}`\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | @@ -7858,103 +7859,6 @@ jobs: shell: bash run: | mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' // @ts-check /// @@ -8048,6 +7952,274 @@ jobs: module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; EOF_b93f537f + cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' + // @ts-check + /// + + /** + * Core Message Utilities Module + * + * This module provides shared utilities for message template processing. + * It includes configuration parsing and template rendering functions. + * + * Supported placeholders: + * - {workflow_name} - Name of the workflow + * - {run_url} - URL to the workflow run + * - {workflow_source} - Source specification (owner/repo/path@ref) + * - {workflow_source_url} - GitHub URL for the workflow source + * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow + * - {operation} - Operation name (for staged mode titles/descriptions) + * - {event_type} - Event type description (for run-started messages) + * - {status} - Workflow status text (for run-failure messages) + * + * Both camelCase and snake_case placeholder formats are supported. + */ + + /** + * @typedef {Object} SafeOutputMessages + * @property {string} [footer] - Custom footer message template + * @property {string} [footerInstall] - Custom installation instructions template + * @property {string} [stagedTitle] - Custom staged mode title template + * @property {string} [stagedDescription] - Custom staged mode description template + * @property {string} [runStarted] - Custom workflow activation message template + * @property {string} [runSuccess] - Custom workflow success message template + * @property {string} [runFailure] - Custom workflow failure message template + * @property {string} [detectionFailure] - Custom detection job failure message template + * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated + */ + + /** + * Get the safe-output messages configuration from environment variable. + * @returns {SafeOutputMessages|null} Parsed messages config or null if not set + */ + function getMessages() { + const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; + if (!messagesEnv) { + return null; + } + + try { + // Parse JSON with camelCase keys from Go struct (using json struct tags) + return JSON.parse(messagesEnv); + } catch (error) { + core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + /** + * Replace placeholders in a template string with values from context. + * Supports {key} syntax for placeholder replacement. + * @param {string} template - Template string with {key} placeholders + * @param {Record} context - Key-value pairs for replacement + * @returns {string} Template with placeholders replaced + */ + function renderTemplate(template, context) { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = context[key]; + return value !== undefined && value !== null ? String(value) : match; + }); + } + + /** + * Convert context object keys to snake_case for template rendering + * @param {Record} obj - Object with camelCase keys + * @returns {Record} Object with snake_case keys + */ + function toSnakeCase(obj) { + /** @type {Record} */ + const result = {}; + for (const [key, value] of Object.entries(obj)) { + // Convert camelCase to snake_case + const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); + result[snakeKey] = value; + // Also keep original key for backwards compatibility + result[key] = value; + } + return result; + } + + module.exports = { + getMessages, + renderTemplate, + toSnakeCase, + }; + + EOF_6cdb27e0 + cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' + // @ts-check + /// + + /** + * Footer Message Module + * + * This module provides footer and installation instructions generation + * for safe-output workflows. + */ + + const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); + + /** + * @typedef {Object} FooterContext + * @property {string} workflowName - Name of the workflow + * @property {string} runUrl - URL of the workflow run + * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) + * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source + * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow + */ + + /** + * Get the footer message, using custom template if configured. + * @param {FooterContext} ctx - Context for footer generation + * @returns {string} Footer message + */ + function getFooterMessage(ctx) { + const messages = getMessages(); + + // Create context with both camelCase and snake_case keys + const templateContext = toSnakeCase(ctx); + + // Default footer template - pirate themed! πŸ΄β€β˜ οΈ + const defaultFooter = "> Ahoy! This treasure was crafted by [πŸ΄β€β˜ οΈ {workflow_name}]({run_url})"; + + // Use custom footer if configured + let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); + + // Add triggering reference if available + if (ctx.triggeringNumber) { + footer += ` fer issue #{triggering_number} πŸ—ΊοΈ`.replace("{triggering_number}", String(ctx.triggeringNumber)); + } + + return footer; + } + + /** + * Get the footer installation instructions, using custom template if configured. + * @param {FooterContext} ctx - Context for footer generation + * @returns {string} Footer installation message or empty string if no source + */ + function getFooterInstallMessage(ctx) { + if (!ctx.workflowSource || !ctx.workflowSourceUrl) { + return ""; + } + + const messages = getMessages(); + + // Create context with both camelCase and snake_case keys + const templateContext = toSnakeCase(ctx); + + // Default installation template - pirate themed! πŸ΄β€β˜ οΈ + const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; + + // Use custom installation message if configured + return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); + } + + /** + * Generates an XML comment marker with agentic workflow metadata for traceability. + * This marker enables searching and tracing back items generated by an agentic workflow. + * + * The marker format is: + * + * + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @returns {string} XML comment marker with workflow metadata + */ + function generateXMLMarker(workflowName, runUrl) { + // Read engine metadata from environment variables + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + + // Build the key-value pairs for the marker + const parts = []; + + // Always include agentic-workflow name + parts.push(`agentic-workflow: ${workflowName}`); + + // Add tracker-id if available (for searchability and tracing) + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + + // Add engine ID if available + if (engineId) { + parts.push(`engine: ${engineId}`); + } + + // Add version if available + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + + // Add model if available + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + + // Always include run URL + parts.push(`run: ${runUrl}`); + + // Return the XML comment marker + return ``; + } + + /** + * Generate the complete footer with AI attribution and optional installation instructions. + * This is a drop-in replacement for the original generateFooter function. + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) + * @param {string} workflowSourceURL - GitHub URL for the workflow source + * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow + * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow + * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow + * @returns {string} Complete footer text + */ + function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { + // Determine triggering number (issue takes precedence, then PR, then discussion) + let triggeringNumber; + if (triggeringIssueNumber) { + triggeringNumber = triggeringIssueNumber; + } else if (triggeringPRNumber) { + triggeringNumber = triggeringPRNumber; + } else if (triggeringDiscussionNumber) { + triggeringNumber = `discussion #${triggeringDiscussionNumber}`; + } + + const ctx = { + workflowName, + runUrl, + workflowSource, + workflowSourceUrl: workflowSourceURL, + triggeringNumber, + }; + + let footer = "\n\n" + getFooterMessage(ctx); + + // Add installation instructions if source is available + const installMessage = getFooterInstallMessage(ctx); + if (installMessage) { + footer += "\n>\n" + installMessage; + } + + // Add XML comment marker for traceability + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + + footer += "\n"; + return footer; + } + + module.exports = { + getFooterMessage, + getFooterInstallMessage, + generateFooterWithMessages, + generateXMLMarker, + }; + + EOF_c14886c6 cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' // @ts-check /** @@ -8594,6 +8766,7 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Dev" GH_AW_ENGINE_ID: "copilot" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e πŸ“ Poetry generated by [{workflow_name}]({run_url})\",\"footerInstall\":\"\\u003e Want to add poems to your discussions? Install with `gh aw add {workflow_source}`\"}" GH_AW_UPDATE_TARGET: "*" GH_AW_UPDATE_BODY: "true" with: @@ -8606,7 +8779,7 @@ jobs: globalThis.io = io; const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require('/tmp/gh-aw/scripts/update_runner.cjs'); const { isDiscussionContext, getDiscussionNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); + const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); const renderStagedItem = createRenderStagedItem({ entityName: "Discussion", numberField: "discussion_number", @@ -8650,7 +8823,7 @@ jobs: const triggeringIssueNumber = context.payload.issue?.number; const triggeringPRNumber = context.payload.pull_request?.number; const triggeringDiscussionNumber = context.payload.discussion?.number; - const footer = generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); + const footer = generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); fieldsToUpdate.body = fieldsToUpdate.body + footer; } const mutationFields = []; diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 450ee15475b..3301b901084 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -20,6 +20,9 @@ safe-outputs: update-discussion: target: "*" body: + messages: + footer: "> πŸ“ Poetry generated by [{workflow_name}]({run_url})" + footer-install: "> Want to add poems to your discussions? Install with `gh aw add {workflow_source}`" --- Find the latest discussion in this repository and update its body by appending a short, creative poem about GitHub Agentic Workflows. diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index b5aade72c47..ac95c1ba104 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1925,6 +1925,42 @@ safe-outputs: # (optional) # This field supports multiple formats (oneOf): + # Option 1: Configuration for updating GitHub discussions from agentic workflow + # output + update-discussion: + # Target for updates: 'triggering' (default), '*' (any discussion), or explicit + # discussion number + # (optional) + target: "example-value" + + # Allow updating discussion title - presence of key indicates field can be updated + # (optional) + title: null + + # Allow updating discussion body - presence of key indicates field can be updated + # (optional) + body: null + + # Maximum number of discussions to update (default: 1) + # (optional) + max: 1 + + # Target repository in format 'owner/repo' for cross-repository discussion + # updates. Takes precedence over trial target repo settings. + # (optional) + target-repo: "example-value" + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # Option 2: Enable discussion updating with default configuration + update-discussion: null + + # (optional) + # This field supports multiple formats (oneOf): + # Option 1: Configuration for closing GitHub issues with comment from agentic # workflow output close-issue: @@ -2863,10 +2899,7 @@ strict: true # (JavaScript), 'run' (shell), or 'py' (Python) must be specified per tool. # (optional) safe-inputs: - # Transport mode for the safe-inputs MCP server. Only 'http' mode is supported, - # which starts the server as a separate step. - # (optional) - mode: "http" + # Runtime environment version overrides. Allows customizing runtime versions # (e.g., Node.js, Python) or defining new runtimes. Runtimes from imported shared diff --git a/pkg/cli/fix_command_test.go b/pkg/cli/fix_command_test.go index 7e75b112b0b..2a34cd4be6f 100644 --- a/pkg/cli/fix_command_test.go +++ b/pkg/cli/fix_command_test.go @@ -542,12 +542,12 @@ This is a test workflow with slash command. } func TestFixCommand_SafeInputsModeRemoval(t *testing.T) { -// Create a temporary directory for test files -tmpDir := t.TempDir() -workflowFile := filepath.Join(tmpDir, "test-workflow.md") + // Create a temporary directory for test files + tmpDir := t.TempDir() + workflowFile := filepath.Join(tmpDir, "test-workflow.md") -// Create a workflow with deprecated safe-inputs.mode field -content := `--- + // Create a workflow with deprecated safe-inputs.mode field + content := `--- on: workflow_dispatch engine: copilot safe-inputs: @@ -563,51 +563,51 @@ safe-inputs: This is a test workflow with safe-inputs mode field. ` -if err := os.WriteFile(workflowFile, []byte(content), 0644); err != nil { -t.Fatalf("Failed to create test file: %v", err) -} + if err := os.WriteFile(workflowFile, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } -// Get the safe-inputs mode removal codemod -modeCodemod := getCodemodByID("safe-inputs-mode-removal") -if modeCodemod == nil { -t.Fatal("safe-inputs-mode-removal codemod not found") -} + // Get the safe-inputs mode removal codemod + modeCodemod := getCodemodByID("safe-inputs-mode-removal") + if modeCodemod == nil { + t.Fatal("safe-inputs-mode-removal codemod not found") + } -// Process the file -fixed, err := processWorkflowFile(workflowFile, []Codemod{*modeCodemod}, true, false) -if err != nil { -t.Fatalf("Failed to process workflow file: %v", err) -} + // Process the file + fixed, err := processWorkflowFile(workflowFile, []Codemod{*modeCodemod}, true, false) + if err != nil { + t.Fatalf("Failed to process workflow file: %v", err) + } -if !fixed { -t.Error("Expected file to be fixed, but no changes were made") -} + if !fixed { + t.Error("Expected file to be fixed, but no changes were made") + } -// Read the updated content -updatedContent, err := os.ReadFile(workflowFile) -if err != nil { -t.Fatalf("Failed to read updated file: %v", err) -} + // Read the updated content + updatedContent, err := os.ReadFile(workflowFile) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } -updatedStr := string(updatedContent) + updatedStr := string(updatedContent) -t.Logf("Updated content:\n%s", updatedStr) + t.Logf("Updated content:\n%s", updatedStr) -// Verify the change - mode field should be removed -if strings.Contains(updatedStr, "mode:") { -t.Errorf("Expected mode field to be removed, but it still exists:\n%s", updatedStr) -} + // Verify the change - mode field should be removed + if strings.Contains(updatedStr, "mode:") { + t.Errorf("Expected mode field to be removed, but it still exists:\n%s", updatedStr) + } -// Verify safe-inputs block and test-tool are preserved -if !strings.Contains(updatedStr, "safe-inputs:") { -t.Error("Expected safe-inputs block to be preserved") -} + // Verify safe-inputs block and test-tool are preserved + if !strings.Contains(updatedStr, "safe-inputs:") { + t.Error("Expected safe-inputs block to be preserved") + } -if !strings.Contains(updatedStr, "test-tool:") { -t.Error("Expected test-tool to be preserved") -} + if !strings.Contains(updatedStr, "test-tool:") { + t.Error("Expected test-tool to be preserved") + } -if !strings.Contains(updatedStr, "description: Test tool") { -t.Error("Expected test-tool description to be preserved") -} + if !strings.Contains(updatedStr, "description: Test tool") { + t.Error("Expected test-tool description to be preserved") + } } diff --git a/pkg/workflow/js/update_discussion.cjs b/pkg/workflow/js/update_discussion.cjs index 665b0fefff0..c8432e9f94f 100644 --- a/pkg/workflow/js/update_discussion.cjs +++ b/pkg/workflow/js/update_discussion.cjs @@ -3,7 +3,7 @@ const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require("./update_runner.cjs"); const { isDiscussionContext, getDiscussionNumber } = require("./update_context_helpers.cjs"); -const { generateFooter } = require("./generate_footer.cjs"); +const { generateFooterWithMessages } = require("./messages_footer.cjs"); // Use shared helper for staged preview rendering const renderStagedItem = createRenderStagedItem({ @@ -72,7 +72,7 @@ async function executeDiscussionUpdate(github, context, discussionNumber, update const triggeringDiscussionNumber = context.payload.discussion?.number; // Append footer to the body - const footer = generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); + const footer = generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); fieldsToUpdate.body = fieldsToUpdate.body + footer; } diff --git a/pkg/workflow/js/update_discussion.test.cjs b/pkg/workflow/js/update_discussion.test.cjs index 29948b1aaea..518469fac05 100644 --- a/pkg/workflow/js/update_discussion.test.cjs +++ b/pkg/workflow/js/update_discussion.test.cjs @@ -291,4 +291,53 @@ describe("update_discussion.cjs", () => { expect(mockCore.info).toHaveBeenCalledWith("No valid updates to apply for this item"); expect(mockGithub.graphql).not.toHaveBeenCalled(); }); + + it("should use custom footer message when configured", async () => { + setAgentOutput({ + items: [{ type: "update_discussion", body: "New discussion body" }], + }); + process.env.GH_AW_UPDATE_BODY = "true"; + process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"; + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + footer: "> Custom footer by [{workflow_name}]({run_url})", + }); + global.context.eventName = "discussion"; + global.context.runId = 789; + + const mockDiscussion = { + id: "D_kwDOABCD123", + number: 123, + title: "Test Discussion", + body: "Old body", + url: "https://github.com/testowner/testrepo/discussions/123", + }; + + // Mock the query + mockGithub.graphql.mockResolvedValueOnce({ + repository: { + discussion: mockDiscussion, + }, + }); + + // Mock the update and capture the body parameter + let capturedBody; + mockGithub.graphql.mockImplementationOnce((query, variables) => { + capturedBody = variables.body; + return Promise.resolve({ + updateDiscussion: { + discussion: { + ...mockDiscussion, + body: variables.body, + }, + }, + }); + }); + + await eval(`(async () => { ${updateDiscussionScript} })()`); + + expect(mockGithub.graphql).toHaveBeenCalledTimes(2); + // Verify the custom footer was used + expect(capturedBody).toContain("Custom footer by"); + expect(capturedBody).toContain("Custom Workflow"); + }); });