diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index f4da663e56e..d8acf2197a9 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -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'." }, + "content_repo": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "description": "Repository that owns the issue or PR, in 'owner/repo' format (e.g., 'github/docs'). When provided, overrides the workflow host repository for resolving content_number. Use this for cross-repo project item resolution when the issue/PR lives in a different repository than where the workflow runs." + }, "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)." diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 638083f2bfd..64599e2bf24 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -23,6 +23,9 @@ 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.content_repo === undefined && output.contentRepo !== undefined) output.content_repo = output.contentRepo; + // Support YAML dash-style alias: content-repo → content_repo + if (output.content_repo === undefined && output["content-repo"] !== undefined) output.content_repo = output["content-repo"]; 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; @@ -1009,12 +1012,26 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = } } if (null !== contentNumber) { + // Determine owner/repo for content resolution: content_repo overrides context.repo for cross-repo support + let contentOwner = owner; + let contentRepo = repo; + if (output.content_repo) { + const trimmedContentRepo = output.content_repo.trim(); + const parts = trimmedContentRepo.split("/").map(p => p.trim()); + if (parts.length === 2 && parts[0] && parts[1]) { + contentOwner = parts[0]; + contentRepo = parts[1]; + core.info(`Using content_repo for resolution: ${contentOwner}/${contentRepo}`); + } else { + core.warning(`Invalid content_repo format "${output.content_repo}": expected "owner/repo". Falling back to workflow host repository.`); + } + } const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", contentQuery = "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: contentRepo, number: contentNumber }), contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest, contentId = contentData.id, existingItem = await (async function (projectId, contentId) { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 62f71b539b5..b10188ee19c 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -1015,6 +1015,140 @@ describe("updateProject", () => { await expect(updateProject(output, temporaryIdMap)).rejects.toThrow(/Temporary ID 'aw_abc789' not found in map/); }); + it("resolves content_number using content_repo for cross-repo issues", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 99, + content_repo: "otherowner/otherrepo", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-cross-repo"), issueResponse("cross-repo-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "cross-repo-item" } } }]); + + await updateProject(output); + + // Verify the content resolution used the cross-repo owner/repo + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "otherowner"); + expect(contentQuery).toBeDefined(); + expect(contentQuery[1]).toMatchObject({ owner: "otherowner", repo: "otherrepo", number: 99 }); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Using content_repo for resolution: otherowner/otherrepo")); + expect(getOutput("item-id")).toBe("cross-repo-item"); + }); + + it("resolves content_number using content_repo for cross-repo pull requests", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "pull_request", + content_number: 55, + content_repo: "anotherorg/anotherrepo", + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-cross-repo-pr"), + pullRequestResponse("cross-repo-pr-id"), + emptyItemsResponse(), + { addProjectV2ItemById: { item: { id: "cross-repo-pr-item" } } }, + ]); + + await updateProject(output); + + // Verify the content resolution used the cross-repo owner/repo + const prQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("pullRequest(number:") && vars?.owner === "anotherorg"); + expect(prQuery).toBeDefined(); + expect(prQuery[1]).toMatchObject({ owner: "anotherorg", repo: "anotherrepo", number: 55 }); + expect(getOutput("item-id")).toBe("cross-repo-pr-item"); + }); + + it("normalizes camelCase contentRepo to content_repo", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 77, + contentRepo: "camelowner/camelrepo", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-camel-repo"), issueResponse("camel-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "camel-item" } } }]); + + await updateProject(output); + + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "camelowner"); + expect(contentQuery).toBeDefined(); + expect(contentQuery[1]).toMatchObject({ owner: "camelowner", repo: "camelrepo", number: 77 }); + expect(getOutput("item-id")).toBe("camel-item"); + }); + + it("normalizes dash-style content-repo alias to content_repo", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 88, + "content-repo": "dashowner/dashrepo", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-dash-repo"), issueResponse("dash-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "dash-item" } } }]); + + await updateProject(output); + + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "dashowner"); + expect(contentQuery).toBeDefined(); + expect(contentQuery[1]).toMatchObject({ owner: "dashowner", repo: "dashrepo", number: 88 }); + expect(getOutput("item-id")).toBe("dash-item"); + }); + + it("trims whitespace from content_repo before resolving", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 99, + content_repo: " trimowner/trimrepo ", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-trim-repo"), issueResponse("trim-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "trim-item" } } }]); + + await updateProject(output); + + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "trimowner"); + expect(contentQuery).toBeDefined(); + expect(contentQuery[1]).toMatchObject({ owner: "trimowner", repo: "trimrepo", number: 99 }); + expect(mockCore.warning).not.toHaveBeenCalled(); + }); + + it("warns and falls back to context.repo on invalid content_repo format (no slash)", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 42, + content_repo: "noslashrepo", + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-fallback"), issueResponse("fallback-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "fallback-item" } } }]); + + await updateProject(output); + + // Warning should be emitted once with the full invalid format message + expect(mockCore.warning).toHaveBeenCalledTimes(1); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Invalid content_repo format "noslashrepo": expected "owner/repo". Falling back to workflow host repository.')); + + // GraphQL content query should use context.repo fallback (testowner/testrepo) + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "testowner" && vars?.repo === "testrepo"); + expect(contentQuery).toBeDefined(); + expect(contentQuery[1]).toMatchObject({ owner: "testowner", repo: "testrepo", number: 42 }); + }); + it("updates an existing text field", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/60"; const output = { diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 55537e67542..6a58c97ef48 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -1222,6 +1222,11 @@ ], "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'." }, + "content_repo": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", + "description": "Repository that owns the issue or PR, in 'owner/repo' format (e.g., 'github/docs'). When provided, overrides the workflow host repository for resolving content_number. Use this for cross-repo project item resolution when the issue/PR lives in a different repository than where the workflow runs." + }, "draft_title": { "type": "string", "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'." diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 1602dd7fb9f..1a78c3ca3c1 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -593,6 +593,10 @@ "oneOf": [{ "type": "number" }, { "type": "string" }], "description": "Issue or PR number (preferred field)" }, + "content_repo": { + "type": "string", + "description": "Repository that owns the issue or PR, in 'owner/repo' format (e.g., 'github/docs'). When provided, overrides the workflow host repository for resolving content_number. Use this for cross-repo project item resolution when the issue/PR lives in a different repository than where the workflow runs." + }, "issue": { "oneOf": [{ "type": "number" }, { "type": "string" }], "description": "Issue number (legacy field, use content_number instead)"