From f7b01c560d30ae952df313182d59449c7c7871ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:23:48 +0000 Subject: [PATCH 1/2] Add codemod migrating messages ET suffix to AIC suffix Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...tive_tokens_suffix_to_ai_credits_suffix.go | 178 ++++++++++++++++++ ...tokens_suffix_to_ai_credits_suffix_test.go | 131 +++++++++++++ pkg/cli/fix_codemods.go | 79 ++++---- pkg/cli/fix_codemods_test.go | 2 + 4 files changed, 351 insertions(+), 39 deletions(-) create mode 100644 pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix.go create mode 100644 pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go diff --git a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix.go b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix.go new file mode 100644 index 00000000000..c650965cd0b --- /dev/null +++ b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix.go @@ -0,0 +1,178 @@ +package cli + +import ( + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var messagesEffectiveTokensSuffixToAICreditsSuffixCodemodLog = logger.New("cli:codemod_messages_effective_tokens_suffix_to_ai_credits_suffix") + +const ( + effectiveTokensSuffixPlaceholder = "{effective_tokens_suffix}" + aiCreditsSuffixPlaceholder = "{ai_credits_suffix}" +) + +func getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() Codemod { + return Codemod{ + ID: "messages-effective-tokens-suffix-to-ai-credits-suffix", + Name: "Migrate safe-outputs messages ET suffix placeholder to AI credits suffix", + Description: "Rewrites safe-outputs.messages templates from '{effective_tokens_suffix}' to '{ai_credits_suffix}' so custom message footers render AI Credits (AIC) instead of Effective Tokens (ET).", + IntroducedIn: "1.0.48", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + messagesMap, ok := getSafeOutputsMessagesMap(frontmatter) + if !ok || !messagesNeedsAICreditsSuffixMigration(messagesMap) { + return content, false, nil + } + + newContent, applied, err := applyFrontmatterLineTransform(content, migrateMessagesEffectiveTokensSuffixToAICreditsSuffix) + if applied { + messagesEffectiveTokensSuffixToAICreditsSuffixCodemodLog.Print("Migrated safe-outputs.messages placeholders from effective_tokens_suffix to ai_credits_suffix") + } + return newContent, applied, err + }, + } +} + +func getSafeOutputsMessagesMap(frontmatter map[string]any) (map[string]any, bool) { + if frontmatter == nil { + return nil, false + } + safeOutputsAny, ok := frontmatter["safe-outputs"] + if !ok { + return nil, false + } + safeOutputsMap, ok := safeOutputsAny.(map[string]any) + if !ok { + return nil, false + } + messagesAny, ok := safeOutputsMap["messages"] + if !ok { + return nil, false + } + messagesMap, ok := messagesAny.(map[string]any) + return messagesMap, ok +} + +func messagesNeedsAICreditsSuffixMigration(messagesMap map[string]any) bool { + for _, value := range messagesMap { + text, ok := value.(string) + if !ok { + continue + } + if strings.Contains(text, effectiveTokensSuffixPlaceholder) { + return true + } + } + return false +} + +func migrateMessagesEffectiveTokensSuffixToAICreditsSuffix(lines []string) ([]string, bool) { + result := make([]string, 0, len(lines)) + modified := false + + inSafeOutputs := false + safeOutputsIndent := "" + safeOutputsChildIndent := "" + inMessages := false + messagesIndent := "" + messagesChildIndent := "" + inBlockScalar := false + blockScalarIndent := "" + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + indent := getIndentation(line) + + if inBlockScalar { + if trimmed == "" || len(indent) > len(blockScalarIndent) { + updated := strings.ReplaceAll(line, effectiveTokensSuffixPlaceholder, aiCreditsSuffixPlaceholder) + if updated != line { + modified = true + } + result = append(result, updated) + continue + } + inBlockScalar = false + blockScalarIndent = "" + } + + if !strings.HasPrefix(trimmed, "#") { + if inMessages && hasExitedBlock(line, messagesIndent) { + inMessages = false + messagesIndent = "" + messagesChildIndent = "" + } + if inSafeOutputs && hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputs = false + safeOutputsIndent = "" + safeOutputsChildIndent = "" + inMessages = false + messagesIndent = "" + messagesChildIndent = "" + } + } + + if strings.HasPrefix(trimmed, "safe-outputs:") { + inSafeOutputs = true + safeOutputsIndent = indent + safeOutputsChildIndent = "" + inMessages = false + messagesIndent = "" + messagesChildIndent = "" + result = append(result, line) + continue + } + + if inSafeOutputs && isDescendant(indent, safeOutputsIndent) && strings.HasSuffix(trimmed, ":") && !strings.HasPrefix(trimmed, "#") { + if safeOutputsChildIndent == "" { + safeOutputsChildIndent = indent + } + if indent == safeOutputsChildIndent && trimmed == "messages:" { + inMessages = true + messagesIndent = indent + messagesChildIndent = "" + } + result = append(result, line) + continue + } + + if inMessages && isDescendant(indent, messagesIndent) && trimmed != "" && !strings.HasPrefix(trimmed, "#") { + if messagesChildIndent == "" { + messagesChildIndent = indent + } + if indent == messagesChildIndent && strings.Contains(trimmed, ":") { + updated := strings.ReplaceAll(line, effectiveTokensSuffixPlaceholder, aiCreditsSuffixPlaceholder) + if updated != line { + modified = true + } + result = append(result, updated) + + parts := strings.SplitN(updated, ":", 2) + if len(parts) == 2 && isBlockScalarIndicator(parts[1]) { + inBlockScalar = true + blockScalarIndent = indent + } + continue + } + } + + result = append(result, line) + } + + return result, modified +} + +func isBlockScalarIndicator(valueSegment string) bool { + valueWithoutComment := valueSegment + if idx := strings.Index(valueSegment, "#"); idx >= 0 { + valueWithoutComment = valueSegment[:idx] + } + trimmed := strings.TrimSpace(valueWithoutComment) + switch trimmed { + case "|", "|-", "|+", ">", ">-", ">+": + return true + default: + return false + } +} diff --git a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go new file mode 100644 index 00000000000..eb527b8f7d2 --- /dev/null +++ b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go @@ -0,0 +1,131 @@ +//go:build !integration + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod(t *testing.T) { + codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() + + assert.Equal(t, "messages-effective-tokens-suffix-to-ai-credits-suffix", codemod.ID) + assert.Equal(t, "Migrate safe-outputs messages ET suffix placeholder to AI credits suffix", codemod.Name) + assert.NotEmpty(t, codemod.Description) + assert.Equal(t, "1.0.48", codemod.IntroducedIn) + require.NotNil(t, codemod.Apply) +} + +func TestMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod_MigratesMessagesTemplates(t *testing.T) { + codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() + + content := `--- +safe-outputs: + messages: + footer: "> Run {effective_tokens_suffix}" + run-failure: "Failed {effective_tokens_suffix}" +--- + +# Workflow` + + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "messages": map[string]any{ + "footer": "> Run {effective_tokens_suffix}", + "run-failure": "Failed {effective_tokens_suffix}", + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.True(t, applied) + assert.Contains(t, result, `footer: "> Run {ai_credits_suffix}"`) + assert.Contains(t, result, `run-failure: "Failed {ai_credits_suffix}"`) + assert.NotContains(t, result, "{effective_tokens_suffix}") + assert.Contains(t, result, "\n# Workflow") +} + +func TestMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod_NoOpWhenPlaceholderMissing(t *testing.T) { + codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() + + content := `--- +safe-outputs: + messages: + footer: "> Run {ai_credits_suffix}" +---` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "messages": map[string]any{ + "footer": "> Run {ai_credits_suffix}", + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, result) +} + +func TestMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod_IdempotentAfterMigration(t *testing.T) { + codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() + + content := `--- +safe-outputs: + messages: + footer: "> Run {ai_credits_suffix}" +---` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "messages": map[string]any{ + "footer": "> Run {ai_credits_suffix}", + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, result) +} + +func TestMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod_PreservesCommentsIndentationBodyAndBlockScalars(t *testing.T) { + codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() + + content := `--- +safe-outputs: + messages: + # keep this comment + footer: "> Run {effective_tokens_suffix}" # keep inline comment + run-failure: | + Failed {effective_tokens_suffix} + Retry {effective_tokens_suffix} +on: workflow_dispatch +--- + +# Workflow Body` + + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "messages": map[string]any{ + "footer": "> Run {effective_tokens_suffix}", + "run-failure": "Failed {effective_tokens_suffix}\nRetry {effective_tokens_suffix}", + }, + }, + "on": "workflow_dispatch", + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.True(t, applied) + assert.Contains(t, result, ` # keep this comment`) + assert.Contains(t, result, `footer: "> Run {ai_credits_suffix}" # keep inline comment`) + assert.Contains(t, result, " Failed {ai_credits_suffix}") + assert.Contains(t, result, " Retry {ai_credits_suffix}") + assert.Contains(t, result, "\non: workflow_dispatch\n") + assert.Contains(t, result, "\n# Workflow Body") +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 037a7ccb710..c3f80ada67e 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -39,45 +39,46 @@ func GetAllCodemods() []Codemod { getDiscussionTriggerCategoriesLowercaseCodemod(), getMCPModeToTypeCodemod(), getInstallScriptURLCodemod(), - getBashAnonymousRemovalCodemod(), // Replace bash: with bash: false - getBashSingleQuotedArgsCodemod(), // Rewrite single-quoted bash args to double-quoted form - getActivationOutputsCodemod(), // Transform needs.activation.outputs.* to steps.sanitized.outputs.* - getRolesToOnRolesCodemod(), // Move top-level roles to on.roles - getBotsToOnBotsCodemod(), // Move top-level bots to on.bots - getEngineStepsToTopLevelCodemod(), // Move engine.steps to top-level steps - getEngineMaxRunsToTopLevelCodemod(), // Move engine.max-runs to top-level max-turns - getMaxRunsToMaxTurnsCodemod(), // Rename top-level max-runs to max-turns - getEngineMaxTurnsToTopLevelCodemod(), // Move engine.max-turns to top-level max-turns - getStepsRunSecretsToEnvCodemod(), // Move all ${{ ... }} expressions in step run fields to step env bindings - getEngineEnvSecretsCodemod(), // Remove unsafe secret-bearing engine.env entries - getAssignToAgentDefaultAgentCodemod(), // Rename deprecated default-agent to name in assign-to-agent - getPlaywrightDomainsToNetworkAllowedCodemod(), // Migrate tools.playwright.allowed_domains to network.allowed - getExpiresIntegerToDayStringCodemod(), // Convert expires integer (days) to string with 'd' suffix - getGitHubAppCodemod(), // Rename deprecated 'app' to 'github-app' - getGitHubAppClientIDCodemod(), // Rename deprecated github-app.app-id to github-app.client-id - getSafeOutputRequireTitlePrefixCodemod(), // Rename deprecated safe-outputs title-prefix constraint fields - getSafeOutputMergePRConstraintsCodemod(), // Rename deprecated merge-pull-request allowed-labels/allowed-branches - getSafeOutputAddReviewerAllowlistsCodemod(), // Rename deprecated add-reviewer reviewers/team-reviewers - getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts - getRateLimitToUserRateLimitCodemod(), // Rename rate-limit to user-rate-limit with max key migration - getEffectiveTokensToAICreditsCodemod(), // Migrate obsolete effective-token budget keys to AI credits keys - getSerenaToSharedImportCodemod(), // Migrate removed tools.serena to shared/mcp/serena.md import - getWorkflowRunBranchesCodemod(), // Add default branches to bare on.workflow_run trigger - getCheckoutPersistCredentialsFalseCodemod(), // Add with.persist-credentials: false to actions/checkout steps - getPullRequestTargetCheckoutFalseCodemod(), // Add checkout: false for pull_request_target workflows when safe - getDependabotPermissionsCodemod(), // Add vulnerability-alerts: read when dependabot toolset is used - getGitHubReposToAllowedReposCodemod(), // Rename deprecated tools.github.repos to tools.github.allowed-repos - getCopilotRequestsFeatureToPermissionsCodemod(), // Migrate features.copilot-requests to permissions.copilot-requests - getByokCopilotFeatureRemovalCodemod(), // Remove deprecated features.byok-copilot (Copilot BYOK is default) - getInlineAgentsFeatureRemovalCodemod(), // Remove deprecated features.inline-agents (inline sub-agents now default) - getCliProxyFeatureToGitHubModeCodemod(), // Migrate features.cli-proxy: true to tools.github.mode: gh-proxy - getDIFCProxyToIntegrityProxyCodemod(), // Migrate deprecated features.difc-proxy to tools.github.integrity-proxy - getMountAsCLIsToCLIProxyCodemod(), // Rename tools.mount-as-clis to tools.cli-proxy and remove features.mcp-cli - getSandboxMCPContainerRemovalCodemod(), // Remove deprecated sandbox.mcp.container (now managed internally) - getSandboxMCPVersionRemovalCodemod(), // Remove deprecated sandbox.mcp.version (now managed internally) - getSandboxAgentFalseRemovalCodemod(), // Remove deprecated sandbox.agent: false (rejected in strict mode) - getInferToDisableModelInvocationCodemod(), // Migrate deprecated 'infer' to 'disable-model-invocation' - getRunInstallScriptsToRuntimesNodeCodemod(), // Move top-level run-install-scripts under runtimes.node + getBashAnonymousRemovalCodemod(), // Replace bash: with bash: false + getBashSingleQuotedArgsCodemod(), // Rewrite single-quoted bash args to double-quoted form + getActivationOutputsCodemod(), // Transform needs.activation.outputs.* to steps.sanitized.outputs.* + getRolesToOnRolesCodemod(), // Move top-level roles to on.roles + getBotsToOnBotsCodemod(), // Move top-level bots to on.bots + getEngineStepsToTopLevelCodemod(), // Move engine.steps to top-level steps + getEngineMaxRunsToTopLevelCodemod(), // Move engine.max-runs to top-level max-turns + getMaxRunsToMaxTurnsCodemod(), // Rename top-level max-runs to max-turns + getEngineMaxTurnsToTopLevelCodemod(), // Move engine.max-turns to top-level max-turns + getStepsRunSecretsToEnvCodemod(), // Move all ${{ ... }} expressions in step run fields to step env bindings + getEngineEnvSecretsCodemod(), // Remove unsafe secret-bearing engine.env entries + getAssignToAgentDefaultAgentCodemod(), // Rename deprecated default-agent to name in assign-to-agent + getPlaywrightDomainsToNetworkAllowedCodemod(), // Migrate tools.playwright.allowed_domains to network.allowed + getExpiresIntegerToDayStringCodemod(), // Convert expires integer (days) to string with 'd' suffix + getGitHubAppCodemod(), // Rename deprecated 'app' to 'github-app' + getGitHubAppClientIDCodemod(), // Rename deprecated github-app.app-id to github-app.client-id + getSafeOutputRequireTitlePrefixCodemod(), // Rename deprecated safe-outputs title-prefix constraint fields + getSafeOutputMergePRConstraintsCodemod(), // Rename deprecated merge-pull-request allowed-labels/allowed-branches + getSafeOutputAddReviewerAllowlistsCodemod(), // Rename deprecated add-reviewer reviewers/team-reviewers + getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts + getRateLimitToUserRateLimitCodemod(), // Rename rate-limit to user-rate-limit with max key migration + getEffectiveTokensToAICreditsCodemod(), // Migrate obsolete effective-token budget keys to AI credits keys + getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod(), // Migrate safe-outputs.messages ET suffix placeholders to AI credits suffix placeholders + getSerenaToSharedImportCodemod(), // Migrate removed tools.serena to shared/mcp/serena.md import + getWorkflowRunBranchesCodemod(), // Add default branches to bare on.workflow_run trigger + getCheckoutPersistCredentialsFalseCodemod(), // Add with.persist-credentials: false to actions/checkout steps + getPullRequestTargetCheckoutFalseCodemod(), // Add checkout: false for pull_request_target workflows when safe + getDependabotPermissionsCodemod(), // Add vulnerability-alerts: read when dependabot toolset is used + getGitHubReposToAllowedReposCodemod(), // Rename deprecated tools.github.repos to tools.github.allowed-repos + getCopilotRequestsFeatureToPermissionsCodemod(), // Migrate features.copilot-requests to permissions.copilot-requests + getByokCopilotFeatureRemovalCodemod(), // Remove deprecated features.byok-copilot (Copilot BYOK is default) + getInlineAgentsFeatureRemovalCodemod(), // Remove deprecated features.inline-agents (inline sub-agents now default) + getCliProxyFeatureToGitHubModeCodemod(), // Migrate features.cli-proxy: true to tools.github.mode: gh-proxy + getDIFCProxyToIntegrityProxyCodemod(), // Migrate deprecated features.difc-proxy to tools.github.integrity-proxy + getMountAsCLIsToCLIProxyCodemod(), // Rename tools.mount-as-clis to tools.cli-proxy and remove features.mcp-cli + getSandboxMCPContainerRemovalCodemod(), // Remove deprecated sandbox.mcp.container (now managed internally) + getSandboxMCPVersionRemovalCodemod(), // Remove deprecated sandbox.mcp.version (now managed internally) + getSandboxAgentFalseRemovalCodemod(), // Remove deprecated sandbox.agent: false (rejected in strict mode) + getInferToDisableModelInvocationCodemod(), // Migrate deprecated 'infer' to 'disable-model-invocation' + getRunInstallScriptsToRuntimesNodeCodemod(), // Move top-level run-install-scripts under runtimes.node } fixCodemodsLog.Printf("Loaded codemod registry: %d codemods available", len(codemods)) return codemods diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 36576ed8bd3..d552e556d7a 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -87,6 +87,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) { "safe-inputs-to-mcp-scripts", "rate-limit-to-user-rate-limit", "effective-tokens-to-ai-credits", + "messages-effective-tokens-suffix-to-ai-credits-suffix", "engine-max-runs-to-top-level", "max-runs-to-max-turns", "engine-max-turns-to-top-level", @@ -175,6 +176,7 @@ func expectedCodemodOrder() []string { "safe-inputs-to-mcp-scripts", "rate-limit-to-user-rate-limit", "effective-tokens-to-ai-credits", + "messages-effective-tokens-suffix-to-ai-credits-suffix", "serena-tools-to-shared-import", "workflow-run-branches-default", "checkout-persist-credentials-false", From 7fd4e57349162696bf00557fac6fc730afb37054 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:24:37 +0000 Subject: [PATCH 2/2] Refine tests for ET suffix codemod migration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...essages_effective_tokens_suffix_to_ai_credits_suffix_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go index eb527b8f7d2..abe8ef2862e 100644 --- a/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go +++ b/pkg/cli/codemod_messages_effective_tokens_suffix_to_ai_credits_suffix_test.go @@ -93,7 +93,7 @@ safe-outputs: assert.Equal(t, content, result) } -func TestMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod_PreservesCommentsIndentationBodyAndBlockScalars(t *testing.T) { +func TestMessagesETSuffixCodemod_PreservesFormattingAndBlockScalars(t *testing.T) { codemod := getMessagesEffectiveTokensSuffixToAICreditsSuffixCodemod() content := `---