Skip to content

Commit c717c8b

Browse files
CopilotpelikhanCopilot
authored
feat(update_project): add target_repo for cross-repo project item resolution (#21404)
* Initial plan * feat: add content_repo for cross-repo project item resolution in update_project - Add content_repo field to update_project tool schema (both JSON files) - Update update_project.cjs to use content_repo for GraphQL content resolution - Add allowed-repos/target-repo config validation in handleUpdateProject - Add TargetRepoSlug and AllowedRepos fields to UpdateProjectConfig Go struct - Update compiler_safe_outputs_config.go to pass target-repo and allowed_repos - Update safe_outputs_config_generation.go to use generateTargetConfigWithRepos - Add target-repo and allowed-repos to main_workflow_schema.json - Update documentation with cross-repo examples and feature tables Co-authored-by: pelikhan <[email protected]> * rename content_repo to target_repo in update_project, add tests - Rename content_repo -> target_repo in update_project.cjs, both schema JSON files, main_workflow_schema.json, Go struct comment, and all documentation - Add camelCase alias targetRepo -> target_repo in normalizeUpdateProjectOutput - Add 9 tests covering: target_repo content resolution, camelCase alias, invalid format, fallback to context.repo, allowed-repos validation, target-repo config match, allowed-repos list, wildcard pattern, no target_repo Co-authored-by: pelikhan <[email protected]> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <[email protected]> * test(update_project): add integration tests and sample workflow for target_repo cross-repo config Co-authored-by: pelikhan <[email protected]> * fix(update_project): address code review comments - schema patterns, error message, tool description Co-authored-by: pelikhan <[email protected]> * fix(update_project): add GitHub Actions macro expression support to target-repo and allowed-repos schema patterns Co-authored-by: pelikhan <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: pelikhan <[email protected]> Co-authored-by: Peli de Halleux <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
1 parent 27c63b6 commit c717c8b

14 files changed

+564
-8
lines changed

.changeset/patch-update-project-content-repo.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/safe_outputs_tools.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,11 @@
10351035
"type": ["number", "string"],
10361036
"description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456), or a temporary ID from a recent create_issue call (e.g., 'aw_abc123', '#aw_Test123'). Required when content_type is 'issue' or 'pull_request'."
10371037
},
1038+
"target_repo": {
1039+
"type": "string",
1040+
"pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
1041+
"description": "Repository containing the issue or pull request, in \"owner/repo\" format (e.g., \"github/docs\"). Use this when the issue or PR belongs to a different repository than the one running the workflow. Requires safe-outputs.update-project.target-repo to match, or safe-outputs.update-project.allowed-repos to include this repository."
1042+
},
10381043
"draft_title": {
10391044
"type": "string",
10401045
"description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue' and creating a new draft (when draft_issue_id is not provided)."

actions/setup/js/update_project.cjs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
66
const { loadTemporaryIdMapFromResolved, resolveIssueNumber, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs");
77
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
88
const { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs");
9+
const { parseRepoSlug, resolveTargetRepoConfig, isRepoAllowed } = require("./repo_helpers.cjs");
910

1011
/**
1112
* Normalize agent output keys for update_project.
@@ -23,6 +24,7 @@ function normalizeUpdateProjectOutput(value) {
2324

2425
if (output.content_type === undefined && output.contentType !== undefined) output.content_type = output.contentType;
2526
if (output.content_number === undefined && output.contentNumber !== undefined) output.content_number = output.contentNumber;
27+
if (output.target_repo === undefined && output.targetRepo !== undefined) output.target_repo = output.targetRepo;
2628

2729
if (output.draft_title === undefined && output.draftTitle !== undefined) output.draft_title = output.draftTitle;
2830
if (output.draft_body === undefined && output.draftBody !== undefined) output.draft_body = output.draftBody;
@@ -471,6 +473,23 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
471473
throw new Error(`${ERR_CONFIG}: GitHub client is required but not provided. Either pass a github client to updateProject() or ensure global.github is set.`);
472474
}
473475
const { owner, repo } = context.repo;
476+
477+
// Determine the effective owner/repo for content resolution.
478+
// When target_repo is provided, use it instead of the workflow's host repo.
479+
// This enables org-level project workflows to resolve issues from other repos.
480+
let contentOwner = owner;
481+
let targetRepo = repo;
482+
if (output.target_repo && typeof output.target_repo === "string") {
483+
const targetRepoSlug = output.target_repo.trim();
484+
const parsed = parseRepoSlug(targetRepoSlug);
485+
if (!parsed) {
486+
throw new Error(`${ERR_VALIDATION}: Invalid target_repo format "${targetRepoSlug}". Use "owner/repo" format (e.g., "github/docs").`);
487+
}
488+
contentOwner = parsed.owner;
489+
targetRepo = parsed.repo;
490+
core.info(`Using target_repo ${targetRepoSlug} for content resolution`);
491+
}
492+
474493
const projectInfo = parseProjectUrl(output.project);
475494
const projectNumberFromUrl = projectInfo.projectNumber;
476495

@@ -1014,7 +1033,7 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
10141033
"Issue" === contentType
10151034
? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n }\n }\n }"
10161035
: "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n }\n }\n }",
1017-
contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }),
1036+
contentResult = await github.graphql(contentQuery, { owner: contentOwner, repo: targetRepo, number: contentNumber }),
10181037
contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest,
10191038
contentId = contentData.id,
10201039
existingItem = await (async function (projectId, contentId) {
@@ -1214,6 +1233,9 @@ async function main(config = {}, githubClient = null) {
12141233
const configuredViews = Array.isArray(config.views) ? config.views : [];
12151234
const configuredFieldDefinitions = Array.isArray(config.field_definitions) ? config.field_definitions : [];
12161235

1236+
// Resolve target-repo and allowed-repos for cross-repo content resolution validation
1237+
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
1238+
12171239
// Check if we're in staged mode
12181240
const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";
12191241

@@ -1243,6 +1265,24 @@ async function main(config = {}, githubClient = null) {
12431265

12441266
const tempIdMap = temporaryIdMap instanceof Map ? temporaryIdMap : loadTemporaryIdMapFromResolved(resolvedTemporaryIds);
12451267

1268+
// Validate target_repo if provided: must be in the allowed repos list.
1269+
// Note: defaultTargetRepo already falls back to context.repo (the current workflow repository)
1270+
// when no target-repo is configured in the frontmatter — so the host repo is always implicitly allowed.
1271+
if (message.target_repo && typeof message.target_repo === "string") {
1272+
const targetRepoSlug = message.target_repo.trim();
1273+
// defaultTargetRepo (target-repo config or current workflow repo) is always permitted;
1274+
// additional repos must be listed in allowed-repos.
1275+
const isDefaultRepo = targetRepoSlug === defaultTargetRepo;
1276+
if (!isDefaultRepo && !isRepoAllowed(targetRepoSlug, allowedRepos)) {
1277+
const errorMsg = `Repository "${targetRepoSlug}" is not allowed for cross-repo content resolution. Configure safe-outputs.update-project.target-repo to set it as the default repository, or add it to safe-outputs.update-project.allowed-repos in the workflow frontmatter to permit this repository.`;
1278+
core.error(errorMsg);
1279+
return {
1280+
success: false,
1281+
error: errorMsg,
1282+
};
1283+
}
1284+
}
1285+
12461286
// Check max limit
12471287
if (processedCount >= maxCount) {
12481288
core.warning(`Skipping update_project: max count of ${maxCount} reached`);

actions/setup/js/update_project.test.cjs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,3 +1994,199 @@ describe("update_project temporary project ID resolution", () => {
19941994
expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID"));
19951995
});
19961996
});
1997+
1998+
describe("update_project target_repo cross-repo content resolution", () => {
1999+
it("uses target_repo owner/repo when resolving issue content_number", async () => {
2000+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2001+
const output = {
2002+
type: "update_project",
2003+
project: projectUrl,
2004+
content_type: "issue",
2005+
content_number: 123,
2006+
target_repo: "otherorg/otherrepo",
2007+
};
2008+
2009+
// Queue responses - issue is resolved against otherorg/otherrepo
2010+
queueResponses([
2011+
repoResponse(), // repository info for testowner/testrepo (project owner lookup)
2012+
viewerResponse(),
2013+
orgProjectV2Response(projectUrl, 60, "project123"),
2014+
issueResponse("issue-id-123"),
2015+
emptyItemsResponse(),
2016+
{ addProjectV2ItemById: { item: { id: "item-cross" } } },
2017+
]);
2018+
2019+
await updateProject(output);
2020+
2021+
// Verify the GraphQL query was made with the correct cross-repo owner/repo
2022+
const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
2023+
expect(contentQueryCall).toBeDefined();
2024+
expect(contentQueryCall[1]).toMatchObject({ owner: "otherorg", repo: "otherrepo", number: 123 });
2025+
2026+
expect(getOutput("item-id")).toBe("item-cross");
2027+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using target_repo otherorg/otherrepo for content resolution"));
2028+
});
2029+
2030+
it("normalizes camelCase targetRepo to target_repo", async () => {
2031+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2032+
const output = {
2033+
type: "update_project",
2034+
project: projectUrl,
2035+
content_type: "issue",
2036+
content_number: 5,
2037+
targetRepo: "otherorg/otherrepo", // camelCase alias
2038+
};
2039+
2040+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-5"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-camel" } } }]);
2041+
2042+
await updateProject(output);
2043+
2044+
const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
2045+
expect(contentQueryCall).toBeDefined();
2046+
expect(contentQueryCall[1]).toMatchObject({ owner: "otherorg", repo: "otherrepo", number: 5 });
2047+
});
2048+
2049+
it("throws on invalid target_repo format", async () => {
2050+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2051+
const output = {
2052+
type: "update_project",
2053+
project: projectUrl,
2054+
content_type: "issue",
2055+
content_number: 1,
2056+
target_repo: "invalid-no-slash",
2057+
};
2058+
2059+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123")]);
2060+
2061+
await expect(updateProject(output)).rejects.toThrow(/Invalid target_repo format/);
2062+
});
2063+
2064+
it("falls back to context.repo when target_repo is not provided", async () => {
2065+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2066+
const output = {
2067+
type: "update_project",
2068+
project: projectUrl,
2069+
content_type: "issue",
2070+
content_number: 7,
2071+
// No target_repo - should use context.repo (testowner/testrepo)
2072+
};
2073+
2074+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-7"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-default" } } }]);
2075+
2076+
await updateProject(output);
2077+
2078+
const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
2079+
expect(contentQueryCall).toBeDefined();
2080+
// Should use context.repo values (testowner/testrepo)
2081+
expect(contentQueryCall[1]).toMatchObject({ owner: "testowner", repo: "testrepo", number: 7 });
2082+
expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Using target_repo"));
2083+
});
2084+
});
2085+
2086+
describe("update_project handler: target_repo allowed-repos validation", () => {
2087+
let messageHandler;
2088+
2089+
beforeEach(() => {
2090+
mockGithub.graphql.mockReset();
2091+
clearCoreMocks();
2092+
});
2093+
2094+
it("rejects target_repo not in allowed-repos", async () => {
2095+
const config = { max: 10, allowed_repos: ["org/allowed-repo"] };
2096+
messageHandler = await updateProjectHandlerFactory(config, mockGithub);
2097+
2098+
const message = {
2099+
type: "update_project",
2100+
project: "https://github.com/orgs/testowner/projects/60",
2101+
content_type: "issue",
2102+
content_number: 1,
2103+
target_repo: "org/forbidden-repo",
2104+
};
2105+
2106+
const result = await messageHandler(message, {}, new Map());
2107+
2108+
expect(result.success).toBe(false);
2109+
expect(result.error).toMatch(/not allowed for cross-repo content resolution/);
2110+
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("org/forbidden-repo"));
2111+
});
2112+
2113+
it("allows target_repo that matches the default target-repo config", async () => {
2114+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2115+
const config = { max: 10, "target-repo": "org/target-repo", allowed_repos: [] };
2116+
messageHandler = await updateProjectHandlerFactory(config, mockGithub);
2117+
2118+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-2"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-allowed" } } }]);
2119+
2120+
const message = {
2121+
type: "update_project",
2122+
project: projectUrl,
2123+
content_type: "issue",
2124+
content_number: 2,
2125+
target_repo: "org/target-repo", // Same as configured target-repo
2126+
};
2127+
2128+
const result = await messageHandler(message, {}, new Map());
2129+
2130+
expect(result.success).toBe(true);
2131+
});
2132+
2133+
it("allows target_repo that matches an entry in allowed-repos", async () => {
2134+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2135+
const config = { max: 10, allowed_repos: ["org/allowed-repo", "org/another-repo"] };
2136+
messageHandler = await updateProjectHandlerFactory(config, mockGithub);
2137+
2138+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-3"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-in-list" } } }]);
2139+
2140+
const message = {
2141+
type: "update_project",
2142+
project: projectUrl,
2143+
content_type: "issue",
2144+
content_number: 3,
2145+
target_repo: "org/allowed-repo",
2146+
};
2147+
2148+
const result = await messageHandler(message, {}, new Map());
2149+
2150+
expect(result.success).toBe(true);
2151+
});
2152+
2153+
it("allows wildcard allowed-repo pattern", async () => {
2154+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2155+
const config = { max: 10, allowed_repos: ["org/*"] };
2156+
messageHandler = await updateProjectHandlerFactory(config, mockGithub);
2157+
2158+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-4"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-wildcard" } } }]);
2159+
2160+
const message = {
2161+
type: "update_project",
2162+
project: projectUrl,
2163+
content_type: "issue",
2164+
content_number: 4,
2165+
target_repo: "org/any-repo-in-org",
2166+
};
2167+
2168+
const result = await messageHandler(message, {}, new Map());
2169+
2170+
expect(result.success).toBe(true);
2171+
});
2172+
2173+
it("does not validate target_repo when not provided", async () => {
2174+
const projectUrl = "https://github.com/orgs/testowner/projects/60";
2175+
const config = { max: 10, allowed_repos: ["org/specific-repo"] };
2176+
messageHandler = await updateProjectHandlerFactory(config, mockGithub);
2177+
2178+
queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-5"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-no-target" } } }]);
2179+
2180+
const message = {
2181+
type: "update_project",
2182+
project: projectUrl,
2183+
content_type: "issue",
2184+
content_number: 5,
2185+
// No target_repo - should pass validation
2186+
};
2187+
2188+
const result = await messageHandler(message, {}, new Map());
2189+
2190+
expect(result.success).toBe(true);
2191+
});
2192+
});

docs/src/content/docs/examples/multi-repo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Most safe output types support the `target-repo` parameter for cross-repository
8787
| `create-discussion` || Create discussions in any repo |
8888
| `create-agent-session` || Create tasks in target repos |
8989
| `update-release` || Update release notes across repos |
90+
| `update-project` | ✅ (`target_repo`) | Update project items from other repos |
9091

9192
**Configuration Example:**
9293

docs/src/content/docs/reference/safe-outputs-specification.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2964,7 +2964,7 @@ safe-outputs:
29642964
**Purpose**: Manage GitHub Projects V2 boards (add items, update fields, remove items).
29652965

29662966
**Default Max**: 10
2967-
**Cross-Repository Support**: No (same repository only)
2967+
**Cross-Repository Support**: Yes (via `target_repo` field in agent output; requires `allowed-repos` configuration)
29682968
**Mandatory**: No
29692969

29702970
**Required Permissions**:
@@ -2980,6 +2980,7 @@ safe-outputs:
29802980
**Notes**:
29812981
- Same permission requirements as `create_project`
29822982
- Higher default max (10) enables batch project board updates
2983+
- Cross-repo support uses `target_repo` in agent output to resolve issues/PRs from other repos; the `allowed-repos` configuration option controls which repos are permitted
29832984

29842985
---
29852986

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,8 @@ safe-outputs:
517517
project: "https://github.com/orgs/myorg/projects/42" # required: target project URL
518518
max: 20 # max operations (default: 10)
519519
github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }}
520+
target-repo: "org/default-repo" # optional: default repo for target_repo resolution
521+
allowed-repos: ["org/repo-a", "org/repo-b"] # optional: additional repos for cross-repo items
520522
views: # optional: auto-create views
521523
- name: "Sprint Board"
522524
layout: board
@@ -532,9 +534,37 @@ safe-outputs:
532534
- `project` (required in configuration): Default project URL shown in examples. Note: Agent output messages **must** explicitly include the `project` field - the configured value is for documentation purposes only.
533535
- `max`: Maximum number of operations per run (default: 10).
534536
- `github-token`: Custom token with Projects permissions (required for Projects v2 access).
537+
- `target-repo`: Default repository for cross-repo content resolution in `owner/repo` format. Wildcards (`*`) are not allowed.
538+
- `allowed-repos`: List of additional repositories whose issues/PRs can be resolved via `target_repo`. The `target-repo` is always implicitly allowed.
535539
- `views`: Optional array of project views to create automatically.
536540
- Exposes outputs: `project-id`, `project-number`, `project-url`, `item-id`.
537541

542+
#### Cross-Repository Content Resolution
543+
544+
For **organization-level projects** that aggregate issues from multiple repositories, use `target_repo` in the agent output to specify which repo contains the issue or PR:
545+
546+
```yaml wrap
547+
safe-outputs:
548+
update-project:
549+
github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }}
550+
allowed-repos: ["org/docs", "org/backend", "org/frontend"]
551+
```
552+
553+
The agent can then specify `target_repo` alongside `content_number`:
554+
555+
```json
556+
{
557+
"type": "update_project",
558+
"project": "https://github.com/orgs/myorg/projects/42",
559+
"content_type": "issue",
560+
"content_number": 123,
561+
"target_repo": "org/docs",
562+
"fields": { "Status": "In Progress" }
563+
}
564+
```
565+
566+
Without `target_repo`, the workflow's host repository is used to resolve `content_number`.
567+
538568
#### Supported Field Types
539569

540570
GitHub Projects V2 supports various custom field types. The following field types are automatically detected and handled:

0 commit comments

Comments
 (0)