diff --git a/actions/setup/js/update_pr_description_helpers.cjs b/actions/setup/js/update_pr_description_helpers.cjs index 45e57f4b071..7d1f0df30c3 100644 --- a/actions/setup/js/update_pr_description_helpers.cjs +++ b/actions/setup/js/update_pr_description_helpers.cjs @@ -7,18 +7,27 @@ * @module update_pr_description_helpers */ -const { getFooterMessage } = require("./messages_footer.cjs"); +const { generateFooterWithMessages } = require("./messages_footer.cjs"); +const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); /** * Build the AI footer with workflow attribution - * Uses the messages system to support custom templates from frontmatter + * Uses the common generateFooterWithMessages helper (includes install instructions, + * missing info sections, blocked domains, and XML metadata marker). * @param {string} workflowName - Name of the workflow * @param {string} runUrl - URL of the workflow run * @returns {string} AI attribution footer */ function buildAIFooter(workflowName, runUrl) { - return "\n\n" + getFooterMessage({ workflowName, runUrl }); + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? ""; + // Use typeof guard since context is a global injected by the Actions Script runtime + const ctx = typeof context !== "undefined" ? context : null; + const triggeringIssueNumber = ctx?.payload?.issue?.number; + const triggeringPRNumber = ctx?.payload?.pull_request?.number; + const triggeringDiscussionNumber = ctx?.payload?.discussion?.number; + return generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(); } /** @@ -77,7 +86,10 @@ function findIsland(body, workflowId) { */ function updateBody(params) { const { currentBody, newContent, operation, workflowName, runUrl, workflowId, includeFooter = true } = params; + // When footer is enabled use the full footer (includes install instructions, XML marker, etc.) + // When footer is disabled still add standalone workflow-id marker for searchability const aiFooter = includeFooter ? buildAIFooter(workflowName, runUrl) : ""; + const workflowIdMarker = !includeFooter && workflowId ? `\n\n${generateWorkflowIdMarker(workflowId)}` : ""; // Sanitize new content to prevent injection attacks const sanitizedNewContent = sanitizeContent(newContent); @@ -85,7 +97,7 @@ function updateBody(params) { if (operation === "replace") { // Replace: use new content with optional AI footer core.info("Operation: replace (full body replacement)"); - return sanitizedNewContent + aiFooter; + return sanitizedNewContent + aiFooter + workflowIdMarker; } if (operation === "replace-island") { @@ -97,7 +109,7 @@ function updateBody(params) { core.info(`Operation: replace-island (updating existing island for workflow ${workflowId})`); const startMarker = buildIslandStartMarker(workflowId); const endMarker = buildIslandEndMarker(workflowId); - const islandContent = `${startMarker}\n${sanitizedNewContent}${aiFooter}\n${endMarker}`; + const islandContent = `${startMarker}\n${sanitizedNewContent}${aiFooter}${workflowIdMarker}\n${endMarker}`; const before = currentBody.substring(0, island.startIndex); const after = currentBody.substring(island.endIndex); @@ -107,7 +119,7 @@ function updateBody(params) { core.info(`Operation: replace-island (island not found for workflow ${workflowId}, falling back to append)`); const startMarker = buildIslandStartMarker(workflowId); const endMarker = buildIslandEndMarker(workflowId); - const islandContent = `${startMarker}\n${sanitizedNewContent}${aiFooter}\n${endMarker}`; + const islandContent = `${startMarker}\n${sanitizedNewContent}${aiFooter}${workflowIdMarker}\n${endMarker}`; const appendSection = `\n\n---\n\n${islandContent}`; return currentBody + appendSection; } @@ -116,13 +128,13 @@ function updateBody(params) { if (operation === "prepend") { // Prepend: add content, AI footer (if enabled), and horizontal line at the start core.info("Operation: prepend (add to start with separator)"); - const prependSection = `${sanitizedNewContent}${aiFooter}\n\n---\n\n`; + const prependSection = `${sanitizedNewContent}${aiFooter}${workflowIdMarker}\n\n---\n\n`; return prependSection + currentBody; } // Default to append core.info("Operation: append (add to end with separator)"); - const appendSection = `\n\n---\n\n${sanitizedNewContent}${aiFooter}`; + const appendSection = `\n\n---\n\n${sanitizedNewContent}${aiFooter}${workflowIdMarker}`; return currentBody + appendSection; } diff --git a/actions/setup/js/update_pr_description_helpers.test.cjs b/actions/setup/js/update_pr_description_helpers.test.cjs index 05ba0e5fd40..0a2e9c41c57 100644 --- a/actions/setup/js/update_pr_description_helpers.test.cjs +++ b/actions/setup/js/update_pr_description_helpers.test.cjs @@ -458,7 +458,7 @@ describe("update_pr_description_helpers.cjs", () => { workflowId: "test-workflow", includeFooter: false, }); - expect(result).toBe("Replacement"); + expect(result).toBe("Replacement\n\n"); expect(result).not.toContain("Generated by"); }); diff --git a/actions/setup/js/update_pull_request.cjs b/actions/setup/js/update_pull_request.cjs index a71406c1ad2..5d6f14b6977 100644 --- a/actions/setup/js/update_pull_request.cjs +++ b/actions/setup/js/update_pull_request.cjs @@ -25,9 +25,10 @@ async function executePRUpdate(github, context, prNumber, updateData) { // Handle body operation (append/prepend/replace/replace-island) const operation = updateData._operation || "replace"; const 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) { @@ -52,6 +53,7 @@ async function executePRUpdate(github, context, prNumber, updateData) { workflowName, runUrl, workflowId, + includeFooter, // Pass footer flag to helper }); core.info(`Will update body (length: ${apiData.body.length})`); @@ -129,6 +131,9 @@ function buildPRUpdateData(item, config) { }; } + // Pass footer config to executeUpdate (default to true) + updateData._includeFooter = config.footer !== false; + return { success: true, data: updateData }; } diff --git a/docs/src/content/docs/reference/footers.md b/docs/src/content/docs/reference/footers.md index f992e647e1b..bff0d8a286b 100644 --- a/docs/src/content/docs/reference/footers.md +++ b/docs/src/content/docs/reference/footers.md @@ -23,7 +23,7 @@ safe-outputs: When `footer: false` is set: - **Visible footer content is omitted** - No AI-generated attribution text appears in the item body - **XML markers are preserved** - Hidden workflow-id and tracker-id markers remain for searchability -- **All safe output types affected** - Applies to create-issue, create-pull-request, create-discussion, update-issue, update-discussion, and update-release +- **All safe output types affected** - Applies to create-issue, create-pull-request, create-discussion, update-issue, update-pull-request, update-discussion, and update-release ## Per-Handler Footer Control diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 6cb16816ada..df3f66482ee 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -396,6 +396,7 @@ safe-outputs: update-pull-request: title: true # enable title updates (default: true) body: true # enable body updates (default: true) + footer: false # omit AI-generated footer from body updates (default: true) max: 1 # max updates (default: 1) target: "*" # "triggering" (default), "*", or number target-repo: "owner/repo" # cross-repository diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 49540fe0c3b..f1c1902deea 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5734,6 +5734,11 @@ "description": "Default operation for body updates: 'append' (add to end), 'prepend' (add to start), or 'replace' (overwrite completely). Defaults to 'replace' if not specified.", "enum": ["append", "prepend", "replace"] }, + "footer": { + "type": "boolean", + "description": "Controls whether AI-generated footer is added when updating the pull request 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 pull requests to update (default: 1)", diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 35043b17043..b1523489d7a 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -485,6 +485,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtrOrDefault("allow_title", c.Title, true). AddBoolPtrOrDefault("allow_body", c.Body, true). AddStringPtr("default_operation", c.Operation). + AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). Build() diff --git a/pkg/workflow/safe_outputs_footer_test.go b/pkg/workflow/safe_outputs_footer_test.go index a2184bab361..739831b07e8 100644 --- a/pkg/workflow/safe_outputs_footer_test.go +++ b/pkg/workflow/safe_outputs_footer_test.go @@ -39,6 +39,7 @@ func TestGlobalFooterConfiguration(t *testing.T) { "update-issue": map[string]any{"body": nil}, "update-discussion": map[string]any{"body": nil}, "update-release": nil, + "update-pull-request": map[string]any{"body": nil}, }, } config := compiler.extractSafeOutputsConfig(frontmatter) @@ -86,6 +87,9 @@ func TestGlobalFooterConfiguration(t *testing.T) { if updateReleaseConfig, ok := handlerConfig["update_release"].(map[string]any); ok { assert.Equal(t, false, updateReleaseConfig["footer"], "update_release should inherit global footer: false") } + if updatePRConfig, ok := handlerConfig["update_pull_request"].(map[string]any); ok { + assert.Equal(t, false, updatePRConfig["footer"], "update_pull_request should inherit global footer: false") + } } } } diff --git a/pkg/workflow/update_pull_request.go b/pkg/workflow/update_pull_request.go index 9ca0152095e..11867458229 100644 --- a/pkg/workflow/update_pull_request.go +++ b/pkg/workflow/update_pull_request.go @@ -12,6 +12,7 @@ type UpdatePullRequestsConfig struct { Title *bool `yaml:"title,omitempty"` // Allow updating PR title - defaults to true, set to false to disable Body *bool `yaml:"body,omitempty"` // Allow updating PR body - defaults to true, set to false to disable Operation *string `yaml:"operation,omitempty"` // Default operation for body updates: "append", "prepend", or "replace" (defaults to "replace") + Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted. } // parseUpdatePullRequestsConfig handles update-pull-request configuration @@ -24,6 +25,7 @@ func (c *Compiler) parseUpdatePullRequestsConfig(outputMap map[string]any) *Upda return []UpdateEntityFieldSpec{ {Name: "title", Mode: FieldParsingBoolValue, Dest: &cfg.Title}, {Name: "body", Mode: FieldParsingBoolValue, Dest: &cfg.Body}, + {Name: "footer", Mode: FieldParsingBoolValue, Dest: &cfg.Footer}, } }, func(configMap map[string]any, cfg *UpdatePullRequestsConfig) { // Parse operation field