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
6 changes: 6 additions & 0 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -2599,6 +2599,12 @@ safe-outputs:
# (optional)
auto-merge: true

# Base branch for the pull request. Defaults to the workflow's branch
# (github.ref_name) if not specified. Useful for cross-repository PRs targeting
# non-default branches (e.g., 'vnext', 'release/v1.0').
# (optional)
base-branch: "example-value"

# 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.
Expand Down
4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4851,6 +4851,10 @@
"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
},
"base-branch": {
"type": "string",
"description": "Base branch for the pull request. Defaults to the workflow's branch (github.ref_name) if not specified. Useful for cross-repository PRs targeting non-default branches (e.g., 'vnext', 'release/v1.0')."
},
"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.",
Expand Down
13 changes: 9 additions & 4 deletions pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ var handlerRegistry = map[string]handlerBuilder{
if cfg.MaximumPatchSize > 0 {
maxPatchSize = cfg.MaximumPatchSize
}
return newHandlerConfigBuilder().
builder := newHandlerConfigBuilder().
AddIfPositive("max", c.Max).
AddIfNotEmpty("title_prefix", c.TitlePrefix).
AddStringSlice("labels", c.Labels).
Expand All @@ -326,10 +326,15 @@ var handlerRegistry = map[string]handlerBuilder{
AddIfPositive("expires", c.Expires).
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
AddStringSlice("allowed_repos", c.AllowedRepos).
AddDefault("base_branch", "${{ github.ref_name }}").
AddDefault("max_patch_size", maxPatchSize).
AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)).
Build()
AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer))
// Add base_branch - use custom value if specified, otherwise use github.ref_name
if c.BaseBranch != "" {
builder.AddDefault("base_branch", c.BaseBranch)
} else {
builder.AddDefault("base_branch", "${{ github.ref_name }}")
}
return builder.Build()
},
"push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any {
if cfg.PushToPullRequestBranch == nil {
Expand Down
72 changes: 72 additions & 0 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,3 +734,75 @@ func TestAutoEnabledHandlers(t *testing.T) {
})
}
}

// TestCreatePullRequestBaseBranch tests the base-branch field configuration
func TestCreatePullRequestBaseBranch(t *testing.T) {
tests := []struct {
name string
baseBranch string
expectedBaseBranch string
}{
{
name: "custom base branch",
baseBranch: "vnext",
expectedBaseBranch: "vnext",
},
{
name: "default base branch",
baseBranch: "",
expectedBaseBranch: "${{ github.ref_name }}",
},
{
name: "branch with slash",
baseBranch: "release/v1.0",
expectedBaseBranch: "release/v1.0",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompiler()

workflowData := &WorkflowData{
Name: "Test Workflow",
SafeOutputs: &SafeOutputsConfig{
CreatePullRequests: &CreatePullRequestsConfig{
BaseSafeOutputConfig: BaseSafeOutputConfig{
Max: 1,
},
BaseBranch: tt.baseBranch,
},
},
}

var steps []string
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)

require.NotEmpty(t, steps, "Steps should be generated")

// Extract and validate JSON
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]map[string]any
err := json.Unmarshal([]byte(jsonStr), &config)
require.NoError(t, err, "Config JSON should be valid")

prConfig, ok := config["create_pull_request"]
require.True(t, ok, "create_pull_request config should exist")

baseBranch, ok := prConfig["base_branch"]
require.True(t, ok, "base_branch should be in config")

assert.Equal(t, tt.expectedBaseBranch, baseBranch, "base_branch should match expected value")
}
}
}
Comment on lines +784 to +805
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TestCreatePullRequestBaseBranch, the JSON extraction loop doesn’t assert that it actually found/parses the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG line. If the env var generation regresses (or the string format changes), the test can pass without validating anything. Track a found boolean (or extract once with SplitN) and require.True(t, found, ...) after the loop, and consider failing if len(parts) != 2 to avoid silent success.

Suggested change
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]map[string]any
err := json.Unmarshal([]byte(jsonStr), &config)
require.NoError(t, err, "Config JSON should be valid")
prConfig, ok := config["create_pull_request"]
require.True(t, ok, "create_pull_request config should exist")
baseBranch, ok := prConfig["base_branch"]
require.True(t, ok, "base_branch should be in config")
assert.Equal(t, tt.expectedBaseBranch, baseBranch, "base_branch should match expected value")
}
}
}
found := false
for _, step := range steps {
if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") {
parts := strings.SplitN(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ", 2)
require.Len(t, parts, 2, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG line should contain key and value")
found = true
jsonStr := strings.TrimSpace(parts[1])
jsonStr = strings.Trim(jsonStr, "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"")
var config map[string]map[string]any
err := json.Unmarshal([]byte(jsonStr), &config)
require.NoError(t, err, "Config JSON should be valid")
prConfig, ok := config["create_pull_request"]
require.True(t, ok, "create_pull_request config should exist")
baseBranch, ok := prConfig["base_branch"]
require.True(t, ok, "base_branch should be in config")
assert.Equal(t, tt.expectedBaseBranch, baseBranch, "base_branch should match expected value")
}
}
require.True(t, found, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG should be present in steps")

Copilot uses AI. Check for mistakes.
})
}
}
9 changes: 7 additions & 2 deletions pkg/workflow/create_pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified)
Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
}

Expand Down Expand Up @@ -62,8 +63,12 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa
var customEnvVars []string
// Pass the workflow ID for branch naming
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_WORKFLOW_ID: %q\n", mainJobName))
// Pass the base branch from GitHub context
customEnvVars = append(customEnvVars, " GH_AW_BASE_BRANCH: ${{ github.ref_name }}\n")
// Pass the base branch - use custom value if specified, otherwise default to github.ref_name
if data.SafeOutputs.CreatePullRequests.BaseBranch != "" {
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_BASE_BRANCH: %q\n", data.SafeOutputs.CreatePullRequests.BaseBranch))
} else {
customEnvVars = append(customEnvVars, " GH_AW_BASE_BRANCH: ${{ github.ref_name }}\n")
}
customEnvVars = append(customEnvVars, buildTitlePrefixEnvVar("GH_AW_PR_TITLE_PREFIX", data.SafeOutputs.CreatePullRequests.TitlePrefix)...)
customEnvVars = append(customEnvVars, buildLabelsEnvVar("GH_AW_PR_LABELS", data.SafeOutputs.CreatePullRequests.Labels)...)
customEnvVars = append(customEnvVars, buildLabelsEnvVar("GH_AW_PR_ALLOWED_LABELS", data.SafeOutputs.CreatePullRequests.AllowedLabels)...)
Expand Down
180 changes: 180 additions & 0 deletions pkg/workflow/create_pull_request_base_branch_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//go:build integration

package workflow

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestCreatePullRequestWithCustomBaseBranch tests end-to-end workflow compilation with custom base-branch
func TestCreatePullRequestWithCustomBaseBranch(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "base-branch-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)

// Create test workflow with custom base-branch
workflowContent := `---
on: push
permissions:
contents: read
actions: read
issues: read
pull-requests: read
engine: copilot
safe-outputs:
create-pull-request:
target-repo: "microsoft/vscode-docs"
base-branch: vnext
draft: true
---

# Test Workflow

Create a pull request targeting vnext branch in cross-repo.
`

workflowPath := filepath.Join(tmpDir, "test-workflow.md")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("Failed to write workflow file: %v", err)
}

// Compile the workflow
compiler := NewCompiler()
if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Failed to compile workflow: %v", err)
}

// Read the compiled output
outputFile := filepath.Join(tmpDir, "test-workflow.lock.yml")
compiledBytes, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read compiled output: %v", err)
}

compiledContent := string(compiledBytes)

// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains base_branch set to "vnext"
// The JSON is escaped in YAML, so we need to look for the escaped version
if !strings.Contains(compiledContent, `\"base_branch\":\"vnext\"`) {
t.Error("Expected handler config to contain base_branch set to vnext in compiled workflow")
}

// Verify it does NOT contain the default github.ref_name expression
if strings.Contains(compiledContent, `\"base_branch\":\"${{ github.ref_name }}\"`) {
t.Error("Did not expect handler config to use github.ref_name when base-branch is explicitly set")
}
}

// TestCreatePullRequestWithDefaultBaseBranch tests workflow compilation with default base-branch
func TestCreatePullRequestWithDefaultBaseBranch(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "default-base-branch-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)

// Create test workflow without base-branch field
workflowContent := `---
on: push
permissions:
contents: read
actions: read
issues: read
pull-requests: read
engine: copilot
safe-outputs:
create-pull-request:
draft: true
---

# Test Workflow

Create a pull request with default base branch.
`

workflowPath := filepath.Join(tmpDir, "test-default.md")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("Failed to write workflow file: %v", err)
}

// Compile the workflow
compiler := NewCompiler()
if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Failed to compile workflow: %v", err)
}

// Read the compiled output
outputFile := filepath.Join(tmpDir, "test-default.lock.yml")
compiledBytes, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read compiled output: %v", err)
}

compiledContent := string(compiledBytes)

// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG uses github.ref_name by default
// The JSON is escaped in YAML, so we need to look for the escaped version
if !strings.Contains(compiledContent, `\"base_branch\":\"${{ github.ref_name }}\"`) {
t.Error("Expected handler config to use github.ref_name when base-branch is not specified")
}
}

// TestCreatePullRequestWithBranchSlash tests workflow compilation with branch containing slash
func TestCreatePullRequestWithBranchSlash(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "branch-slash-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)

// Create test workflow with base-branch containing slash
workflowContent := `---
on: push
permissions:
contents: read
actions: read
issues: read
pull-requests: read
engine: copilot
safe-outputs:
create-pull-request:
base-branch: release/v1.0
draft: true
---

# Test Workflow

Create a pull request targeting release/v1.0 branch.
`

workflowPath := filepath.Join(tmpDir, "test-slash.md")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("Failed to write workflow file: %v", err)
}

// Compile the workflow
compiler := NewCompiler()
if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Failed to compile workflow: %v", err)
}

// Read the compiled output
outputFile := filepath.Join(tmpDir, "test-slash.lock.yml")
compiledBytes, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read compiled output: %v", err)
}

compiledContent := string(compiledBytes)

// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains base_branch set to "release/v1.0"
// The JSON is escaped in YAML, so we need to look for the escaped version
if !strings.Contains(compiledContent, `\"base_branch\":\"release/v1.0\"`) {
t.Error("Expected handler config to contain base_branch set to release/v1.0 in compiled workflow")
}
}
Loading