diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index cc57f9770c..c91976e1cc 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -9,6 +9,7 @@ const { resolveTarget } = require("./safe_output_helpers.cjs"); const { loadTemporaryIdMap, resolveRepoIssueTarget } = require("./temporary_id.cjs"); const { sleep } = require("./error_recovery.cjs"); const { parseAllowedRepos, validateRepo } = require("./repo_helpers.cjs"); +const { resolvePullRequestRepo, buildBranchInstruction } = require("./pr_helpers.cjs"); async function main() { const result = loadAgentOutput(); @@ -84,6 +85,12 @@ async function main() { core.info(`Default custom instructions: ${defaultCustomInstructions}`); } + // Get base branch configuration for PR creation in target repo + const configuredBaseBranch = process.env.GH_AW_AGENT_BASE_BRANCH?.trim(); + if (configuredBaseBranch) { + core.info(`Configured base branch: ${configuredBaseBranch}`); + } + // Get target configuration (defaults to "triggering") const targetConfig = process.env.GH_AW_AGENT_TARGET?.trim() || "triggering"; core.info(`Target configuration: ${targetConfig}`); @@ -157,6 +164,10 @@ async function main() { let pullRequestOwner = null; let pullRequestRepo = null; let pullRequestRepoId = null; + // Effective base branch: explicit config > fetched default branch from PR repo + let effectiveBaseBranch = configuredBaseBranch || null; + // Resolved default branch fetched from the target PR repo (used in NOT clause of branch instructions) + let resolvedDefaultBranch = null; // Get allowed PR repos configuration for cross-repo validation const allowedPullRequestReposEnv = process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS?.trim(); @@ -178,18 +189,16 @@ async function main() { pullRequestRepo = parts[1]; core.info(`Using pull request repository: ${pullRequestOwner}/${pullRequestRepo}`); - // Fetch the repository ID for the PR repo (needed for GraphQL agentAssignment) + // Fetch the repository ID and default branch for the PR repo try { - const pullRequestRepoQuery = ` - query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - id - } - } - `; - const pullRequestRepoResponse = await github.graphql(pullRequestRepoQuery, { owner: pullRequestOwner, name: pullRequestRepo }); - pullRequestRepoId = pullRequestRepoResponse.repository.id; + const resolved = await resolvePullRequestRepo(github, pullRequestOwner, pullRequestRepo, configuredBaseBranch); + pullRequestRepoId = resolved.repoId; + effectiveBaseBranch = resolved.effectiveBaseBranch; + resolvedDefaultBranch = resolved.resolvedDefaultBranch; core.info(`Pull request repository ID: ${pullRequestRepoId}`); + if (!configuredBaseBranch && effectiveBaseBranch) { + core.info(`Resolved pull request repository default branch: ${effectiveBaseBranch}`); + } } catch (error) { core.setFailed(`Failed to fetch pull request repository ID for ${pullRequestOwner}/${pullRequestRepo}: ${getErrorMessage(error)}`); return; @@ -211,7 +220,12 @@ async function main() { // They are NOT available as per-item overrides in the tool call const model = defaultModel; const customAgent = defaultCustomAgent; - const customInstructions = defaultCustomInstructions; + // Build effective custom instructions: prepend base-branch instruction when needed + let customInstructions = defaultCustomInstructions; + if (effectiveBaseBranch) { + const branchInstruction = buildBranchInstruction(effectiveBaseBranch, resolvedDefaultBranch); + customInstructions = customInstructions ? `${branchInstruction}\n\n${customInstructions}` : branchInstruction; + } // Use these variables to allow temporary IDs to override target repo per-item. // Default to the configured target repo. diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 685c77b0bd..7e2078ba9e 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -58,6 +58,7 @@ describe("assign_to_agent", () => { delete process.env.GH_AW_TEMPORARY_ID_MAP; delete process.env.GH_AW_AGENT_PULL_REQUEST_REPO; delete process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS; + delete process.env.GH_AW_AGENT_BASE_BRANCH; // Reset context to default mockContext.eventName = "issues"; @@ -1163,10 +1164,11 @@ describe("assign_to_agent", () => { // Mock GraphQL responses mockGithub.graphql - // Get PR repository ID + // Get PR repository ID and default branch .mockResolvedValueOnce({ repository: { id: "pull-request-repo-id", + defaultBranchRef: { name: "main" }, }, }) // Find agent @@ -1224,10 +1226,11 @@ describe("assign_to_agent", () => { // Mock GraphQL responses mockGithub.graphql - // Get global PR repository ID (for default-pr-repo) + // Get global PR repository ID and default branch (for default-pr-repo) .mockResolvedValueOnce({ repository: { id: "default-pr-repo-id", + defaultBranchRef: { name: "main" }, }, }) // Get item PR repository ID @@ -1286,10 +1289,11 @@ describe("assign_to_agent", () => { // Mock GraphQL responses mockGithub.graphql - // Get PR repository ID + // Get PR repository ID and default branch .mockResolvedValueOnce({ repository: { id: "auto-allowed-repo-id", + defaultBranchRef: { name: "main" }, }, }) // Find agent @@ -1322,4 +1326,89 @@ describe("assign_to_agent", () => { expect(mockCore.setFailed).not.toHaveBeenCalled(); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/auto-allowed-repo")); }); + + it("should use explicit base-branch when GH_AW_AGENT_BASE_BRANCH is set", async () => { + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/code-repo"; + process.env.GH_AW_AGENT_BASE_BRANCH = "develop"; + setAgentOutput({ + items: [{ type: "assign_to_agent", issue_number: 42, agent: "copilot" }], + errors: [], + }); + + mockGithub.graphql + // Get PR repo ID and default branch + .mockResolvedValueOnce({ repository: { id: "code-repo-id", defaultBranchRef: { name: "main" } } }) + // Find agent + .mockResolvedValueOnce({ repository: { suggestedActors: { nodes: [{ login: "copilot-swe-agent", id: "agent-id" }] } } }) + // Get issue details + .mockResolvedValueOnce({ repository: { issue: { id: "issue-id", assignees: { nodes: [] } } } }) + // Assign agent + .mockResolvedValueOnce({ replaceActorsForAssignable: { __typename: "ReplaceActorsForAssignablePayload" } }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + // Verify the mutation was called with custom instructions containing the branch instruction + const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1]; + expect(lastCall[0]).toContain("customInstructions"); + expect(lastCall[1].customInstructions).toContain("develop"); + // NOT clause should reference the resolved default branch, not hardcoded 'main' + expect(lastCall[1].customInstructions).toContain("NOT from 'main'"); + }); + + it("should auto-resolve non-main default branch from pull-request-repo and pass as instruction", async () => { + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/code-repo"; + // No GH_AW_AGENT_BASE_BRANCH set - should use repo's default branch + setAgentOutput({ + items: [{ type: "assign_to_agent", issue_number: 42, agent: "copilot" }], + errors: [], + }); + + mockGithub.graphql + // Get PR repo ID and default branch (non-main) + .mockResolvedValueOnce({ repository: { id: "code-repo-id", defaultBranchRef: { name: "develop" } } }) + // Find agent + .mockResolvedValueOnce({ repository: { suggestedActors: { nodes: [{ login: "copilot-swe-agent", id: "agent-id" }] } } }) + // Get issue details + .mockResolvedValueOnce({ repository: { issue: { id: "issue-id", assignees: { nodes: [] } } } }) + // Assign agent + .mockResolvedValueOnce({ replaceActorsForAssignable: { __typename: "ReplaceActorsForAssignablePayload" } }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved pull request repository default branch: develop")); + // Verify the mutation was called with custom instructions containing branch info + const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1]; + expect(lastCall[0]).toContain("customInstructions"); + expect(lastCall[1].customInstructions).toContain("develop"); + }); + + it("should inject branch instruction even when pull-request-repo default branch is main (no explicit base-branch)", async () => { + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/code-repo"; + // No GH_AW_AGENT_BASE_BRANCH set; repo default is main + setAgentOutput({ + items: [{ type: "assign_to_agent", issue_number: 42, agent: "copilot" }], + errors: [], + }); + + mockGithub.graphql + // Get PR repo ID and default branch (main) + .mockResolvedValueOnce({ repository: { id: "code-repo-id", defaultBranchRef: { name: "main" } } }) + // Find agent + .mockResolvedValueOnce({ repository: { suggestedActors: { nodes: [{ login: "copilot-swe-agent", id: "agent-id" }] } } }) + // Get issue details + .mockResolvedValueOnce({ repository: { issue: { id: "issue-id", assignees: { nodes: [] } } } }) + // Assign agent + .mockResolvedValueOnce({ replaceActorsForAssignable: { __typename: "ReplaceActorsForAssignablePayload" } }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.setFailed).not.toHaveBeenCalled(); + // Instruction is injected with the resolved default branch name (no NOT clause since it matches) + const lastCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1]; + expect(lastCall[0]).toContain("customInstructions"); + expect(lastCall[1].customInstructions).toContain("main"); + expect(lastCall[1].customInstructions).not.toContain("NOT from"); + }); }); diff --git a/actions/setup/js/pr_helpers.cjs b/actions/setup/js/pr_helpers.cjs index d4897890dd..039b5bf97f 100644 --- a/actions/setup/js/pr_helpers.cjs +++ b/actions/setup/js/pr_helpers.cjs @@ -68,4 +68,46 @@ function getPullRequestNumber(messageItem, context) { return { prNumber: contextPR, error: null }; } -module.exports = { detectForkPR, getPullRequestNumber }; +/** + * Resolves the pull request repository ID and effective base branch. + * Fetches `id` and `defaultBranchRef.name` from the GitHub API. + * The effective base branch is the explicitly configured branch (if any), + * falling back to the repository's actual default branch. + * + * @param {import("@actions/github-script").AsyncFunctionArguments["github"]} github + * @param {string} owner + * @param {string} repo + * @param {string|undefined} configuredBaseBranch - explicitly configured base branch (may be undefined) + * @returns {Promise<{repoId: string, effectiveBaseBranch: string|null, resolvedDefaultBranch: string|null}>} + */ +async function resolvePullRequestRepo(github, owner, repo, configuredBaseBranch) { + const query = ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + defaultBranchRef { name } + } + } + `; + const response = await github.graphql(query, { owner, name: repo }); + const repoId = response.repository.id; + const resolvedDefaultBranch = response.repository.defaultBranchRef?.name ?? null; + const effectiveBaseBranch = configuredBaseBranch || resolvedDefaultBranch; + return { repoId, effectiveBaseBranch, resolvedDefaultBranch }; +} + +/** + * Builds a branch instruction string to prepend to custom instructions. + * Tells the agent which branch to create its work branch from, with an + * optional NOT clause when the effective branch differs from the repo default. + * + * @param {string} effectiveBaseBranch - the branch the agent should branch from + * @param {string|null} resolvedDefaultBranch - the repo's actual default branch (used in NOT clause) + * @returns {string} + */ +function buildBranchInstruction(effectiveBaseBranch, resolvedDefaultBranch) { + const notClause = resolvedDefaultBranch && resolvedDefaultBranch !== effectiveBaseBranch ? `, NOT from '${resolvedDefaultBranch}'` : ""; + return `IMPORTANT: Create your branch from the '${effectiveBaseBranch}' branch${notClause}.`; +} + +module.exports = { detectForkPR, getPullRequestNumber, resolvePullRequestRepo, buildBranchInstruction }; diff --git a/actions/setup/js/pr_helpers.test.cjs b/actions/setup/js/pr_helpers.test.cjs index fe06f4c837..05548a79db 100644 --- a/actions/setup/js/pr_helpers.test.cjs +++ b/actions/setup/js/pr_helpers.test.cjs @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; describe("pr_helpers.cjs", () => { let detectForkPR; @@ -294,3 +294,59 @@ describe("pr_helpers.cjs", () => { }); }); }); + +describe("resolvePullRequestRepo", () => { + const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); + + it("returns repoId, effectiveBaseBranch from explicit config, and resolvedDefaultBranch", async () => { + const fakeGithub = { + graphql: vi.fn().mockResolvedValue({ repository: { id: "repo-id", defaultBranchRef: { name: "develop" } } }), + }; + const result = await resolvePullRequestRepo(fakeGithub, "owner", "repo", "feature"); + expect(result.repoId).toBe("repo-id"); + expect(result.resolvedDefaultBranch).toBe("develop"); + // explicit config wins over fetched default + expect(result.effectiveBaseBranch).toBe("feature"); + }); + + it("falls back to repo default branch when no explicit base branch configured", async () => { + const fakeGithub = { + graphql: vi.fn().mockResolvedValue({ repository: { id: "repo-id", defaultBranchRef: { name: "trunk" } } }), + }; + const result = await resolvePullRequestRepo(fakeGithub, "owner", "repo", undefined); + expect(result.repoId).toBe("repo-id"); + expect(result.resolvedDefaultBranch).toBe("trunk"); + expect(result.effectiveBaseBranch).toBe("trunk"); + }); + + it("handles missing defaultBranchRef gracefully", async () => { + const fakeGithub = { + graphql: vi.fn().mockResolvedValue({ repository: { id: "repo-id", defaultBranchRef: null } }), + }; + const result = await resolvePullRequestRepo(fakeGithub, "owner", "repo", undefined); + expect(result.repoId).toBe("repo-id"); + expect(result.resolvedDefaultBranch).toBeNull(); + expect(result.effectiveBaseBranch).toBeNull(); + }); +}); + +describe("buildBranchInstruction", () => { + const { buildBranchInstruction } = require("./pr_helpers.cjs"); + + it("produces a plain instruction when effective branch equals resolved default", () => { + const instruction = buildBranchInstruction("main", "main"); + expect(instruction).toBe("IMPORTANT: Create your branch from the 'main' branch."); + expect(instruction).not.toContain("NOT from"); + }); + + it("includes NOT clause when effective branch differs from resolved default", () => { + const instruction = buildBranchInstruction("feature", "develop"); + expect(instruction).toBe("IMPORTANT: Create your branch from the 'feature' branch, NOT from 'develop'."); + }); + + it("omits NOT clause when resolvedDefaultBranch is null", () => { + const instruction = buildBranchInstruction("feature", null); + expect(instruction).toBe("IMPORTANT: Create your branch from the 'feature' branch."); + expect(instruction).not.toContain("NOT from"); + }); +}); diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index c97defed83..31fb1765e7 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1106,6 +1106,7 @@ safe-outputs: target-repo: "owner/repo" # where the issue lives (cross-repository) pull-request-repo: "owner/repo" # where the PR should be created (may differ from issue repo) allowed-pull-request-repos: [owner/repo1, owner/repo2] # additional allowed PR repositories + base-branch: "develop" # target branch for PR (default: target repo's default branch) github-token: ${{ secrets.SOME_CUSTOM_TOKEN }} # optional custom token for permissions ``` @@ -1126,6 +1127,8 @@ When `pull-request-repo` is configured, Copilot will create the pull request in The repository specified by `pull-request-repo` is automatically allowed - you don't need to list it in `allowed-pull-request-repos`. Use `allowed-pull-request-repos` to specify additional repositories where PRs can be created. +Use `base-branch` to specify which branch in the target repository the pull request should target. When omitted, the target repository's actual default branch is used automatically. Only relevant when `pull-request-repo` is configured. + **Assignee Filtering:** When `allowed` list is configured, existing agent assignees not in the list are removed while regular user assignees are preserved. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0b31502295..c1547455e0 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5470,6 +5470,10 @@ "description": "If true, the workflow continues gracefully when agent assignment fails (e.g., due to missing token or insufficient permissions), logging a warning instead of failing. Default is false. Useful for workflows that should not fail when agent assignment is optional.", "default": false }, + "base-branch": { + "type": "string", + "description": "Base branch for pull request creation in the target repository. Defaults to the target repo's default branch. Only relevant when pull-request-repo is configured." + }, "github-token": { "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." diff --git a/pkg/workflow/assign_to_agent.go b/pkg/workflow/assign_to_agent.go index b196f9fbba..2e5ca7207c 100644 --- a/pkg/workflow/assign_to_agent.go +++ b/pkg/workflow/assign_to_agent.go @@ -18,6 +18,7 @@ type AssignToAgentConfig struct { IgnoreIfError bool `yaml:"ignore-if-error,omitempty"` // If true, workflow continues when agent assignment fails PullRequestRepoSlug string `yaml:"pull-request-repo,omitempty"` // Target repository for PR creation in format "owner/repo" (where the issue lives may differ) AllowedPullRequestRepos []string `yaml:"allowed-pull-request-repos,omitempty"` // List of additional repositories that PRs can be created in (beyond pull-request-repo which is automatically allowed) + BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for PR creation in target repo (defaults to target repo's default branch) } // parseAssignToAgentConfig handles assign-to-agent configuration @@ -42,8 +43,8 @@ func (c *Compiler) parseAssignToAgentConfig(outputMap map[string]any) *AssignToA config.Max = 1 } - assignToAgentLog.Printf("Parsed assign-to-agent config: default_agent=%s, default_model=%s, default_custom_agent=%s, allowed_count=%d, target=%s, max=%d, pull_request_repo=%s", - config.DefaultAgent, config.DefaultModel, config.DefaultCustomAgent, len(config.Allowed), config.Target, config.Max, config.PullRequestRepoSlug) + assignToAgentLog.Printf("Parsed assign-to-agent config: default_agent=%s, default_model=%s, default_custom_agent=%s, allowed_count=%d, target=%s, max=%d, pull_request_repo=%s, base_branch=%s", + config.DefaultAgent, config.DefaultModel, config.DefaultCustomAgent, len(config.Allowed), config.Target, config.Max, config.PullRequestRepoSlug, config.BaseBranch) return &config } diff --git a/pkg/workflow/compiler_safe_outputs_specialized.go b/pkg/workflow/compiler_safe_outputs_specialized.go index 0cd34eddbc..d3bff636f4 100644 --- a/pkg/workflow/compiler_safe_outputs_specialized.go +++ b/pkg/workflow/compiler_safe_outputs_specialized.go @@ -68,6 +68,11 @@ func (c *Compiler) buildAssignToAgentStepConfig(data *WorkflowData, mainJobName customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_PULL_REQUEST_REPO: %q\n", cfg.PullRequestRepoSlug)) } + // Add base branch environment variable for PR creation in target repo + if cfg.BaseBranch != "" { + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_BASE_BRANCH: %q\n", cfg.BaseBranch)) + } + // Add allowed PR repos list environment variable (comma-separated) if len(cfg.AllowedPullRequestRepos) > 0 { allowedPullRequestReposStr := "" diff --git a/pkg/workflow/tool_description_enhancer.go b/pkg/workflow/tool_description_enhancer.go index c9869a9883..a73b5ffac5 100644 --- a/pkg/workflow/tool_description_enhancer.go +++ b/pkg/workflow/tool_description_enhancer.go @@ -302,6 +302,9 @@ func enhanceToolDescription(toolName, baseDescription string, safeOutputs *SafeO if config.Max > 0 { constraints = append(constraints, fmt.Sprintf("Maximum %d issue(s) can be assigned to agent.", config.Max)) } + if config.BaseBranch != "" { + constraints = append(constraints, fmt.Sprintf("Pull requests will target the %q branch.", config.BaseBranch)) + } } case "update_project":