Skip to content

Commit 9513a84

Browse files
authored
Add base-branch field for cross-repo PRs targeting non-default branches (#15089)
1 parent 2578c21 commit 9513a84

6 files changed

Lines changed: 278 additions & 6 deletions

File tree

docs/src/content/docs/reference/frontmatter-full.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2599,6 +2599,12 @@ safe-outputs:
25992599
# (optional)
26002600
auto-merge: true
26012601

2602+
# Base branch for the pull request. Defaults to the workflow's branch
2603+
# (github.ref_name) if not specified. Useful for cross-repository PRs targeting
2604+
# non-default branches (e.g., 'vnext', 'release/v1.0').
2605+
# (optional)
2606+
base-branch: "example-value"
2607+
26022608
# Controls whether AI-generated footer is added to the pull request. When false,
26032609
# the visible footer content is omitted but XML markers (workflow-id, tracker-id,
26042610
# metadata) are still included for searchability. Defaults to true.

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4851,6 +4851,10 @@
48514851
"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.",
48524852
"default": false
48534853
},
4854+
"base-branch": {
4855+
"type": "string",
4856+
"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')."
4857+
},
48544858
"footer": {
48554859
"type": "boolean",
48564860
"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.",

pkg/workflow/compiler_safe_outputs_config.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ var handlerRegistry = map[string]handlerBuilder{
315315
if cfg.MaximumPatchSize > 0 {
316316
maxPatchSize = cfg.MaximumPatchSize
317317
}
318-
return newHandlerConfigBuilder().
318+
builder := newHandlerConfigBuilder().
319319
AddIfPositive("max", c.Max).
320320
AddIfNotEmpty("title_prefix", c.TitlePrefix).
321321
AddStringSlice("labels", c.Labels).
@@ -326,10 +326,15 @@ var handlerRegistry = map[string]handlerBuilder{
326326
AddIfPositive("expires", c.Expires).
327327
AddIfNotEmpty("target-repo", c.TargetRepoSlug).
328328
AddStringSlice("allowed_repos", c.AllowedRepos).
329-
AddDefault("base_branch", "${{ github.ref_name }}").
330329
AddDefault("max_patch_size", maxPatchSize).
331-
AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer)).
332-
Build()
330+
AddBoolPtr("footer", getEffectiveFooter(c.Footer, cfg.Footer))
331+
// Add base_branch - use custom value if specified, otherwise use github.ref_name
332+
if c.BaseBranch != "" {
333+
builder.AddDefault("base_branch", c.BaseBranch)
334+
} else {
335+
builder.AddDefault("base_branch", "${{ github.ref_name }}")
336+
}
337+
return builder.Build()
333338
},
334339
"push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any {
335340
if cfg.PushToPullRequestBranch == nil {

pkg/workflow/compiler_safe_outputs_config_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,3 +734,75 @@ func TestAutoEnabledHandlers(t *testing.T) {
734734
})
735735
}
736736
}
737+
738+
// TestCreatePullRequestBaseBranch tests the base-branch field configuration
739+
func TestCreatePullRequestBaseBranch(t *testing.T) {
740+
tests := []struct {
741+
name string
742+
baseBranch string
743+
expectedBaseBranch string
744+
}{
745+
{
746+
name: "custom base branch",
747+
baseBranch: "vnext",
748+
expectedBaseBranch: "vnext",
749+
},
750+
{
751+
name: "default base branch",
752+
baseBranch: "",
753+
expectedBaseBranch: "${{ github.ref_name }}",
754+
},
755+
{
756+
name: "branch with slash",
757+
baseBranch: "release/v1.0",
758+
expectedBaseBranch: "release/v1.0",
759+
},
760+
}
761+
762+
for _, tt := range tests {
763+
t.Run(tt.name, func(t *testing.T) {
764+
compiler := NewCompiler()
765+
766+
workflowData := &WorkflowData{
767+
Name: "Test Workflow",
768+
SafeOutputs: &SafeOutputsConfig{
769+
CreatePullRequests: &CreatePullRequestsConfig{
770+
BaseSafeOutputConfig: BaseSafeOutputConfig{
771+
Max: 1,
772+
},
773+
BaseBranch: tt.baseBranch,
774+
},
775+
},
776+
}
777+
778+
var steps []string
779+
compiler.addHandlerManagerConfigEnvVar(&steps, workflowData)
780+
781+
require.NotEmpty(t, steps, "Steps should be generated")
782+
783+
// Extract and validate JSON
784+
for _, step := range steps {
785+
if strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") {
786+
parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ")
787+
if len(parts) == 2 {
788+
jsonStr := strings.TrimSpace(parts[1])
789+
jsonStr = strings.Trim(jsonStr, "\"")
790+
jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"")
791+
792+
var config map[string]map[string]any
793+
err := json.Unmarshal([]byte(jsonStr), &config)
794+
require.NoError(t, err, "Config JSON should be valid")
795+
796+
prConfig, ok := config["create_pull_request"]
797+
require.True(t, ok, "create_pull_request config should exist")
798+
799+
baseBranch, ok := prConfig["base_branch"]
800+
require.True(t, ok, "base_branch should be in config")
801+
802+
assert.Equal(t, tt.expectedBaseBranch, baseBranch, "base_branch should match expected value")
803+
}
804+
}
805+
}
806+
})
807+
}
808+
}

pkg/workflow/create_pull_request.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type CreatePullRequestsConfig struct {
2323
AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo)
2424
Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs)
2525
AutoMerge bool `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request when all required checks pass
26+
BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified)
2627
Footer *bool `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.
2728
}
2829

@@ -62,8 +63,12 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa
6263
var customEnvVars []string
6364
// Pass the workflow ID for branch naming
6465
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_WORKFLOW_ID: %q\n", mainJobName))
65-
// Pass the base branch from GitHub context
66-
customEnvVars = append(customEnvVars, " GH_AW_BASE_BRANCH: ${{ github.ref_name }}\n")
66+
// Pass the base branch - use custom value if specified, otherwise default to github.ref_name
67+
if data.SafeOutputs.CreatePullRequests.BaseBranch != "" {
68+
customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_BASE_BRANCH: %q\n", data.SafeOutputs.CreatePullRequests.BaseBranch))
69+
} else {
70+
customEnvVars = append(customEnvVars, " GH_AW_BASE_BRANCH: ${{ github.ref_name }}\n")
71+
}
6772
customEnvVars = append(customEnvVars, buildTitlePrefixEnvVar("GH_AW_PR_TITLE_PREFIX", data.SafeOutputs.CreatePullRequests.TitlePrefix)...)
6873
customEnvVars = append(customEnvVars, buildLabelsEnvVar("GH_AW_PR_LABELS", data.SafeOutputs.CreatePullRequests.Labels)...)
6974
customEnvVars = append(customEnvVars, buildLabelsEnvVar("GH_AW_PR_ALLOWED_LABELS", data.SafeOutputs.CreatePullRequests.AllowedLabels)...)
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//go:build integration
2+
3+
package workflow
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestCreatePullRequestWithCustomBaseBranch tests end-to-end workflow compilation with custom base-branch
13+
func TestCreatePullRequestWithCustomBaseBranch(t *testing.T) {
14+
tmpDir, err := os.MkdirTemp("", "base-branch-test")
15+
if err != nil {
16+
t.Fatalf("Failed to create temp dir: %v", err)
17+
}
18+
defer os.RemoveAll(tmpDir)
19+
20+
// Create test workflow with custom base-branch
21+
workflowContent := `---
22+
on: push
23+
permissions:
24+
contents: read
25+
actions: read
26+
issues: read
27+
pull-requests: read
28+
engine: copilot
29+
safe-outputs:
30+
create-pull-request:
31+
target-repo: "microsoft/vscode-docs"
32+
base-branch: vnext
33+
draft: true
34+
---
35+
36+
# Test Workflow
37+
38+
Create a pull request targeting vnext branch in cross-repo.
39+
`
40+
41+
workflowPath := filepath.Join(tmpDir, "test-workflow.md")
42+
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
43+
t.Fatalf("Failed to write workflow file: %v", err)
44+
}
45+
46+
// Compile the workflow
47+
compiler := NewCompiler()
48+
if err := compiler.CompileWorkflow(workflowPath); err != nil {
49+
t.Fatalf("Failed to compile workflow: %v", err)
50+
}
51+
52+
// Read the compiled output
53+
outputFile := filepath.Join(tmpDir, "test-workflow.lock.yml")
54+
compiledBytes, err := os.ReadFile(outputFile)
55+
if err != nil {
56+
t.Fatalf("Failed to read compiled output: %v", err)
57+
}
58+
59+
compiledContent := string(compiledBytes)
60+
61+
// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains base_branch set to "vnext"
62+
// The JSON is escaped in YAML, so we need to look for the escaped version
63+
if !strings.Contains(compiledContent, `\"base_branch\":\"vnext\"`) {
64+
t.Error("Expected handler config to contain base_branch set to vnext in compiled workflow")
65+
}
66+
67+
// Verify it does NOT contain the default github.ref_name expression
68+
if strings.Contains(compiledContent, `\"base_branch\":\"${{ github.ref_name }}\"`) {
69+
t.Error("Did not expect handler config to use github.ref_name when base-branch is explicitly set")
70+
}
71+
}
72+
73+
// TestCreatePullRequestWithDefaultBaseBranch tests workflow compilation with default base-branch
74+
func TestCreatePullRequestWithDefaultBaseBranch(t *testing.T) {
75+
tmpDir, err := os.MkdirTemp("", "default-base-branch-test")
76+
if err != nil {
77+
t.Fatalf("Failed to create temp dir: %v", err)
78+
}
79+
defer os.RemoveAll(tmpDir)
80+
81+
// Create test workflow without base-branch field
82+
workflowContent := `---
83+
on: push
84+
permissions:
85+
contents: read
86+
actions: read
87+
issues: read
88+
pull-requests: read
89+
engine: copilot
90+
safe-outputs:
91+
create-pull-request:
92+
draft: true
93+
---
94+
95+
# Test Workflow
96+
97+
Create a pull request with default base branch.
98+
`
99+
100+
workflowPath := filepath.Join(tmpDir, "test-default.md")
101+
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
102+
t.Fatalf("Failed to write workflow file: %v", err)
103+
}
104+
105+
// Compile the workflow
106+
compiler := NewCompiler()
107+
if err := compiler.CompileWorkflow(workflowPath); err != nil {
108+
t.Fatalf("Failed to compile workflow: %v", err)
109+
}
110+
111+
// Read the compiled output
112+
outputFile := filepath.Join(tmpDir, "test-default.lock.yml")
113+
compiledBytes, err := os.ReadFile(outputFile)
114+
if err != nil {
115+
t.Fatalf("Failed to read compiled output: %v", err)
116+
}
117+
118+
compiledContent := string(compiledBytes)
119+
120+
// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG uses github.ref_name by default
121+
// The JSON is escaped in YAML, so we need to look for the escaped version
122+
if !strings.Contains(compiledContent, `\"base_branch\":\"${{ github.ref_name }}\"`) {
123+
t.Error("Expected handler config to use github.ref_name when base-branch is not specified")
124+
}
125+
}
126+
127+
// TestCreatePullRequestWithBranchSlash tests workflow compilation with branch containing slash
128+
func TestCreatePullRequestWithBranchSlash(t *testing.T) {
129+
tmpDir, err := os.MkdirTemp("", "branch-slash-test")
130+
if err != nil {
131+
t.Fatalf("Failed to create temp dir: %v", err)
132+
}
133+
defer os.RemoveAll(tmpDir)
134+
135+
// Create test workflow with base-branch containing slash
136+
workflowContent := `---
137+
on: push
138+
permissions:
139+
contents: read
140+
actions: read
141+
issues: read
142+
pull-requests: read
143+
engine: copilot
144+
safe-outputs:
145+
create-pull-request:
146+
base-branch: release/v1.0
147+
draft: true
148+
---
149+
150+
# Test Workflow
151+
152+
Create a pull request targeting release/v1.0 branch.
153+
`
154+
155+
workflowPath := filepath.Join(tmpDir, "test-slash.md")
156+
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
157+
t.Fatalf("Failed to write workflow file: %v", err)
158+
}
159+
160+
// Compile the workflow
161+
compiler := NewCompiler()
162+
if err := compiler.CompileWorkflow(workflowPath); err != nil {
163+
t.Fatalf("Failed to compile workflow: %v", err)
164+
}
165+
166+
// Read the compiled output
167+
outputFile := filepath.Join(tmpDir, "test-slash.lock.yml")
168+
compiledBytes, err := os.ReadFile(outputFile)
169+
if err != nil {
170+
t.Fatalf("Failed to read compiled output: %v", err)
171+
}
172+
173+
compiledContent := string(compiledBytes)
174+
175+
// Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains base_branch set to "release/v1.0"
176+
// The JSON is escaped in YAML, so we need to look for the escaped version
177+
if !strings.Contains(compiledContent, `\"base_branch\":\"release/v1.0\"`) {
178+
t.Error("Expected handler config to contain base_branch set to release/v1.0 in compiled workflow")
179+
}
180+
}

0 commit comments

Comments
 (0)