Skip to content
5 changes: 5 additions & 0 deletions .changeset/patch-update-project-content-repo.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@
},
"pull_request_number": {
"type": ["number", "string"],
"description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) omitting it will cause the comment to fail."
"description": "Pull request number to add the review comment to. This is the numeric ID from the GitHub URL (e.g., 876 in github.com/owner/repo/pull/876). If omitted, adds the comment to the PR that triggered this workflow. Required when the workflow target is '*' (any PR) \u2014 omitting it will cause the comment to fail."
},
"start_line": {
"type": ["number", "string"],
Expand Down Expand Up @@ -1008,6 +1008,11 @@
"type": ["number", "string"],
"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'."
},
"target_repo": {
"type": "string",
"pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$",
"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."
},
"draft_title": {
"type": "string",
"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)."
Expand Down
42 changes: 41 additions & 1 deletion actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { loadTemporaryIdMapFromResolved, resolveIssueNumber, isTemporaryId, normalizeTemporaryId } = require("./temporary_id.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs");
const { parseRepoSlug, resolveTargetRepoConfig, isRepoAllowed } = require("./repo_helpers.cjs");

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

if (output.content_type === undefined && output.contentType !== undefined) output.content_type = output.contentType;
if (output.content_number === undefined && output.contentNumber !== undefined) output.content_number = output.contentNumber;
if (output.target_repo === undefined && output.targetRepo !== undefined) output.target_repo = output.targetRepo;

if (output.draft_title === undefined && output.draftTitle !== undefined) output.draft_title = output.draftTitle;
if (output.draft_body === undefined && output.draftBody !== undefined) output.draft_body = output.draftBody;
Expand Down Expand Up @@ -471,6 +473,23 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient =
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.`);
}
const { owner, repo } = context.repo;

// Determine the effective owner/repo for content resolution.
// When target_repo is provided, use it instead of the workflow's host repo.
// This enables org-level project workflows to resolve issues from other repos.
let contentOwner = owner;
let targetRepo = repo;
if (output.target_repo && typeof output.target_repo === "string") {
const targetRepoSlug = output.target_repo.trim();
const parsed = parseRepoSlug(targetRepoSlug);
if (!parsed) {
throw new Error(`${ERR_VALIDATION}: Invalid target_repo format "${targetRepoSlug}". Use "owner/repo" format (e.g., "github/docs").`);
}
contentOwner = parsed.owner;
targetRepo = parsed.repo;
core.info(`Using target_repo ${targetRepoSlug} for content resolution`);
}

const projectInfo = parseProjectUrl(output.project);
const projectNumberFromUrl = projectInfo.projectNumber;

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

// Resolve target-repo and allowed-repos for cross-repo content resolution validation
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);

// Check if we're in staged mode
const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true";

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

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

// Validate target_repo if provided: must be in the allowed repos list.
// Note: defaultTargetRepo already falls back to context.repo (the current workflow repository)
// when no target-repo is configured in the frontmatter — so the host repo is always implicitly allowed.
if (message.target_repo && typeof message.target_repo === "string") {
const targetRepoSlug = message.target_repo.trim();
// defaultTargetRepo (target-repo config or current workflow repo) is always permitted;
// additional repos must be listed in allowed-repos.
const isDefaultRepo = targetRepoSlug === defaultTargetRepo;
if (!isDefaultRepo && !isRepoAllowed(targetRepoSlug, allowedRepos)) {
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.`;
core.error(errorMsg);
return {
success: false,
error: errorMsg,
};
}
}

// Check max limit
if (processedCount >= maxCount) {
core.warning(`Skipping update_project: max count of ${maxCount} reached`);
Expand Down
196 changes: 196 additions & 0 deletions actions/setup/js/update_project.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1994,3 +1994,199 @@ describe("update_project temporary project ID resolution", () => {
expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID"));
});
});

describe("update_project target_repo cross-repo content resolution", () => {
it("uses target_repo owner/repo when resolving issue content_number", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 123,
target_repo: "otherorg/otherrepo",
};

// Queue responses - issue is resolved against otherorg/otherrepo
queueResponses([
repoResponse(), // repository info for testowner/testrepo (project owner lookup)
viewerResponse(),
orgProjectV2Response(projectUrl, 60, "project123"),
issueResponse("issue-id-123"),
emptyItemsResponse(),
{ addProjectV2ItemById: { item: { id: "item-cross" } } },
]);

await updateProject(output);

// Verify the GraphQL query was made with the correct cross-repo owner/repo
const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
expect(contentQueryCall).toBeDefined();
expect(contentQueryCall[1]).toMatchObject({ owner: "otherorg", repo: "otherrepo", number: 123 });

expect(getOutput("item-id")).toBe("item-cross");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using target_repo otherorg/otherrepo for content resolution"));
});

it("normalizes camelCase targetRepo to target_repo", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 5,
targetRepo: "otherorg/otherrepo", // camelCase alias
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-5"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-camel" } } }]);

await updateProject(output);

const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
expect(contentQueryCall).toBeDefined();
expect(contentQueryCall[1]).toMatchObject({ owner: "otherorg", repo: "otherrepo", number: 5 });
});

it("throws on invalid target_repo format", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 1,
target_repo: "invalid-no-slash",
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123")]);

await expect(updateProject(output)).rejects.toThrow(/Invalid target_repo format/);
});

it("falls back to context.repo when target_repo is not provided", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const output = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 7,
// No target_repo - should use context.repo (testowner/testrepo)
};

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-7"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-default" } } }]);

await updateProject(output);

const contentQueryCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("issue(number:"));
expect(contentQueryCall).toBeDefined();
// Should use context.repo values (testowner/testrepo)
expect(contentQueryCall[1]).toMatchObject({ owner: "testowner", repo: "testrepo", number: 7 });
expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Using target_repo"));
});
});

describe("update_project handler: target_repo allowed-repos validation", () => {
let messageHandler;

beforeEach(() => {
mockGithub.graphql.mockReset();
clearCoreMocks();
});

it("rejects target_repo not in allowed-repos", async () => {
const config = { max: 10, allowed_repos: ["org/allowed-repo"] };
messageHandler = await updateProjectHandlerFactory(config, mockGithub);

const message = {
type: "update_project",
project: "https://github.com/orgs/testowner/projects/60",
content_type: "issue",
content_number: 1,
target_repo: "org/forbidden-repo",
};

const result = await messageHandler(message, {}, new Map());

expect(result.success).toBe(false);
expect(result.error).toMatch(/not allowed for cross-repo content resolution/);
expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("org/forbidden-repo"));
});

it("allows target_repo that matches the default target-repo config", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const config = { max: 10, "target-repo": "org/target-repo", allowed_repos: [] };
messageHandler = await updateProjectHandlerFactory(config, mockGithub);

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-2"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-allowed" } } }]);

const message = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 2,
target_repo: "org/target-repo", // Same as configured target-repo
};

const result = await messageHandler(message, {}, new Map());

expect(result.success).toBe(true);
});

it("allows target_repo that matches an entry in allowed-repos", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const config = { max: 10, allowed_repos: ["org/allowed-repo", "org/another-repo"] };
messageHandler = await updateProjectHandlerFactory(config, mockGithub);

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-3"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-in-list" } } }]);

const message = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 3,
target_repo: "org/allowed-repo",
};

const result = await messageHandler(message, {}, new Map());

expect(result.success).toBe(true);
});

it("allows wildcard allowed-repo pattern", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const config = { max: 10, allowed_repos: ["org/*"] };
messageHandler = await updateProjectHandlerFactory(config, mockGithub);

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-4"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-wildcard" } } }]);

const message = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 4,
target_repo: "org/any-repo-in-org",
};

const result = await messageHandler(message, {}, new Map());

expect(result.success).toBe(true);
});

it("does not validate target_repo when not provided", async () => {
const projectUrl = "https://github.com/orgs/testowner/projects/60";
const config = { max: 10, allowed_repos: ["org/specific-repo"] };
messageHandler = await updateProjectHandlerFactory(config, mockGithub);

queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project123"), issueResponse("issue-id-5"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "item-no-target" } } }]);

const message = {
type: "update_project",
project: projectUrl,
content_type: "issue",
content_number: 5,
// No target_repo - should pass validation
};

const result = await messageHandler(message, {}, new Map());

expect(result.success).toBe(true);
});
});
1 change: 1 addition & 0 deletions docs/src/content/docs/examples/multi-repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Most safe output types support the `target-repo` parameter for cross-repository
| `create-discussion` | ✅ | Create discussions in any repo |
| `create-agent-session` | ✅ | Create tasks in target repos |
| `update-release` | ✅ | Update release notes across repos |
| `update-project` | ✅ (`target_repo`) | Update project items from other repos |

**Configuration Example:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2964,7 +2964,7 @@ safe-outputs:
**Purpose**: Manage GitHub Projects V2 boards (add items, update fields, remove items).

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

**Required Permissions**:
Expand All @@ -2980,6 +2980,7 @@ safe-outputs:
**Notes**:
- Same permission requirements as `create_project`
- Higher default max (10) enables batch project board updates
- 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

---

Expand Down
30 changes: 30 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,8 @@ safe-outputs:
project: "https://github.com/orgs/myorg/projects/42" # required: target project URL
max: 20 # max operations (default: 10)
github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }}
target-repo: "org/default-repo" # optional: default repo for target_repo resolution
allowed-repos: ["org/repo-a", "org/repo-b"] # optional: additional repos for cross-repo items
views: # optional: auto-create views
- name: "Sprint Board"
layout: board
Expand All @@ -532,9 +534,37 @@ safe-outputs:
- `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.
- `max`: Maximum number of operations per run (default: 10).
- `github-token`: Custom token with Projects permissions (required for Projects v2 access).
- `target-repo`: Default repository for cross-repo content resolution in `owner/repo` format. Wildcards (`*`) are not allowed.
- `allowed-repos`: List of additional repositories whose issues/PRs can be resolved via `target_repo`. The `target-repo` is always implicitly allowed.
- `views`: Optional array of project views to create automatically.
- Exposes outputs: `project-id`, `project-number`, `project-url`, `item-id`.

#### Cross-Repository Content Resolution

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:

```yaml wrap
safe-outputs:
update-project:
github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }}
allowed-repos: ["org/docs", "org/backend", "org/frontend"]
```

The agent can then specify `target_repo` alongside `content_number`:

```json
{
"type": "update_project",
"project": "https://github.com/orgs/myorg/projects/42",
"content_type": "issue",
"content_number": 123,
"target_repo": "org/docs",
"fields": { "Status": "In Progress" }
}
```

Without `target_repo`, the workflow's host repository is used to resolve `content_number`.

#### Supported Field Types

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