Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
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'."
},
"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)."
Expand Down
19 changes: 18 additions & 1 deletion actions/setup/js/update_project.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.`);
}
Comment on lines +1025 to +1027
}
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) {
Expand Down
134 changes: 134 additions & 0 deletions actions/setup/js/update_project.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
Expand Down
4 changes: 4 additions & 0 deletions schemas/agent-output.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down