diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index 205ba0e401..0d6daf1424 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -178,6 +178,7 @@ async function main(config = {}) { const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const fallbackToIssue = config.fallback_to_issue !== false; // Default to true const closeOlderDiscussions = config.close_older_discussions === true || config.close_older_discussions === "true"; + const includeFooter = config.footer !== false; // Default to true (include footer) // Parse labels from config const labelsConfig = config.labels || []; @@ -366,15 +367,18 @@ async function main(config = {}) { const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; // Generate footer with expiration using helper - const footer = generateFooterWithExpiration({ - footerText: `> AI generated by [${workflowName}](${runUrl})`, - expiresHours, - entityType: "Discussion", - }); - - bodyLines.push(``, ``, footer); + // When footer is disabled, only add XML markers (no visible footer content) + if (includeFooter) { + const footer = generateFooterWithExpiration({ + footerText: `> AI generated by [${workflowName}](${runUrl})`, + expiresHours, + entityType: "Discussion", + }); + bodyLines.push(``, ``, footer); + } // Add standalone workflow-id marker for searchability (consistent with comments) + // Always add XML markers even when footer is disabled if (workflowId) { bodyLines.push(``, generateWorkflowIdMarker(workflowId)); } diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 06f06687e3..9ea30bb352 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -198,6 +198,7 @@ async function main(config = {}) { const defaultTargetRepo = getDefaultTargetRepo(config); const groupEnabled = config.group === true || config.group === "true"; const closeOlderIssuesEnabled = config.close_older_issues === true || config.close_older_issues === "true"; + const includeFooter = config.footer !== false; // Default to true (include footer) // Check if copilot assignment is enabled const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; @@ -417,11 +418,14 @@ async function main(config = {}) { } // Generate footer and add expiration using helper - const footer = addExpirationToFooter(generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), expiresHours, "Issue"); - - bodyLines.push(``, ``, footer); + // When footer is disabled, only add XML markers (no visible footer content) + if (includeFooter) { + const footer = addExpirationToFooter(generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), expiresHours, "Issue"); + bodyLines.push(``, ``, footer); + } // Add standalone workflow-id marker for searchability (consistent with comments) + // Always add XML markers even when footer is disabled if (workflowId) { bodyLines.push(``, generateWorkflowIdMarker(workflowId)); } diff --git a/actions/setup/js/create_pull_request.cjs b/actions/setup/js/create_pull_request.cjs index 18dde670de..b3e2e47d3d 100644 --- a/actions/setup/js/create_pull_request.cjs +++ b/actions/setup/js/create_pull_request.cjs @@ -71,6 +71,7 @@ async function main(config = {}) { const baseBranch = config.base_branch || ""; const maxSizeKb = config.max_patch_size ? parseInt(String(config.max_patch_size), 10) : 1024; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); + const includeFooter = config.footer !== false; // Default to true (include footer) // Environment validation - fail early if required variables are missing const workflowId = process.env.GH_AW_WORKFLOW_ID; @@ -371,16 +372,19 @@ async function main(config = {}) { } // Generate footer with expiration using helper - const footer = generateFooterWithExpiration({ - footerText: `> AI generated by [${workflowName}](${runUrl})`, - expiresHours, - entityType: "Pull Request", - suffix: expiresHours > 0 ? "\n\n" : undefined, - }); - - bodyLines.push(``, ``, footer); + // When footer is disabled, only add XML markers (no visible footer content) + if (includeFooter) { + const footer = generateFooterWithExpiration({ + footerText: `> AI generated by [${workflowName}](${runUrl})`, + expiresHours, + entityType: "Pull Request", + suffix: expiresHours > 0 ? "\n\n" : undefined, + }); + bodyLines.push(``, ``, footer); + } // Add standalone workflow-id marker for searchability (consistent with comments) + // Always add XML markers even when footer is disabled if (workflowId) { bodyLines.push(``, generateWorkflowIdMarker(workflowId)); } diff --git a/actions/setup/js/types/safe-outputs-config.d.ts b/actions/setup/js/types/safe-outputs-config.d.ts index f1eb310512..cb9918c224 100644 --- a/actions/setup/js/types/safe-outputs-config.d.ts +++ b/actions/setup/js/types/safe-outputs-config.d.ts @@ -16,6 +16,7 @@ interface CreateIssueConfig extends SafeOutputConfig { labels?: string[]; "target-repo"?: string; "allowed-repos"?: string[]; + footer?: boolean; } /** @@ -26,6 +27,7 @@ interface CreateDiscussionConfig extends SafeOutputConfig { "category-id"?: string; "target-repo"?: string; "allowed-repos"?: string[]; + footer?: boolean; } /** @@ -81,6 +83,7 @@ interface CreatePullRequestConfig extends SafeOutputConfig { labels?: string[]; draft?: boolean; "if-no-changes"?: string; + footer?: boolean; } /** @@ -128,6 +131,7 @@ interface UpdateIssueConfig extends SafeOutputConfig { target?: string; title?: boolean; body?: boolean; + footer?: boolean; } /** @@ -137,6 +141,7 @@ interface UpdateDiscussionConfig extends SafeOutputConfig { target?: string; title?: boolean; body?: boolean; + footer?: boolean; } /** @@ -190,6 +195,7 @@ interface AssignToAgentConfig extends SafeOutputConfig { */ interface UpdateReleaseConfig extends SafeOutputConfig { target?: string; + footer?: boolean; } /** diff --git a/actions/setup/js/update_discussion.cjs b/actions/setup/js/update_discussion.cjs index c06d94b28f..b81a8b3354 100644 --- a/actions/setup/js/update_discussion.cjs +++ b/actions/setup/js/update_discussion.cjs @@ -134,6 +134,9 @@ function buildDiscussionUpdateData(item, config) { updateData.body = item.body; } + // Pass footer config to executeUpdate (default to true) + updateData._includeFooter = config.footer !== false; + return { success: true, data: updateData }; } diff --git a/actions/setup/js/update_issue.cjs b/actions/setup/js/update_issue.cjs index 534c97c668..02bbfcd2ae 100644 --- a/actions/setup/js/update_issue.cjs +++ b/actions/setup/js/update_issue.cjs @@ -27,9 +27,10 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) { // Default to "append" to add footer with AI attribution const operation = updateData._operation || "append"; let rawBody = updateData._rawBody; + const includeFooter = updateData._includeFooter !== false; // Default to true // Remove internal fields - const { _operation, _rawBody, ...apiData } = updateData; + const { _operation, _rawBody, _includeFooter, ...apiData } = updateData; // If we have a body, process it with the appropriate operation if (rawBody !== undefined) { @@ -61,6 +62,7 @@ async function executeIssueUpdate(github, context, issueNumber, updateData) { workflowName, runUrl, runId: context.runId, + includeFooter, // Pass footer flag to helper }); core.info(`Will update body (length: ${apiData.body.length})`); @@ -136,6 +138,9 @@ function buildIssueUpdateData(item, config) { updateData.milestone = item.milestone; } + // Pass footer config to executeUpdate (default to true) + updateData._includeFooter = config.footer !== false; + return { success: true, data: updateData }; } diff --git a/actions/setup/js/update_pr_description_helpers.cjs b/actions/setup/js/update_pr_description_helpers.cjs index 7f1390589c..c4d993b13c 100644 --- a/actions/setup/js/update_pr_description_helpers.cjs +++ b/actions/setup/js/update_pr_description_helpers.cjs @@ -71,15 +71,16 @@ function findIsland(body, runId) { * @param {string} params.workflowName - Name of the workflow * @param {string} params.runUrl - URL of the workflow run * @param {number} params.runId - Workflow run ID + * @param {boolean} [params.includeFooter=true] - Whether to include AI-generated footer (default: true) * @returns {string} Updated body content */ function updateBody(params) { - const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; - const aiFooter = buildAIFooter(workflowName, runUrl); + const { currentBody, newContent, operation, workflowName, runUrl, runId, includeFooter = true } = params; + const aiFooter = includeFooter ? buildAIFooter(workflowName, runUrl) : ""; if (operation === "replace") { - // Replace: use new content with AI footer - core.info("Operation: replace (full body replacement with footer)"); + // Replace: use new content with optional AI footer + core.info("Operation: replace (full body replacement)"); return newContent + aiFooter; } @@ -109,7 +110,7 @@ function updateBody(params) { } if (operation === "prepend") { - // Prepend: add content, AI footer, and horizontal line at the start + // Prepend: add content, AI footer (if enabled), and horizontal line at the start core.info("Operation: prepend (add to start with separator)"); const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; return prependSection + currentBody; diff --git a/actions/setup/js/update_pr_description_helpers.test.cjs b/actions/setup/js/update_pr_description_helpers.test.cjs index 6d35008e37..61888854ed 100644 --- a/actions/setup/js/update_pr_description_helpers.test.cjs +++ b/actions/setup/js/update_pr_description_helpers.test.cjs @@ -400,4 +400,100 @@ describe("update_pr_description_helpers.cjs", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("append")); }); }); + + describe("Footer parameter", () => { + it("should omit footer when includeFooter is false", () => { + const result = updateBody({ + currentBody: "Original", + newContent: "New content", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + includeFooter: false, + }); + expect(result).toContain("Original"); + expect(result).toContain("New content"); + expect(result).not.toContain("Generated by"); + expect(result).not.toContain("AI generated"); + }); + + it("should include footer by default when includeFooter is not specified", () => { + const result = updateBody({ + currentBody: "Original", + newContent: "New content", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + // includeFooter not specified, should default to true + }); + expect(result).toContain("Original"); + expect(result).toContain("New content"); + expect(result).toContain("Generated by"); + }); + + it("should include footer when includeFooter is explicitly true", () => { + const result = updateBody({ + currentBody: "Original", + newContent: "New content", + operation: "append", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + includeFooter: true, + }); + expect(result).toContain("Original"); + expect(result).toContain("New content"); + expect(result).toContain("Generated by"); + }); + + it("should omit footer in replace operation when includeFooter is false", () => { + const result = updateBody({ + currentBody: "Original", + newContent: "Replacement", + operation: "replace", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + includeFooter: false, + }); + expect(result).toBe("Replacement"); + expect(result).not.toContain("Generated by"); + }); + + it("should omit footer in prepend operation when includeFooter is false", () => { + const result = updateBody({ + currentBody: "Original", + newContent: "Prepended", + operation: "prepend", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + includeFooter: false, + }); + expect(result).toContain("Prepended"); + expect(result).toContain("Original"); + expect(result).not.toContain("Generated by"); + }); + + it("should omit footer in replace-island operation when includeFooter is false", () => { + const currentBody = "Before\n\nOld island\n\nAfter"; + const result = updateBody({ + currentBody, + newContent: "New island", + operation: "replace-island", + workflowName: "Test", + runUrl: "https://github.com/test/actions/runs/123", + runId: 123, + includeFooter: false, + }); + expect(result).toContain("Before"); + expect(result).toContain("New island"); + expect(result).toContain("After"); + expect(result).not.toContain("Generated by"); + expect(result).toContain(""); + expect(result).toContain(""); + }); + }); }); diff --git a/actions/setup/js/update_release.cjs b/actions/setup/js/update_release.cjs index 335135695d..08611d3116 100644 --- a/actions/setup/js/update_release.cjs +++ b/actions/setup/js/update_release.cjs @@ -10,12 +10,14 @@ const { updateBody } = require("./update_pr_description_helpers.cjs"); * * @param {Object} config - Handler configuration * @param {number} [config.max] - Maximum number of releases to update + * @param {boolean} [config.footer] - Controls whether AI-generated footer is added (default: true) * @returns {Promise} Handler function that processes a single message */ async function main(config = {}) { // Check if we're in staged mode const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; + const includeFooter = config.footer !== false; // Default to true (include footer) /** * Process a single update-release message @@ -90,6 +92,7 @@ async function main(config = {}) { workflowName, runUrl, runId: context.runId, + includeFooter, // Pass footer flag to helper }); // Update the release diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index b0be7dc601..7959d57818 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1878,6 +1878,12 @@ safe-outputs: # (optional) close-older-issues: true + # Controls whether AI-generated footer is added to the issue. When false, the + # visible footer content is omitted but XML markers (workflow-id, tracker-id, + # metadata) are still included for searchability. Defaults to true. + # (optional) + footer: true + # Option 2: Enable issue creation with default configuration create-issue: null @@ -2222,6 +2228,12 @@ safe-outputs: # (optional) fallback-to-issue: true + # Controls whether AI-generated footer is added to the discussion. When false, the + # visible footer content is omitted but XML markers (workflow-id, tracker-id, + # metadata) are still included for searchability. Defaults to true. + # (optional) + footer: true + # Time until the discussion expires and should be automatically closed. Supports # integer (days), relative time format like '2h' (2 hours), '7d' (7 days), '2w' (2 # weeks), '1m' (1 month), '1y' (1 year), or false to disable expiration. Minimum @@ -2322,6 +2334,12 @@ safe-outputs: # (optional) target-repo: "example-value" + # Controls whether AI-generated footer is added when updating the discussion body. + # When false, the visible footer content is omitted. Defaults to true. Only + # applies when 'body' is enabled. + # (optional) + footer: true + # Option 2: Enable discussion updating with default configuration update-discussion: null @@ -2581,6 +2599,12 @@ safe-outputs: # (optional) auto-merge: true + # Controls whether AI-generated footer is added to the pull request. When false, + # the visible footer content is omitted but XML markers (workflow-id, tracker-id, + # metadata) are still included for searchability. Defaults to true. + # (optional) + footer: true + # Option 2: Enable pull request creation with default configuration create-pull-request: null @@ -2965,6 +2989,12 @@ safe-outputs: # (optional) body: null + # Controls whether AI-generated footer is added when updating the issue body. When + # false, the visible footer content is omitted but XML markers are still included. + # Defaults to true. Only applies when 'body' is enabled. + # (optional) + footer: true + # Maximum number of issues to update (default: 1) # (optional) max: 1 @@ -3274,6 +3304,11 @@ safe-outputs: # (optional) target-repo: "example-value" + # Controls whether AI-generated footer is added when updating the release body. + # When false, the visible footer content is omitted. Defaults to true. + # (optional) + footer: true + # Option 2: Enable release updates with default configuration update-release: null @@ -3467,6 +3502,14 @@ safe-outputs: # (optional) max: 1 + # Global footer control for all safe outputs. When false, omits visible + # AI-generated footer content from all created/updated entities (issues, PRs, + # discussions, releases) while still including XML markers for searchability. + # Individual safe-output types (create-issue, update-issue, etc.) can override + # this by specifying their own footer field. Defaults to true. + # (optional) + footer: true + # Runner specification for all safe-outputs jobs (activation, create-issue, # add-comment, etc.). Single runner label (e.g., 'ubuntu-slim', 'ubuntu-latest', # 'windows-latest', 'self-hosted'). Defaults to 'ubuntu-slim'. See diff --git a/pkg/cli/workflows/test-footer-disabled.md b/pkg/cli/workflows/test-footer-disabled.md new file mode 100644 index 0000000000..9ae26841bf --- /dev/null +++ b/pkg/cli/workflows/test-footer-disabled.md @@ -0,0 +1,22 @@ +--- +on: + workflow_dispatch: +engine: copilot +permissions: + contents: read +safe-outputs: + create-issue: + title-prefix: "[test-footer] " + footer: false +--- + +# Test Footer Disabled in Create Issue + +Create a test issue with `footer: false` to demonstrate that: +1. The visible AI-generated footer is omitted +2. XML markers (workflow-id, tracker-id) are still included +3. The issue is searchable via workflow-id + +Create an issue with: +- **Title**: "Test issue without footer" +- **Body**: "This issue should not have a visible AI-generated footer, but should still have XML markers for searchability." diff --git a/pkg/cli/workflows/test-global-footer-override.md b/pkg/cli/workflows/test-global-footer-override.md new file mode 100644 index 0000000000..3597d99d4d --- /dev/null +++ b/pkg/cli/workflows/test-global-footer-override.md @@ -0,0 +1,26 @@ +--- +on: + workflow_dispatch: +engine: copilot +permissions: + contents: read +safe-outputs: + footer: false # Global: hide footer for all safe outputs + create-issue: + title-prefix: "[global-off] " + # Uses global footer: false + create-pull-request: + title-prefix: "[override-on] " + footer: true # Local override: show footer for PRs only +--- + +# Test Global Footer with Override + +Demonstrates global footer control with local override: + +1. **Global setting**: `safe-outputs.footer: false` hides footers for all outputs +2. **Local override**: `create-pull-request.footer: true` shows footer only for PRs + +Create two outputs to demonstrate: +- An issue with title "[global-off] Test issue" (no footer) +- A note that if this were creating a PR, it would have a footer due to the override diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 10ec36bd20..71e0bab2a5 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3942,6 +3942,11 @@ "type": "boolean", "description": "When true, automatically close older issues with the same workflow-id marker as 'not planned' with a comment linking to the new issue. Searches for issues containing the workflow-id marker in their body. Maximum 10 issues will be closed. Only runs if issue creation succeeds.", "default": false + }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added to the issue. When false, the visible footer content is omitted but XML markers (workflow-id, tracker-id, metadata) are still included for searchability. Defaults to true.", + "default": true } }, "additionalProperties": false, @@ -4364,6 +4369,11 @@ "description": "When true (default), fallback to creating an issue if discussion creation fails due to permissions. The fallback issue will include a note indicating it was intended to be a discussion. If close-older-discussions is enabled, the close-older-issues logic will be applied to the fallback issue.", "default": true }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added to the discussion. When false, the visible footer content is omitted but XML markers (workflow-id, tracker-id, metadata) are still included for searchability. Defaults to true.", + "default": true + }, "expires": { "oneOf": [ { @@ -4514,6 +4524,11 @@ "target-repo": { "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository discussion updates. Takes precedence over trial target repo settings." + }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added when updating the discussion body. When false, the visible footer content is omitted. Defaults to true. Only applies when 'body' is enabled.", + "default": true } }, "additionalProperties": false @@ -4835,6 +4850,11 @@ "type": "boolean", "description": "Enable auto-merge for the pull request. When enabled, the PR will be automatically merged once all required checks pass and required approvals are met. Defaults to false.", "default": false + }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added to the pull request. When false, the visible footer content is omitted but XML markers (workflow-id, tracker-id, metadata) are still included for searchability. Defaults to true.", + "default": true } }, "additionalProperties": false, @@ -5290,6 +5310,11 @@ "type": "null", "description": "Allow updating issue body - presence of key indicates field can be updated" }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added when updating the issue body. When false, the visible footer content is omitted but XML markers are still included. Defaults to true. Only applies when 'body' is enabled.", + "default": true + }, "max": { "type": "integer", "description": "Maximum number of issues to update (default: 1)", @@ -5678,6 +5703,11 @@ "type": "string", "description": "Target repository for cross-repo release updates (format: owner/repo). If not specified, updates releases in the workflow's repository.", "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$" + }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added when updating the release body. When false, the visible footer content is omitted. Defaults to true.", + "default": true } }, "additionalProperties": false @@ -6028,6 +6058,12 @@ } ] }, + "footer": { + "type": "boolean", + "description": "Global footer control for all safe outputs. When false, omits visible AI-generated footer content from all created/updated entities (issues, PRs, discussions, releases) while still including XML markers for searchability. Individual safe-output types (create-issue, update-issue, etc.) can override this by specifying their own footer field. Defaults to true.", + "default": true, + "examples": [false, true] + }, "runs-on": { "type": "string", "description": "Runner specification for all safe-outputs jobs (activation, create-issue, add-comment, etc.). Single runner label (e.g., 'ubuntu-slim', 'ubuntu-latest', 'windows-latest', 'self-hosted'). Defaults to 'ubuntu-slim'. See https://github.blog/changelog/2025-10-28-1-vcpu-linux-runner-now-available-in-github-actions-in-public-preview/" diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 0fbc23757a..f4dcdd6882 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -9,6 +9,16 @@ import ( var compilerSafeOutputsConfigLog = logger.New("workflow:compiler_safe_outputs_config") +// getEffectiveFooter returns the effective footer value for a config +// If the local footer is set, use it; otherwise fall back to global footer +// Returns nil if neither is set (default to true in JavaScript) +func getEffectiveFooter(localFooter *bool, globalFooter *bool) *bool { + if localFooter != nil { + return localFooter + } + return globalFooter +} + // handlerConfigBuilder provides a fluent API for building handler configurations type handlerConfigBuilder struct { config map[string]any @@ -111,6 +121,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddIfTrue("group", c.Group). AddIfTrue("close_older_issues", c.CloseOlderIssues). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "add_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -143,6 +154,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfPositive("expires", c.Expires). AddBoolPtr("fallback_to_issue", c.FallbackToIssue). AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "close_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -228,6 +240,7 @@ var handlerRegistry = map[string]handlerBuilder{ return builder. AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -252,6 +265,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_labels", c.AllowedLabels). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -276,6 +290,7 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.UpdateRelease return newHandlerConfigBuilder(). AddIfPositive("max", c.Max). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -313,6 +328,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddDefault("base_branch", "${{ github.ref_name }}"). AddDefault("max_patch_size", maxPatchSize). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). Build() }, "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 2a5d51091f..fc5570e9cd 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -502,6 +502,7 @@ type SafeOutputsConfig struct { RunsOn string `yaml:"runs-on,omitempty"` // Runner configuration for safe-outputs jobs Messages *SafeOutputMessagesConfig `yaml:"messages,omitempty"` // Custom message templates for footer and notifications Mentions *MentionsConfig `yaml:"mentions,omitempty"` // Configuration for @mention filtering in safe outputs + Footer *bool `yaml:"footer,omitempty"` // Global footer control - when false, omits visible footer from all safe outputs (XML markers still included) } // SafeOutputMessagesConfig holds custom message templates for safe-output footer and notification messages diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 4e73c449c5..f7c68b6c93 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -23,6 +23,7 @@ type CreateDiscussionsConfig struct { RequiredCategory string `yaml:"required-category,omitempty"` // Required category for matching when close-older-discussions is enabled Expires int `yaml:"expires,omitempty"` // Hours until the discussion expires and should be automatically closed FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseDiscussionsConfig handles create-discussion configuration @@ -143,6 +144,12 @@ func (c *Compiler) buildCreateOutputDiscussionJob(data *WorkflowData, mainJobNam customEnvVars = append(customEnvVars, " GH_AW_DISCUSSION_FALLBACK_TO_ISSUE: \"true\"\n") } + // Add footer flag if explicitly set to false + if data.SafeOutputs.CreateDiscussions.Footer != nil && !*data.SafeOutputs.CreateDiscussions.Footer { + customEnvVars = append(customEnvVars, " GH_AW_FOOTER: \"false\"\n") + discussionLog.Print("Footer disabled - XML markers will be included but visible footer content will be omitted") + } + // Add environment variable for temporary ID map from create_issue job if createIssueJobName != "" { customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_TEMPORARY_ID_MAP: ${{ needs.%s.outputs.temporary_id_map }}\n", createIssueJobName)) diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index 184a6ef02e..36b21f2212 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -20,6 +20,7 @@ type CreateIssuesConfig struct { CloseOlderIssues bool `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed Group bool `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseIssuesConfig handles create-issue configuration @@ -154,6 +155,12 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str createIssueLog.Print("Close older issues enabled - older issues with same title prefix or labels will be closed") } + // Add footer flag if explicitly set to false + if data.SafeOutputs.CreateIssues.Footer != nil && !*data.SafeOutputs.CreateIssues.Footer { + customEnvVars = append(customEnvVars, " GH_AW_FOOTER: \"false\"\n") + createIssueLog.Print("Footer disabled - XML markers will be included but visible footer content will be omitted") + } + // Add standard environment variables (metadata + staged/target repo) customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.CreateIssues.TargetRepoSlug)...) diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 6dc109edf1..e4b1059f51 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -23,6 +23,7 @@ type CreatePullRequestsConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo) Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs) AutoMerge bool `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // buildCreateOutputPullRequestJob creates the create_pull_request job @@ -105,6 +106,12 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_PR_EXPIRES: \"%d\"\n", data.SafeOutputs.CreatePullRequests.Expires)) } + // Add footer flag if explicitly set to false + if data.SafeOutputs.CreatePullRequests.Footer != nil && !*data.SafeOutputs.CreatePullRequests.Footer { + customEnvVars = append(customEnvVars, " GH_AW_FOOTER: \"false\"\n") + createPRLog.Print("Footer disabled - XML markers will be included but visible footer content will be omitted") + } + // Add standard environment variables (metadata + staged/target repo) customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.CreatePullRequests.TargetRepoSlug)...) diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 1ba1a52d09..13b8ac3fb6 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -355,6 +355,14 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.Mentions = parseMentionsConfig(mentions) } + // Handle global footer flag + if footer, exists := outputMap["footer"]; exists { + if footerBool, ok := footer.(bool); ok { + config.Footer = &footerBool + safeOutputsConfigLog.Printf("Global footer control: %t", footerBool) + } + } + // Handle jobs (safe-jobs must be under safe-outputs) if jobs, exists := outputMap["jobs"]; exists { if jobsMap, ok := jobs.(map[string]any); ok { diff --git a/pkg/workflow/safe_outputs_footer_test.go b/pkg/workflow/safe_outputs_footer_test.go new file mode 100644 index 0000000000..a2184bab36 --- /dev/null +++ b/pkg/workflow/safe_outputs_footer_test.go @@ -0,0 +1,174 @@ +//go:build !integration + +package workflow + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFooterConfiguration(t *testing.T) { + compiler := NewCompiler() + frontmatter := map[string]any{ + "name": "Test", + "safe-outputs": map[string]any{ + "create-issue": map[string]any{"footer": false}, + }, + } + config := compiler.extractSafeOutputsConfig(frontmatter) + require.NotNil(t, config) + require.NotNil(t, config.CreateIssues) + require.NotNil(t, config.CreateIssues.Footer) + assert.False(t, *config.CreateIssues.Footer) +} + +func TestGlobalFooterConfiguration(t *testing.T) { + t.Run("global footer: false applies to all handlers", func(t *testing.T) { + compiler := NewCompiler() + frontmatter := map[string]any{ + "name": "Test", + "safe-outputs": map[string]any{ + "footer": false, // Global footer control + "create-issue": map[string]any{"title-prefix": "[test] "}, + "create-pull-request": nil, + "create-discussion": nil, + "update-issue": map[string]any{"body": nil}, + "update-discussion": map[string]any{"body": nil}, + "update-release": nil, + }, + } + config := compiler.extractSafeOutputsConfig(frontmatter) + require.NotNil(t, config) + require.NotNil(t, config.Footer) + assert.False(t, *config.Footer) + + // Verify global footer is propagated to handlers + workflowData := &WorkflowData{ + Name: "Test", + SafeOutputs: config, + } + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + stepsContent := strings.Join(steps, "") + require.Contains(t, stepsContent, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") + + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + var handlerConfig map[string]any + err := json.Unmarshal([]byte(jsonStr), &handlerConfig) + require.NoError(t, err) + + // All handlers should have footer: false from global setting + if issueConfig, ok := handlerConfig["create_issue"].(map[string]any); ok { + assert.Equal(t, false, issueConfig["footer"], "create_issue should inherit global footer: false") + } + if prConfig, ok := handlerConfig["create_pull_request"].(map[string]any); ok { + assert.Equal(t, false, prConfig["footer"], "create_pull_request should inherit global footer: false") + } + if discussionConfig, ok := handlerConfig["create_discussion"].(map[string]any); ok { + assert.Equal(t, false, discussionConfig["footer"], "create_discussion should inherit global footer: false") + } + if updateIssueConfig, ok := handlerConfig["update_issue"].(map[string]any); ok { + assert.Equal(t, false, updateIssueConfig["footer"], "update_issue should inherit global footer: false") + } + if updateDiscussionConfig, ok := handlerConfig["update_discussion"].(map[string]any); ok { + assert.Equal(t, false, updateDiscussionConfig["footer"], "update_discussion should inherit global footer: false") + } + if updateReleaseConfig, ok := handlerConfig["update_release"].(map[string]any); ok { + assert.Equal(t, false, updateReleaseConfig["footer"], "update_release should inherit global footer: false") + } + } + } + } + }) + + t.Run("local footer overrides global footer", func(t *testing.T) { + compiler := NewCompiler() + frontmatter := map[string]any{ + "name": "Test", + "safe-outputs": map[string]any{ + "footer": false, // Global: hide footer + "create-issue": map[string]any{"title-prefix": "[test] "}, + "create-pull-request": map[string]any{"footer": true}, // Local: show footer + }, + } + config := compiler.extractSafeOutputsConfig(frontmatter) + require.NotNil(t, config) + require.NotNil(t, config.Footer) + assert.False(t, *config.Footer, "Global footer should be false") + require.NotNil(t, config.CreatePullRequests.Footer) + assert.True(t, *config.CreatePullRequests.Footer, "Local PR footer should override to true") + + // Verify in handler config + workflowData := &WorkflowData{ + Name: "Test", + SafeOutputs: config, + } + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + var handlerConfig map[string]any + err := json.Unmarshal([]byte(jsonStr), &handlerConfig) + require.NoError(t, err) + + issueConfig, ok := handlerConfig["create_issue"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, issueConfig["footer"], "create_issue should use global footer: false") + + prConfig, ok := handlerConfig["create_pull_request"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, prConfig["footer"], "create_pull_request should override to footer: true") + } + } + } + }) +} + +func TestFooterInHandlerConfig(t *testing.T) { + compiler := NewCompiler() + workflowData := &WorkflowData{ + Name: "Test", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 1}, + Footer: boolPtr(false), + }, + }, + } + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + stepsContent := strings.Join(steps, "") + require.Contains(t, stepsContent, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") + for _, step := range steps { + if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + if len(parts) == 2 { + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + var config map[string]any + err := json.Unmarshal([]byte(jsonStr), &config) + require.NoError(t, err) + issueConfig, ok := config["create_issue"].(map[string]any) + require.True(t, ok) + assert.Equal(t, false, issueConfig["footer"]) + } + } + } +} diff --git a/pkg/workflow/update_discussion.go b/pkg/workflow/update_discussion.go index 05688aee91..f30bba41ab 100644 --- a/pkg/workflow/update_discussion.go +++ b/pkg/workflow/update_discussion.go @@ -13,6 +13,7 @@ type UpdateDiscussionsConfig struct { Body *bool `yaml:"body,omitempty"` // Allow updating discussion body - presence indicates field can be updated Labels *bool `yaml:"labels,omitempty"` // Allow updating discussion labels - presence indicates field can be updated AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseUpdateDiscussionsConfig handles update-discussion configuration @@ -24,6 +25,7 @@ func (c *Compiler) parseUpdateDiscussionsConfig(outputMap map[string]any) *Updat {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body}, {Name: "labels", Mode: FieldParsingKeyExistence, Dest: &cfg.Labels}, + {Name: "footer", Mode: FieldParsingBoolValue, Dest: &cfg.Footer}, } }, func(cm map[string]any, cfg *UpdateDiscussionsConfig) { diff --git a/pkg/workflow/update_issue.go b/pkg/workflow/update_issue.go index 6063022fab..752a2f0938 100644 --- a/pkg/workflow/update_issue.go +++ b/pkg/workflow/update_issue.go @@ -12,6 +12,7 @@ type UpdateIssuesConfig struct { Status *bool `yaml:"status,omitempty"` // Allow updating issue status (open/closed) - presence indicates field can be updated Title *bool `yaml:"title,omitempty"` // Allow updating issue title - presence indicates field can be updated Body *bool `yaml:"body,omitempty"` // Allow updating issue body - presence indicates field can be updated + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseUpdateIssuesConfig handles update-issue configuration @@ -23,6 +24,7 @@ func (c *Compiler) parseUpdateIssuesConfig(outputMap map[string]any) *UpdateIssu {Name: "status", Mode: FieldParsingKeyExistence, Dest: &cfg.Status}, {Name: "title", Mode: FieldParsingKeyExistence, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingKeyExistence, Dest: &cfg.Body}, + {Name: "footer", Mode: FieldParsingBoolValue, Dest: &cfg.Footer}, } }, nil) } diff --git a/pkg/workflow/update_release.go b/pkg/workflow/update_release.go index df1cfbf09a..4784d861cb 100644 --- a/pkg/workflow/update_release.go +++ b/pkg/workflow/update_release.go @@ -9,6 +9,7 @@ var updateReleaseLog = logger.New("workflow:update_release") // UpdateReleaseConfig holds configuration for updating GitHub releases from agent output type UpdateReleaseConfig struct { UpdateEntityConfig `yaml:",inline"` + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseUpdateReleaseConfig handles update-release configuration @@ -16,6 +17,8 @@ func (c *Compiler) parseUpdateReleaseConfig(outputMap map[string]any) *UpdateRel return parseUpdateEntityConfigTyped(c, outputMap, UpdateEntityRelease, "update-release", updateReleaseLog, func(cfg *UpdateReleaseConfig) []UpdateEntityFieldSpec { - return nil // No entity-specific fields for releases + return []UpdateEntityFieldSpec{ + {Name: "footer", Mode: FieldParsingBoolValue, Dest: &cfg.Footer}, + } }, nil) }