Skip to content

Commit a4a8bf2

Browse files
Copilotpelikhan
andauthored
feat: support runtime/dynamic reviewers, team-reviewers, and assignees in create-pull-request (#42621)
* Initial plan * Initial plan for dynamic reviewers/assignees in create-pull-request Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * fix create-pull-request runtime reviewer config Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * chore: outline integration test plan Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * test: add runtime create-pull-request workflow coverage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * test: harden runtime routing integration assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * test: clarify runtime routing workflow coverage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * test: align runtime routing wording Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 9644038 commit a4a8bf2

9 files changed

Lines changed: 269 additions & 10 deletions

actions/setup/js/create_pull_request.cjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ async function main(config = {}) {
770770
core.info(`Configured team reviewers: ${configTeamReviewers.join(", ")}`);
771771
}
772772
if (configAssignees && configAssignees.length > 0) {
773-
core.info(`Configured assignees (for fallback issues): ${configAssignees.join(", ")}`);
773+
core.info(`Configured assignees (for pull request and fallback issues): ${configAssignees.join(", ")}`);
774774
}
775775
if (titlePrefix) {
776776
core.info(`Title prefix: ${titlePrefix}`);
@@ -2295,6 +2295,21 @@ ${patchPreview}`;
22952295
}
22962296
}
22972297

2298+
if (configAssignees && configAssignees.length > 0) {
2299+
core.info(`Assigning assignees to pull request #${pullRequest.number}: ${JSON.stringify(configAssignees)}`);
2300+
try {
2301+
await githubClient.rest.issues.addAssignees({
2302+
owner: repoParts.owner,
2303+
repo: repoParts.repo,
2304+
issue_number: pullRequest.number,
2305+
assignees: configAssignees,
2306+
});
2307+
core.info(`Assigned assignees to pull request #${pullRequest.number}: ${JSON.stringify(configAssignees)}`);
2308+
} catch (assigneeError) {
2309+
core.warning(`Failed to assign assignees to PR #${pullRequest.number}: ${assigneeError instanceof Error ? assigneeError.message : String(assigneeError)}`);
2310+
}
2311+
}
2312+
22982313
const requestChangesSections = [];
22992314
if (manifestProtectionRequestReview && manifestProtectionRequestReview.length > 0) {
23002315
const protectedFilesReviewTemplatePath = getPromptPath("manifest_protection_request_changes_review.md");

actions/setup/js/create_pull_request.test.cjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ describe("create_pull_request - draft policy enforcement", () => {
8484
},
8585
issues: {
8686
addLabels: vi.fn().mockResolvedValue({}),
87+
addAssignees: vi.fn().mockResolvedValue({}),
8788
},
8889
},
8990
graphql: vi.fn(),
@@ -1151,6 +1152,7 @@ describe("create_pull_request - auto-close-issue configuration", () => {
11511152
},
11521153
issues: {
11531154
addLabels: vi.fn().mockResolvedValue({}),
1155+
addAssignees: vi.fn().mockResolvedValue({}),
11541156
},
11551157
},
11561158
graphql: vi.fn(),
@@ -2159,6 +2161,7 @@ describe("create_pull_request - configured reviewers", () => {
21592161
},
21602162
issues: {
21612163
addLabels: vi.fn().mockResolvedValue({}),
2164+
addAssignees: vi.fn().mockResolvedValue({}),
21622165
},
21632166
},
21642167
graphql: vi.fn(),
@@ -2277,6 +2280,60 @@ describe("create_pull_request - configured reviewers", () => {
22772280
expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to request reviewers"));
22782281
});
22792282

2283+
it("should assign configured assignees to the created PR", async () => {
2284+
const { main } = require("./create_pull_request.cjs");
2285+
const handler = await main({ assignees: ["user1", "user2"], allow_empty: true });
2286+
2287+
const result = await handler({ title: "Test PR", body: "Test body" }, {});
2288+
2289+
expect(result.success).toBe(true);
2290+
expect(global.github.rest.issues.addAssignees).toHaveBeenCalledWith(
2291+
expect.objectContaining({
2292+
owner: "test-owner",
2293+
repo: "test-repo",
2294+
issue_number: 42,
2295+
assignees: ["user1", "user2"],
2296+
})
2297+
);
2298+
});
2299+
2300+
it("should not assign assignees to the created PR when none remain after sanitization", async () => {
2301+
const { main } = require("./create_pull_request.cjs");
2302+
const handler = await main({ assignees: ["copilot"], allow_empty: true });
2303+
2304+
const result = await handler({ title: "Test PR", body: "Test body" }, {});
2305+
2306+
expect(result.success).toBe(true);
2307+
expect(global.github.rest.issues.addAssignees).not.toHaveBeenCalled();
2308+
});
2309+
2310+
it("should continue successfully even if addAssignees fails", async () => {
2311+
global.github.rest.issues.addAssignees.mockRejectedValue(new Error("API error"));
2312+
2313+
const { main } = require("./create_pull_request.cjs");
2314+
const handler = await main({ assignees: ["user1"], allow_empty: true });
2315+
2316+
const result = await handler({ title: "Test PR", body: "Test body" }, {});
2317+
2318+
expect(result.success).toBe(true);
2319+
expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to assign assignees"));
2320+
});
2321+
2322+
it("should strip copilot before assigning PR assignees", async () => {
2323+
const { main } = require("./create_pull_request.cjs");
2324+
const handler = await main({ assignees: ["copilot", "user1"], allow_empty: true });
2325+
2326+
const result = await handler({ title: "Test PR", body: "Test body" }, {});
2327+
2328+
expect(result.success).toBe(true);
2329+
expect(global.github.rest.issues.addAssignees).toHaveBeenCalledWith(
2330+
expect.objectContaining({
2331+
issue_number: 42,
2332+
assignees: ["user1"],
2333+
})
2334+
);
2335+
});
2336+
22802337
it("should retry addLabels on race condition and warn after all retries exhausted", async () => {
22812338
// GitHub API transiently fails to resolve the PR node ID immediately after creation.
22822339
// withRetry retries 5 times (6 total calls); after exhaustion it should warn but NOT fall back to an issue.

docs/src/content/docs/reference/safe-outputs-pull-requests.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ safe-outputs:
3333
labels: [automation] # labels to attach
3434
reviewers: [user1, copilot] # reviewers (use 'copilot' for bot)
3535
team-reviewers: [platform-reviewers] # team slugs to request as reviewers
36-
assignees: [user1] # assignees for fallback issues
36+
assignees: [user1] # assignees for the created PR and any fallback issue
3737
draft: true # create as draft — enforced as policy (default: true)
3838
max: 3 # max PRs per run (default: 1)
3939
expires: 14 # auto-close after N days (same-repo only; also accepts 2h, 7d, 2w, 1m, 1y)
@@ -71,6 +71,19 @@ See [Cross-Repository Operations](/gh-aw/reference/cross-repository/) for `targe
7171

7272
`allowed-branches` restricts which _source_ branch names the agent may use. The effective branch (agent-provided, or the checkout branch as fallback) must match a configured glob.
7373

74+
### Runtime reviewers and assignees
75+
76+
`reviewers`, `team-reviewers`, and `assignees` accept either a static list or a single GitHub Actions expression string. This lets you route a cross-repository PR back to the triggering actor or a runtime-selected team without recompiling the workflow.
77+
78+
```yaml wrap
79+
safe-outputs:
80+
create-pull-request:
81+
target-repo: "acme/shared-infra"
82+
reviewers: ${{ github.event.pull_request.user.login }}
83+
team-reviewers: ${{ inputs.review-team }}
84+
assignees: ${{ github.event.pull_request.user.login }}
85+
```
86+
7487
### Branch naming
7588

7689
By default a random hex suffix is appended to the agent-provided branch name to avoid collisions. Set `preserve-branch-name: true` to omit the suffix (useful for repositories that enforce naming conventions such as Jira keys). If `preserve-branch-name: true` and the branch already exists on the remote, use `recreate-ref: true` to force-delete and recreate it (force-push semantics, intended for long-lived branches whose previous PR was already merged).
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//go:build integration
2+
3+
package cli
4+
5+
import (
6+
"encoding/json"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"testing"
11+
12+
goyaml "github.com/goccy/go-yaml"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestCompileCreatePullRequestRuntimeRoutingWorkflow(t *testing.T) {
18+
setup := setupIntegrationTest(t)
19+
defer setup.cleanup()
20+
21+
srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-copilot-create-pull-request-runtime-routing.md")
22+
dstPath := filepath.Join(setup.workflowsDir, "test-copilot-create-pull-request-runtime-routing.md")
23+
copyWorkflowFile(t, srcPath, dstPath)
24+
25+
cmd := exec.Command(setup.binaryPath, "compile", dstPath)
26+
output, err := cmd.CombinedOutput()
27+
require.NoError(t, err, "compile should succeed:\n%s", string(output))
28+
29+
lockFilePath := filepath.Join(setup.workflowsDir, "test-copilot-create-pull-request-runtime-routing.lock.yml")
30+
lockContent, err := os.ReadFile(lockFilePath)
31+
require.NoError(t, err, "failed to read compiled lock file")
32+
33+
lockContentStr := string(lockContent)
34+
assert.Contains(t, lockContentStr, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG", "lock file should include safe outputs handler config")
35+
assert.Contains(t, lockContentStr, `GH_AW_INPUT_REVIEW_TEAM: ${{ inputs.review-team }}`, "workflow_dispatch input should be preserved for runtime templating")
36+
37+
var compiledWorkflow map[string]any
38+
require.NoError(t, goyaml.Unmarshal(lockContent, &compiledWorkflow), "lock file should be valid YAML")
39+
40+
handlerConfigJSON := extractHandlerConfigJSON(t, compiledWorkflow)
41+
42+
var handlerConfig map[string]map[string]any
43+
require.NoError(t, json.Unmarshal([]byte(handlerConfigJSON), &handlerConfig), "handler config should be valid JSON")
44+
45+
createPullRequestConfig, ok := handlerConfig["create_pull_request"]
46+
require.True(t, ok, "handler config should include create_pull_request")
47+
assert.Equal(t, "${{ github.actor }}", createPullRequestConfig["reviewers"], "reviewers expression should remain a runtime string in handler config")
48+
// Handler config normalizes frontmatter keys like team-reviewers to team_reviewers
49+
// to match JSON property naming conventions.
50+
assert.Equal(t, "${{ inputs.review-team }}", createPullRequestConfig["team_reviewers"], "team_reviewers expression should remain a runtime string in handler config")
51+
assert.Equal(t, "${{ github.actor }}", createPullRequestConfig["assignees"], "assignees expression should remain a runtime string in handler config")
52+
}
53+
54+
func extractHandlerConfigJSON(t *testing.T, compiledWorkflow map[string]any) string {
55+
t.Helper()
56+
57+
jobs, ok := compiledWorkflow["jobs"].(map[string]any)
58+
require.True(t, ok, "compiled workflow should include jobs map")
59+
60+
safeOutputsJob, ok := jobs["safe_outputs"].(map[string]any)
61+
require.True(t, ok, "compiled workflow should include safe_outputs job")
62+
63+
steps, ok := safeOutputsJob["steps"].([]any)
64+
require.True(t, ok, "safe_outputs job should include steps")
65+
66+
for _, step := range steps {
67+
stepMap, ok := step.(map[string]any)
68+
if !ok {
69+
continue
70+
}
71+
env, ok := stepMap["env"].(map[string]any)
72+
if !ok {
73+
continue
74+
}
75+
handlerConfig, ok := env["GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG"].(string)
76+
if ok {
77+
return handlerConfig
78+
}
79+
}
80+
81+
t.Fatal("failed to locate GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG in safe_outputs job steps")
82+
return ""
83+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
review-team:
6+
description: 'Team slug to request on the created pull request'
7+
required: true
8+
type: string
9+
permissions:
10+
contents: read
11+
actions: read
12+
engine: copilot
13+
safe-outputs:
14+
create-pull-request:
15+
title-prefix: "[TEST-RUNTIME-ROUTING] "
16+
reviewers: ${{ github.actor }}
17+
team-reviewers: ${{ inputs.review-team }}
18+
assignees: ${{ github.actor }}
19+
draft: true
20+
---
21+
22+
# Test Copilot Create Pull Request with Runtime Routing
23+
24+
This is a test workflow to verify that `create-pull-request` accepts runtime expressions for reviewers, team reviewers, and assignees.
25+
26+
Please:
27+
1. Create a new file called `test-runtime-routing-demo.txt` with a simple message
28+
2. Use the `create-pull-request` safe output to create a pull request with your changes
29+
3. Confirm the created pull request automatically has:
30+
- The triggering actor assigned as a reviewer (check the Reviewers section in the GitHub PR sidebar)
31+
- The workflow input team assigned as a team reviewer (check the Reviewers section in the GitHub PR sidebar)
32+
- The triggering actor assigned as an assignee (check the Assignees section in the GitHub PR sidebar)
33+
- The title prefix "[TEST-RUNTIME-ROUTING]"
34+
- Draft status: true
35+
36+
This workflow demonstrates runtime expression support for `reviewers`, `team-reviewers`, and `assignees` on `create-pull-request`.

pkg/workflow/config_parsing_helpers_test.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,8 +1198,9 @@ func TestAddTemplatableStringSliceBuilder(t *testing.T) {
11981198
}
11991199

12001200
// TestParsePullRequestsConfigExpressionFields verifies that create-pull-request
1201-
// accepts GitHub Actions expression strings for labels, allowed-repos,
1202-
// allowed-base-branches, and allowed-branches.
1201+
// accepts GitHub Actions expression strings for labels, reviewers,
1202+
// team-reviewers, assignees, allowed-repos, allowed-base-branches, and
1203+
// allowed-branches.
12031204
func TestParsePullRequestsConfigExpressionFields(t *testing.T) {
12041205
tests := []struct {
12051206
name string
@@ -1213,6 +1214,24 @@ func TestParsePullRequestsConfigExpressionFields(t *testing.T) {
12131214
expr: "${{ inputs.labels }}",
12141215
getField: func(c *CreatePullRequestsConfig) []string { return c.Labels },
12151216
},
1217+
{
1218+
name: "reviewers as expression",
1219+
field: "reviewers",
1220+
expr: "${{ github.event.pull_request.user.login }}",
1221+
getField: func(c *CreatePullRequestsConfig) []string { return c.Reviewers },
1222+
},
1223+
{
1224+
name: "team-reviewers as expression",
1225+
field: "team-reviewers",
1226+
expr: "${{ inputs['review-team'] }}",
1227+
getField: func(c *CreatePullRequestsConfig) []string { return c.TeamReviewers },
1228+
},
1229+
{
1230+
name: "assignees as expression",
1231+
field: "assignees",
1232+
expr: "${{ github.event.pull_request.user.login }}",
1233+
getField: func(c *CreatePullRequestsConfig) []string { return c.Assignees },
1234+
},
12161235
{
12171236
name: "allowed-repos as expression",
12181237
field: "allowed-repos",
@@ -1383,6 +1402,39 @@ func TestHandlerConfigExpressionFields(t *testing.T) {
13831402
configKey: "labels",
13841403
wantArray: true,
13851404
},
1405+
{
1406+
name: "create_pull_request reviewers expression stored as string",
1407+
safeOuts: &SafeOutputsConfig{
1408+
CreatePullRequests: &CreatePullRequestsConfig{
1409+
Reviewers: []string{"${{ github.event.pull_request.user.login }}"},
1410+
},
1411+
},
1412+
handler: "create_pull_request",
1413+
configKey: "reviewers",
1414+
wantValue: "${{ github.event.pull_request.user.login }}",
1415+
},
1416+
{
1417+
name: "create_pull_request team_reviewers expression stored as string",
1418+
safeOuts: &SafeOutputsConfig{
1419+
CreatePullRequests: &CreatePullRequestsConfig{
1420+
TeamReviewers: []string{"${{ inputs['review-team'] }}"},
1421+
},
1422+
},
1423+
handler: "create_pull_request",
1424+
configKey: "team_reviewers",
1425+
wantValue: "${{ inputs['review-team'] }}",
1426+
},
1427+
{
1428+
name: "create_pull_request assignees expression stored as string",
1429+
safeOuts: &SafeOutputsConfig{
1430+
CreatePullRequests: &CreatePullRequestsConfig{
1431+
Assignees: []string{"${{ github.event.pull_request.user.login }}"},
1432+
},
1433+
},
1434+
handler: "create_pull_request",
1435+
configKey: "assignees",
1436+
wantValue: "${{ github.event.pull_request.user.login }}",
1437+
},
13861438
{
13871439
name: "create_pull_request allowed_repos expression stored as string",
13881440
safeOuts: &SafeOutputsConfig{

pkg/workflow/create_pull_request.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ type CreatePullRequestsConfig struct {
3737
RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_pull_request tool calls must include temporary_id.
3838
Labels []string `yaml:"labels,omitempty"`
3939
AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones).
40-
Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request
41-
TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request
42-
Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to any fallback issue created by create-pull-request
40+
Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request. Accepts a static list or a single GitHub Actions expression.
41+
TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request. Accepts a static list or a single GitHub Actions expression.
42+
Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to the created pull request and any fallback issue. Accepts a static list or a single GitHub Actions expression.
4343
FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels.
4444
Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values
4545
IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore"

pkg/workflow/safe_outputs_handler_registry.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,9 +484,9 @@ var handlerRegistry = map[string]handlerBuilder{
484484
AddIfNotEmpty("title_prefix", c.TitlePrefix).
485485
AddTemplatableStringSlice("labels", c.Labels).
486486
AddStringSlice("fallback_labels", c.FallbackLabels).
487-
AddStringSlice("reviewers", c.Reviewers).
488-
AddStringSlice("team_reviewers", c.TeamReviewers).
489-
AddStringSlice("assignees", c.Assignees).
487+
AddTemplatableStringSlice("reviewers", c.Reviewers).
488+
AddTemplatableStringSlice("team_reviewers", c.TeamReviewers).
489+
AddTemplatableStringSlice("assignees", c.Assignees).
490490
AddTemplatableBool("draft", c.Draft).
491491
AddIfNotEmpty("if_no_changes", c.IfNoChanges).
492492
AddTemplatableBool("allow_empty", c.AllowEmpty).

pkg/workflow/tool_description_enhancer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@ func enhanceToolDescription(toolName, baseDescription string, safeOutputs *SafeO
250250
if len(config.Reviewers) > 0 {
251251
constraints = append(constraints, fmt.Sprintf("Reviewers %s will be assigned.", formatStringList(config.Reviewers)))
252252
}
253+
if len(config.Assignees) > 0 {
254+
constraints = append(constraints, fmt.Sprintf("Assignees %s will be assigned to the created pull request and any fallback issue.", formatStringList(config.Assignees)))
255+
}
253256
if config.RequireTemporaryID {
254257
constraints = append(constraints, "temporary_id is required.")
255258
}

0 commit comments

Comments
 (0)