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
36 changes: 25 additions & 11 deletions actions/setup/js/assign_to_agent.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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.
Expand Down
95 changes: 92 additions & 3 deletions actions/setup/js/assign_to_agent.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
});
});
44 changes: 43 additions & 1 deletion actions/setup/js/pr_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
58 changes: 57 additions & 1 deletion actions/setup/js/pr_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
});
});
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.

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 @@ -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."
Expand Down
5 changes: 3 additions & 2 deletions pkg/workflow/assign_to_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
5 changes: 5 additions & 0 deletions pkg/workflow/compiler_safe_outputs_specialized.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/tool_description_enhancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Loading