diff --git a/actions/setup/js/assign_agent_helpers.cjs b/actions/setup/js/assign_agent_helpers.cjs index d5bb1fa47ef..22bdf549955 100644 --- a/actions/setup/js/assign_agent_helpers.cjs +++ b/actions/setup/js/assign_agent_helpers.cjs @@ -246,9 +246,10 @@ async function getPullRequestDetails(owner, repo, pullNumber) { * @param {Array<{id: string, login: string}>} currentAssignees - List of current assignees with id and login * @param {string} agentName - Agent name for error messages * @param {string[]|null} allowedAgents - Optional list of allowed agent names. If provided, filters out non-allowed agents from current assignees. + * @param {string|null} pullRequestRepoId - Optional pull request repository ID for specifying where the PR should be created (GitHub agentAssignment.targetRepositoryId) * @returns {Promise} True if successful */ -async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null) { +async function assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents = null, pullRequestRepoId = null) { // Filter current assignees based on allowed list (if configured) let filteredAssignees = currentAssignees; if (allowedAgents && allowedAgents.length > 0) { @@ -271,24 +272,54 @@ async function assignAgentToIssue(assignableId, agentId, currentAssignees, agent // Build actor IDs array - include new agent and preserve filtered assignees const actorIds = [agentId, ...filteredAssignees.map(a => a.id).filter(id => id !== agentId)]; - const mutation = ` - mutation($assignableId: ID!, $actorIds: [ID!]!) { - replaceActorsForAssignable(input: { - assignableId: $assignableId, - actorIds: $actorIds - }) { - __typename + // Build the mutation - conditionally include agentAssignment if pullRequestRepoId is provided + let mutation; + let variables; + + if (pullRequestRepoId) { + // Include agentAssignment with targetRepositoryId for cross-repo PR creation + mutation = ` + mutation($assignableId: ID!, $actorIds: [ID!]!, $targetRepoId: ID!) { + replaceActorsForAssignable(input: { + assignableId: $assignableId, + actorIds: $actorIds, + agentAssignment: { + targetRepositoryId: $targetRepoId + } + }) { + __typename + } } - } - `; + `; + variables = { + assignableId: assignableId, + actorIds, + targetRepoId: pullRequestRepoId, + }; + } else { + // Standard mutation without agentAssignment + mutation = ` + mutation($assignableId: ID!, $actorIds: [ID!]!) { + replaceActorsForAssignable(input: { + assignableId: $assignableId, + actorIds: $actorIds + }) { + __typename + } + } + `; + variables = { + assignableId: assignableId, + actorIds, + }; + } try { core.info("Using built-in github object for mutation"); - core.debug(`GraphQL mutation with variables: assignableId=${assignableId}, actorIds=${JSON.stringify(actorIds)}`); + core.debug(`GraphQL mutation with variables: assignableId=${assignableId}, actorIds=${JSON.stringify(actorIds)}${pullRequestRepoId ? `, targetRepoId=${pullRequestRepoId}` : ""}`); const response = await github.graphql(mutation, { - assignableId: assignableId, - actorIds, + ...variables, headers: { "GraphQL-Features": "issues_copilot_assignment_api_support", }, diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 0e58ab23b43..a9c684739aa 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -120,6 +120,53 @@ async function main() { // The github-token is set at the step level, so the built-in github object is authenticated // with the correct token (GH_AW_AGENT_TOKEN by default) + // Get PR repository configuration (where the PR should be created, may differ from issue repo) + const pullRequestRepoEnv = process.env.GH_AW_AGENT_PULL_REQUEST_REPO?.trim(); + let pullRequestOwner = null; + let pullRequestRepo = null; + let pullRequestRepoId = null; + + // Get allowed PR repos configuration for cross-repo validation + const allowedPullRequestReposEnv = process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS?.trim(); + const allowedPullRequestRepos = parseAllowedRepos(allowedPullRequestReposEnv); + + if (pullRequestRepoEnv) { + const parts = pullRequestRepoEnv.split("/"); + if (parts.length === 2) { + // Validate PR repository against allowlist + // The configured pull-request-repo is treated as the default (always allowed) + // allowed-pull-request-repos contains additional repositories beyond pull-request-repo + const repoValidation = validateRepo(pullRequestRepoEnv, pullRequestRepoEnv, allowedPullRequestRepos); + if (!repoValidation.valid) { + core.setFailed(`E004: ${repoValidation.error}`); + return; + } + + pullRequestOwner = parts[0]; + pullRequestRepo = parts[1]; + core.info(`Using pull request repository: ${pullRequestOwner}/${pullRequestRepo}`); + + // Fetch the repository ID for the PR repo (needed for GraphQL agentAssignment) + 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; + core.info(`Pull request repository ID: ${pullRequestRepoId}`); + } catch (error) { + core.setFailed(`Failed to fetch pull request repository ID for ${pullRequestOwner}/${pullRequestRepo}: ${getErrorMessage(error)}`); + return; + } + } else { + core.warning(`Invalid pull-request-repo format: ${pullRequestRepoEnv}. Expected owner/repo. PRs will be created in issue repository.`); + } + } + // Cache agent IDs to avoid repeated lookups const agentCache = {}; @@ -181,6 +228,58 @@ async function main() { const hasExplicitTarget = itemForTarget.issue_number != null || itemForTarget.pull_number != null; const effectiveTarget = hasExplicitTarget ? "*" : targetConfig; + // Handle per-item pull_request_repo parameter (where the PR should be created) + // This overrides the global pull-request-repo configuration if specified + let effectivePullRequestRepoId = pullRequestRepoId; + if (item.pull_request_repo) { + const itemPullRequestRepo = item.pull_request_repo.trim(); + const pullRequestRepoParts = itemPullRequestRepo.split("/"); + if (pullRequestRepoParts.length === 2) { + // Validate PR repository against allowlist + // The global pull-request-repo (if set) is treated as the default (always allowed) + // allowed-pull-request-repos contains additional allowed repositories + const defaultPullRequestRepo = pullRequestRepoEnv || defaultRepo; + const pullRequestRepoValidation = validateRepo(itemPullRequestRepo, defaultPullRequestRepo, allowedPullRequestRepos); + if (!pullRequestRepoValidation.valid) { + core.error(`E004: ${pullRequestRepoValidation.error}`); + results.push({ + issue_number: item.issue_number || null, + pull_number: item.pull_number || null, + agent: agentName, + success: false, + error: pullRequestRepoValidation.error, + }); + continue; + } + + // Fetch the repository ID for the item's PR repo + try { + const itemPullRequestRepoQuery = ` + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + id + } + } + `; + const itemPullRequestRepoResponse = await github.graphql(itemPullRequestRepoQuery, { owner: pullRequestRepoParts[0], name: pullRequestRepoParts[1] }); + effectivePullRequestRepoId = itemPullRequestRepoResponse.repository.id; + core.info(`Using per-item pull request repository: ${itemPullRequestRepo} (ID: ${effectivePullRequestRepoId})`); + } catch (error) { + core.error(`Failed to fetch pull request repository ID for ${itemPullRequestRepo}: ${getErrorMessage(error)}`); + results.push({ + issue_number: item.issue_number || null, + pull_number: item.pull_number || null, + agent: agentName, + success: false, + error: `Failed to fetch pull request repository ID for ${itemPullRequestRepo}`, + }); + continue; + } + } else { + core.warning(`Invalid pull_request_repo format: ${itemPullRequestRepo}. Expected owner/repo. Using global pull-request-repo if configured.`); + } + } + // Resolve target number using the same logic as other safe outputs // This allows automatic resolution from workflow context when issue_number/pull_number is not explicitly provided const targetResult = resolveTarget({ @@ -306,8 +405,9 @@ async function main() { // Assign agent using GraphQL mutation - uses built-in github object authenticated via github-token // Pass the allowed list so existing assignees are filtered before calling replaceActorsForAssignable + // Pass the PR repo ID if configured (to specify where the PR should be created) core.info(`Assigning ${agentName} coding agent to ${type} #${number}...`); - const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents); + const success = await assignAgentToIssue(assignableId, agentId, currentAssignees, agentName, allowedAgents, effectivePullRequestRepoId); if (!success) { throw new Error(`Failed to assign ${agentName} via GraphQL`); diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index ec2e7fdd31e..685c77b0bd6 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -56,6 +56,8 @@ describe("assign_to_agent", () => { delete process.env.GH_AW_TARGET_REPO; delete process.env.GH_AW_AGENT_IGNORE_IF_ERROR; 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; // Reset context to default mockContext.eventName = "issues"; @@ -1144,4 +1146,180 @@ describe("assign_to_agent", () => { expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Looking for copilot coding agent")); }, 20000); }); + + it("should handle pull-request-repo configuration correctly", async () => { + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/pull-request-repo"; + // Note: pull-request-repo is automatically allowed, no need to set allowed list + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + // Get PR repository ID + .mockResolvedValueOnce({ + repository: { + id: "pull-request-repo-id", + }, + }) + // 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 with agentAssignment + .mockResolvedValueOnce({ + replaceActorsForAssignable: { + __typename: "ReplaceActorsForAssignablePayload", + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/pull-request-repo")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Pull request repository ID: pull-request-repo-id")); + + // Verify the mutation was called with agentAssignment + const lastGraphQLCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1]; + expect(lastGraphQLCall[0]).toContain("agentAssignment"); + expect(lastGraphQLCall[0]).toContain("targetRepositoryId"); + expect(lastGraphQLCall[1].targetRepoId).toBe("pull-request-repo-id"); + }); + + it("should handle per-item pull_request_repo parameter", async () => { + // Set global pull-request-repo which will be automatically allowed + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/default-pr-repo"; + // Set allowed list for additional repos + process.env.GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS = "test-owner/item-pull-request-repo"; + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + pull_request_repo: "test-owner/item-pull-request-repo", + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + // Get global PR repository ID (for default-pr-repo) + .mockResolvedValueOnce({ + repository: { + id: "default-pr-repo-id", + }, + }) + // Get item PR repository ID + .mockResolvedValueOnce({ + repository: { + id: "item-pull-request-repo-id", + }, + }) + // 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 with agentAssignment + .mockResolvedValueOnce({ + replaceActorsForAssignable: { + __typename: "ReplaceActorsForAssignablePayload", + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using per-item pull request repository: test-owner/item-pull-request-repo")); + + // Verify the mutation was called with per-item PR repo ID + const lastGraphQLCall = mockGithub.graphql.mock.calls[mockGithub.graphql.mock.calls.length - 1]; + expect(lastGraphQLCall[1].targetRepoId).toBe("item-pull-request-repo-id"); + }); + + it("should allow pull-request-repo without it being in allowed-pull-request-repos", async () => { + // Set pull-request-repo but DO NOT set allowed-pull-request-repos + // This tests that pull-request-repo is automatically allowed (like target-repo behavior) + process.env.GH_AW_AGENT_PULL_REQUEST_REPO = "test-owner/auto-allowed-repo"; + setAgentOutput({ + items: [ + { + type: "assign_to_agent", + issue_number: 42, + agent: "copilot", + }, + ], + errors: [], + }); + + // Mock GraphQL responses + mockGithub.graphql + // Get PR repository ID + .mockResolvedValueOnce({ + repository: { + id: "auto-allowed-repo-id", + }, + }) + // 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 with agentAssignment + .mockResolvedValueOnce({ + replaceActorsForAssignable: { + __typename: "ReplaceActorsForAssignablePayload", + }, + }); + + await eval(`(async () => { ${assignToAgentScript}; await main(); })()`); + + // Should succeed - pull-request-repo is automatically allowed + expect(mockCore.setFailed).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using pull request repository: test-owner/auto-allowed-repo")); + }); }); diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 053167be84b..fe1863ae1d9 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1304,7 +1304,9 @@ safe-outputs: allowed: [copilot] # restrict to specific agents (optional) max: 1 # max assignments (default: 1) target: "triggering" # "triggering" (default), "*", or number - target-repo: "owner/repo" # cross-repository + 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 ``` **Behavior:** @@ -1312,6 +1314,20 @@ safe-outputs: - `target: "*"` - Requires explicit `issue_number` or `pull_number` in agent output - `target: "123"` - Always uses issue/PR #123 +**Cross-Repository PR Creation:** +The `pull-request-repo` parameter allows you to create pull requests in a different repository than where the issue lives. This is useful when: +- Issues are tracked in a central repository but code lives in separate repositories +- You want to separate issue tracking from code repositories + +When `pull-request-repo` is configured, Copilot will create the pull request in the specified repository instead of the issue's repository. The issue repository is determined by `target-repo` or defaults to the workflow's repository. + +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. + +You can also specify `pull_request_repo` on a per-assignment basis in the agent output using the `assign_to_agent` tool: +```python +assign_to_agent(issue_number=123, agent="copilot", pull_request_repo="owner/codebase-repo") +``` + **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 455d13da753..b5a7a65e026 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -5380,6 +5380,17 @@ "type": "string", "description": "Target repository in format 'owner/repo' for cross-repository agent assignment. Takes precedence over trial target repo settings." }, + "pull-request-repo": { + "type": "string", + "description": "Target repository where the pull request should be created, in format 'owner/repo'. If omitted, the PR will be created in the same repository as the issue (specified by target-repo or the workflow's repository). This allows issues and code to live in different repositories." + }, + "allowed-pull-request-repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of additional repositories that pull requests can be created in beyond pull-request-repo. Each entry should be in 'owner/repo' format. The repository specified by pull-request-repo is automatically allowed without needing to be listed here." + }, "ignore-if-error": { "type": "boolean", "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.", diff --git a/pkg/workflow/assign_to_agent.go b/pkg/workflow/assign_to_agent.go index f23e682ab85..c86905a9a59 100644 --- a/pkg/workflow/assign_to_agent.go +++ b/pkg/workflow/assign_to_agent.go @@ -8,11 +8,13 @@ var assignToAgentLog = logger.New("workflow:assign_to_agent") // AssignToAgentConfig holds configuration for assigning agents to issues from agent output type AssignToAgentConfig struct { - BaseSafeOutputConfig `yaml:",inline"` - SafeOutputTargetConfig `yaml:",inline"` - DefaultAgent string `yaml:"name,omitempty"` // Default agent to assign (e.g., "copilot") - Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed agent names. If omitted, any agents are allowed. - IgnoreIfError bool `yaml:"ignore-if-error,omitempty"` // If true, workflow continues when agent assignment fails + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputTargetConfig `yaml:",inline"` + DefaultAgent string `yaml:"name,omitempty"` // Default agent to assign (e.g., "copilot") + Allowed []string `yaml:"allowed,omitempty"` // Optional list of allowed agent names. If omitted, any agents are allowed. + 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) } // parseAssignToAgentConfig handles assign-to-agent configuration @@ -37,7 +39,7 @@ func (c *Compiler) parseAssignToAgentConfig(outputMap map[string]any) *AssignToA config.Max = 1 } - assignToAgentLog.Printf("Parsed assign-to-agent config: default_agent=%s, allowed_count=%d, target=%s, max=%d", config.DefaultAgent, len(config.Allowed), config.Target, config.Max) + assignToAgentLog.Printf("Parsed assign-to-agent config: default_agent=%s, allowed_count=%d, target=%s, max=%d, pull_request_repo=%s", config.DefaultAgent, len(config.Allowed), config.Target, config.Max, config.PullRequestRepoSlug) return &config } diff --git a/pkg/workflow/compiler_safe_outputs_specialized.go b/pkg/workflow/compiler_safe_outputs_specialized.go index 0c51e63cd86..b78454a9ec1 100644 --- a/pkg/workflow/compiler_safe_outputs_specialized.go +++ b/pkg/workflow/compiler_safe_outputs_specialized.go @@ -48,6 +48,23 @@ func (c *Compiler) buildAssignToAgentStepConfig(data *WorkflowData, mainJobName customEnvVars = append(customEnvVars, " GH_AW_AGENT_IGNORE_IF_ERROR: \"true\"\n") } + // Add PR repository configuration environment variable (where the PR should be created) + if cfg.PullRequestRepoSlug != "" { + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_PULL_REQUEST_REPO: %q\n", cfg.PullRequestRepoSlug)) + } + + // Add allowed PR repos list environment variable (comma-separated) + if len(cfg.AllowedPullRequestRepos) > 0 { + allowedPullRequestReposStr := "" + for i, repo := range cfg.AllowedPullRequestRepos { + if i > 0 { + allowedPullRequestReposStr += "," + } + allowedPullRequestReposStr += repo + } + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_AGENT_ALLOWED_PULL_REQUEST_REPOS: %q\n", allowedPullRequestReposStr)) + } + // Allow assign_to_agent to reference issues created earlier in the same run via temporary IDs (aw_...) // The handler manager (process_safe_outputs) produces a temporary_id_map output when create_issue is enabled. if data.SafeOutputs != nil && data.SafeOutputs.CreateIssues != nil { diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index b35bb9867cd..dda35bd5881 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -504,7 +504,7 @@ }, { "name": "assign_to_agent", - "description": "Assign the GitHub Copilot coding agent to work on an issue or pull request. The agent will analyze the issue/PR and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot. Example usage: assign_to_agent(issue_number=123, agent=\"copilot\") or assign_to_agent(pull_number=456, agent=\"copilot\")", + "description": "Assign the GitHub Copilot coding agent to work on an issue or pull request. The agent will analyze the issue/PR and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot. Example usage: assign_to_agent(issue_number=123, agent=\"copilot\") or assign_to_agent(pull_number=456, agent=\"copilot\", pull_request_repo=\"owner/repo\")", "inputSchema": { "type": "object", "properties": { @@ -525,6 +525,10 @@ "agent": { "type": "string", "description": "Agent identifier to assign. Defaults to 'copilot' (the Copilot coding agent) if not specified." + }, + "pull_request_repo": { + "type": "string", + "description": "Target repository where the pull request should be created, in 'owner/repo' format. If omitted, the PR will be created in the same repository as the issue. This allows issues and code to live in different repositories. The global pull-request-repo configuration (if set) is automatically allowed; additional repositories must be listed in allowed-pull-request-repos." } }, "additionalProperties": false diff --git a/scratchpad/safe-outputs-specification.md b/scratchpad/safe-outputs-specification.md index f2500ba6ad6..018f750ef28 100644 --- a/scratchpad/safe-outputs-specification.md +++ b/scratchpad/safe-outputs-specification.md @@ -309,6 +309,14 @@ For cross-repository operations, implementations MUST: - Reject operations when token lacks required permissions - Apply same validation rules as same-repository operations +For operations that support separate PR repository selection (e.g., `assign-to-agent`), implementations MUST: +- Validate `pull-request-repo` format: `owner/repo` +- Automatically allow the repository specified by `pull-request-repo` (no need to list in `allowed-pull-request-repos`) +- Validate per-item `pull_request_repo` values against the global `pull-request-repo` (default) and `allowed-pull-request-repos` (additional allowed repositories) +- Use `target-repo` for the resource location (issue/PR) +- Use `pull-request-repo` for PR creation location +- Return E004 error code for unauthorized repositories + ### 3.5 Layer 4: Execution Handlers #### 3.5.1 Job Isolation @@ -704,6 +712,14 @@ Operations supporting cross-repository actions MUST: - Use target repository for all GitHub API calls - Apply same validation rules as same-repository operations +For operations that assign agents to issues/PRs, implementations MAY support: +- Accept `pull-request-repo: "owner/repo"` configuration to specify where PRs should be created +- Accept `allowed-pull-request-repos: ["owner/repo1", "owner/repo2"]` for validation of additional repositories +- Automatically allow the repository specified by `pull-request-repo` (it does not need to be listed in `allowed-pull-request-repos`) +- Use `agentAssignment.targetRepositoryId` in GraphQL mutations when available + +This pattern enables issue tracking in one repository while code changes are created in a different repository. + Exceptions (same-repository only): - `push-to-pull-request-branch` - Requires repository write access - `upload-asset` - Creates orphaned branches @@ -951,7 +967,36 @@ safe-outputs: - Label `upstream-request` applied - Attribution footer references source workflow -#### A.3 Staged Mode Preview +#### A.3 Cross-Repository Agent Assignment + +**Configuration:** +```yaml +safe-outputs: + assign-to-agent: + target-repo: "octocat/issues" + pull-request-repo: "octocat/codebase" + allowed-pull-request-repos: + - "octocat/codebase" + - "octocat/codebase-v2" +``` + +**Agent Request:** +```json +{ + "type": "assign_to_agent", + "issue_number": 42, + "agent": "copilot", + "pull_request_repo": "octocat/codebase" +} +``` + +**Result:** +- Issue #42 in `octocat/issues` assigned to Copilot +- Agent creates PR in `octocat/codebase` (not in `octocat/issues`) +- GraphQL mutation includes `agentAssignment.targetRepositoryId` +- Enables issue tracking separate from code repositories + +#### A.4 Staged Mode Preview **Configuration:** ```yaml