Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions pkg/cli/codemod_pull_request_target_checkout_false.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package cli

import (
"regexp"
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var pullRequestTargetCheckoutFalseCodemodLog = logger.New("cli:codemod_pull_request_target_checkout_false")
var gitCheckoutPattern = regexp.MustCompile(`\bgit\s+checkout(?:\s|$)`)

// getPullRequestTargetCheckoutFalseCodemod adds checkout: false for pull_request_target workflows
// when checkout is not disabled and no explicit checkout command is detected in workflow content.
func getPullRequestTargetCheckoutFalseCodemod() Codemod {
return Codemod{
ID: "pull-request-target-checkout-false",
Name: "Add checkout: false for pull_request_target",
Description: "Adds checkout: false to workflows using on.pull_request_target when checkout is not disabled and no explicit checkout command is detected",
IntroducedIn: "1.0.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
if isFrontmatterStrictFalse(frontmatter) {
return content, false, nil
}

if !hasPullRequestTargetTrigger(frontmatter) || isPullRequestTargetCheckoutDisabled(frontmatter) {
return content, false, nil
}

if hasExplicitCheckoutCommands(content) {
pullRequestTargetCheckoutFalseCodemodLog.Print("Skipping pull_request_target checkout codemod: explicit checkout command detected")
return content, false, nil
}

newContent, applied, err := applyFrontmatterLineTransform(content, ensureCheckoutFalseForPullRequestTarget)
if applied {
pullRequestTargetCheckoutFalseCodemodLog.Print("Added checkout: false for pull_request_target workflow")
}
return newContent, applied, err
},
}
}

func hasPullRequestTargetTrigger(frontmatter map[string]any) bool {
onAny, hasOn := frontmatter["on"]
if !hasOn {
return false
}

switch on := onAny.(type) {
case map[string]any:
_, hasPullRequestTarget := on["pull_request_target"]
return hasPullRequestTarget
case []any:
for _, entry := range on {
event, ok := entry.(string)
if ok && strings.TrimSpace(event) == "pull_request_target" {
return true
}
}
case []string:
for _, event := range on {
if strings.TrimSpace(event) == "pull_request_target" {
return true
}
}
case string:
return strings.TrimSpace(on) == "pull_request_target"
}

return false
}

func isPullRequestTargetCheckoutDisabled(frontmatter map[string]any) bool {
checkoutAny, hasCheckout := frontmatter["checkout"]
if !hasCheckout {
return false
}

checkoutDisabled, ok := checkoutAny.(bool)
return ok && !checkoutDisabled
}

func hasExplicitCheckoutCommands(content string) bool {
lowerContent := strings.ToLower(content)

unsafeCheckoutPatterns := []string{
"actions/checkout",
"uses: actions/checkout",
"gh pr checkout",
Comment on lines +83 to +90
"git checkout ",
"git checkout\n",
"refs/pull/",
}

for _, pattern := range unsafeCheckoutPatterns {
if strings.Contains(lowerContent, pattern) {
return true
}
}

return gitCheckoutPattern.MatchString(lowerContent)
}

func ensureCheckoutFalseForPullRequestTarget(lines []string) ([]string, bool) {
onIdx := -1
onEnd := len(lines)

for i, line := range lines {
trimmed := strings.TrimSpace(line)
if !isTopLevelKey(line) {
continue
}

if strings.HasPrefix(trimmed, "checkout:") {
checkoutLine, modified := normalizeCheckoutFalseLine(line)
if !modified {
return lines, false
}
updated := append([]string(nil), lines...)
updated[i] = checkoutLine
return updated, true
}

if strings.HasPrefix(trimmed, "on:") {
onIdx = i
for j := i + 1; j < len(lines); j++ {
if isTopLevelKey(lines[j]) {
onEnd = j
break
}
}
}
}

insertAt := 0
if onIdx >= 0 {
insertAt = onEnd
}

result := make([]string, 0, len(lines)+1)
result = append(result, lines[:insertAt]...)
result = append(result, "checkout: false")
result = append(result, lines[insertAt:]...)
return result, true
}

func normalizeCheckoutFalseLine(line string) (string, bool) {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "checkout: false") {
return line, false
}

valueAndComment := strings.TrimPrefix(line, "checkout:")
comment := ""
if idx := strings.Index(valueAndComment, "#"); idx >= 0 {
commentStart := idx
for commentStart > 0 {
prev := valueAndComment[commentStart-1]
if prev != ' ' && prev != '\t' {
break
}
commentStart--
}
comment = valueAndComment[commentStart:]
}

if comment == "" {
return "checkout: false", true
}

return "checkout: false" + comment, true
Comment on lines +156 to +172
}
170 changes: 170 additions & 0 deletions pkg/cli/codemod_pull_request_target_checkout_false_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//go:build !integration

package cli

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPullRequestTargetCheckoutFalseCodemod(t *testing.T) {
codemod := getPullRequestTargetCheckoutFalseCodemod()

t.Run("adds checkout false after on block when missing", func(t *testing.T) {
content := `---
on:
pull_request_target:
description: Review PR metadata
---

# Prompt
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.True(t, applied, "codemod should apply when checkout is missing")
assert.Contains(t, result, "checkout: false", "codemod should add checkout: false")
assert.Contains(t, result, "pull_request_target:\ncheckout: false\ndescription:", "checkout should be inserted after on block")
})

t.Run("normalizes checkout true to false", func(t *testing.T) {
content := `---
on:
pull_request_target:
checkout: true
---
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
"checkout": true,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.True(t, applied, "codemod should apply when checkout is true")
assert.Contains(t, result, "checkout: false", "codemod should set checkout to false")
assert.NotContains(t, result, "checkout: true", "codemod should remove checkout: true")
})

t.Run("preserves inline comment spacing when normalizing checkout", func(t *testing.T) {
content := `---
on:
pull_request_target:
checkout: true # keep-comment
---
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
"checkout": true,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.True(t, applied, "codemod should apply when checkout is true")
assert.Contains(t, result, "checkout: false # keep-comment", "codemod should keep space before inline comment")
assert.NotContains(t, result, "checkout: false# keep-comment", "codemod should not collapse comment spacing")
})

t.Run("does not modify when checkout false already exists", func(t *testing.T) {
content := `---
on:
pull_request_target:
checkout: false
---
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
"checkout": false,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.False(t, applied, "codemod should not apply when checkout is already false")
assert.Equal(t, content, result, "content should remain unchanged")
})

t.Run("does not modify non pull_request_target workflow", func(t *testing.T) {
content := `---
on:
pull_request:
---
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request": map[string]any{},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.False(t, applied, "codemod should not apply without pull_request_target")
assert.Equal(t, content, result, "content should remain unchanged")
})

t.Run("does not modify when explicit checkout command exists", func(t *testing.T) {
content := `---
on:
pull_request_target:
---

Run gh pr checkout ${{ github.event.pull_request.number }} before tests.
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.False(t, applied, "codemod should not apply when explicit checkout command exists")
assert.Equal(t, content, result, "content should remain unchanged")
})

t.Run("does not modify when git checkout uses tab separator", func(t *testing.T) {
content := "---\non:\n pull_request_target:\n---\n\nUse git checkout\tfeature-branch before tests.\n"
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.False(t, applied, "codemod should not apply when git checkout is present with tab separator")
assert.Equal(t, content, result, "content should remain unchanged")
})

t.Run("does not modify when strict is explicitly false", func(t *testing.T) {
content := `---
on:
pull_request_target:
strict: false
---
`
frontmatter := map[string]any{
"on": map[string]any{
"pull_request_target": map[string]any{},
},
"strict": false,
}

result, applied, err := codemod.Apply(content, frontmatter)
require.NoError(t, err, "codemod should not return an error")
assert.False(t, applied, "codemod should not apply when strict is false")
assert.Equal(t, content, result, "content should remain unchanged")
})
}
1 change: 1 addition & 0 deletions pkg/cli/fix_codemods.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func GetAllCodemods() []Codemod {
getPluginsToDependenciesCodemod(), // Migrate plugins to dependencies (plugins removed in favour of APM)
getSerenaToSharedImportCodemod(), // Migrate removed tools.serena to shared/mcp/serena.md import
getWorkflowRunBranchesCodemod(), // Add default branches to bare on.workflow_run trigger
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
getByokCopilotFeatureRemovalCodemod(), // Remove deprecated features.byok-copilot (Copilot BYOK is default)
Expand Down
4 changes: 3 additions & 1 deletion pkg/cli/fix_codemods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func TestGetAllCodemods_ReturnsAllCodemods(t *testing.T) {
codemods := GetAllCodemods()

// Verify we have the expected number of codemods
expectedCount := 41
expectedCount := 42
assert.Len(t, codemods, expectedCount, "Should return all %d codemods", expectedCount)

// Verify all codemods have required fields
Expand Down Expand Up @@ -85,6 +85,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) {
"engine-env-secrets-to-engine-config",
"serena-tools-to-shared-import",
"workflow-run-branches-default",
"pull-request-target-checkout-false",
"dependabot-toolset-permissions",
"features-byok-copilot-removal",
"mount-as-clis-to-cli-proxy",
Expand Down Expand Up @@ -144,6 +145,7 @@ func TestGetAllCodemods_InExpectedOrder(t *testing.T) {
"plugins-to-dependencies",
"serena-tools-to-shared-import",
"workflow-run-branches-default",
"pull-request-target-checkout-false",
"dependabot-toolset-permissions",
"github-repos-to-allowed-repos",
"features-byok-copilot-removal",
Expand Down