From 16c4939099b2f432d9f6774c39334394624deafd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:32:42 +0000 Subject: [PATCH 1/5] Initial plan From 0ec12873d4de91a8e9dfc622694a6b97cf387e2f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:59:05 +0000 Subject: [PATCH 2/5] Add content_repo field to update_project safe output for cross-repo project item resolution Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_outputs_tools.json | 5 ++ actions/setup/js/update_project.cjs | 16 +++++- actions/setup/js/update_project.test.cjs | 70 ++++++++++++++++++++++++ pkg/workflow/js/safe_outputs_tools.json | 5 ++ schemas/agent-output.json | 4 ++ 5 files changed, 99 insertions(+), 1 deletion(-) 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..62e77649ba2 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -23,6 +23,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.content_repo === undefined && output.contentRepo !== undefined) output.content_repo = output.contentRepo; 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 +1010,25 @@ 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 parts = output.content_repo.split("/"); + 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..9dd70300aa1 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -1015,6 +1015,76 @@ 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("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)" From ae6c1f5d7455b23c87d86206b1ae59c2dfe9be90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:35:49 +0000 Subject: [PATCH 3/5] feat: add allowed-repos config to update_project for content_repo validation, Go parser support, and agentic workflow test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 34 +++++- actions/setup/js/update_project.test.cjs | 108 ++++++++++++++++++ .../test-update-project-cross-repo.md | 63 ++++++++++ pkg/parser/schemas/main_workflow_schema.json | 12 ++ pkg/workflow/compiler_safe_outputs_config.go | 3 +- .../safe_outputs_cross_repo_config_test.go | 88 +++++++++++++- pkg/workflow/update_project.go | 10 +- 7 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 pkg/cli/workflows/test-update-project-cross-repo.md diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 62e77649ba2..a2ab14a19a7 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -5,7 +5,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); 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 { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_PERMISSION, ERR_VALIDATION } = require("./error_codes.cjs"); /** * Normalize agent output keys for update_project. @@ -460,9 +460,10 @@ async function fetchAllProjectFields(github, projectId) { * @param {any} output - Safe output configuration * @param {Map} temporaryIdMap - Map of temporary IDs to resolved issue numbers * @param {Object} githubClient - GitHub client (Octokit instance) to use for GraphQL queries + * @param {Set|null} allowedContentRepos - Optional set of allowed repos for content_repo validation. When non-empty, only repos in this set (plus the workflow's host repository from context.repo) are accepted as content_repo values. When null or empty, any repo is accepted. * @returns {Promise} Returns undefined for most operations, or an object with temporary ID mapping for draft issue creation */ -async function updateProject(output, temporaryIdMap = new Map(), githubClient = null) { +async function updateProject(output, temporaryIdMap = new Map(), githubClient = null, allowedContentRepos = null) { output = normalizeUpdateProjectOutput(output); // Use the provided github client, or fall back to the global github object @@ -1016,6 +1017,20 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = if (output.content_repo) { const parts = output.content_repo.split("/"); if (parts.length === 2 && parts[0] && parts[1]) { + const requestedContentRepo = output.content_repo; + const defaultRepo = `${owner}/${repo}`; + // Validate against allowed_repos when configured + if (allowedContentRepos !== null && allowedContentRepos.size > 0) { + const isDefaultRepo = requestedContentRepo === defaultRepo; + const isAllowed = isDefaultRepo || allowedContentRepos.has(requestedContentRepo); + if (!isAllowed) { + throw new Error( + `${ERR_PERMISSION}: content_repo "${requestedContentRepo}" is not in the allowed-repos list. ` + + `Allowed: ${defaultRepo}${allowedContentRepos.size > 0 ? ", " + Array.from(allowedContentRepos).join(", ") : ""}. ` + + `Configure allowed-repos under safe-outputs.update-project in your workflow frontmatter.` + ); + } + } contentOwner = parts[0]; contentRepo = parts[1]; core.info(`Using content_repo for resolution: ${contentOwner}/${contentRepo}`); @@ -1228,6 +1243,12 @@ async function main(config = {}, githubClient = null) { const configuredViews = Array.isArray(config.views) ? config.views : []; const configuredFieldDefinitions = Array.isArray(config.field_definitions) ? config.field_definitions : []; + // Parse allowed-repos for content_repo validation (cross-repo issue/PR resolution) + // When non-empty, only repos in this set (plus the workflow's host repository) are accepted as content_repo values. + const rawAllowedRepos = Array.isArray(config.allowed_repos) ? config.allowed_repos : []; + const sanitizedAllowedRepos = rawAllowedRepos.map(r => (typeof r === "string" ? r.trim() : "")).filter(r => r); + const allowedContentRepos = new Set(sanitizedAllowedRepos); + // Check if we're in staged mode const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; @@ -1237,6 +1258,9 @@ async function main(config = {}, githubClient = null) { if (configuredFieldDefinitions.length > 0) { core.info(`Found ${configuredFieldDefinitions.length} configured field definition(s) in frontmatter`); } + if (allowedContentRepos.size > 0) { + core.info(`Allowed content repos: ${Array.from(allowedContentRepos).join(", ")}`); + } core.info(`Max count: ${maxCount}`); // Track state @@ -1356,7 +1380,7 @@ async function main(config = {}, githubClient = null) { }; try { - await updateProject(fieldsOutput, tempIdMap, github); + await updateProject(fieldsOutput, tempIdMap, github, allowedContentRepos); core.info("✓ Created configured fields"); } catch (err) { // prettier-ignore @@ -1388,7 +1412,7 @@ async function main(config = {}, githubClient = null) { } // Process the update_project message - const updateResult = await updateProject(effectiveMessage, tempIdMap, github); + const updateResult = await updateProject(effectiveMessage, tempIdMap, github, allowedContentRepos); // After processing the first message, create configured views if any // Views are created after the first item is processed to ensure the project exists @@ -1413,7 +1437,7 @@ async function main(config = {}, githubClient = null) { }, }; - await updateProject(viewOutput, tempIdMap, github); + await updateProject(viewOutput, tempIdMap, github, allowedContentRepos); core.info(`✓ Created view ${i + 1}/${configuredViews.length}: ${viewConfig.name} (${viewConfig.layout})`); } catch (err) { // prettier-ignore diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 9dd70300aa1..cc82b2c9efe 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -2064,3 +2064,111 @@ describe("update_project temporary project ID resolution", () => { expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID")); }); }); + +describe("update_project allowed_repos for content_repo validation", () => { + let messageHandler; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGithub.graphql.mockReset(); + }); + + it("allows content_repo in the allowed list", async () => { + messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["otherowner/otherrepo"] }, mockGithub); + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-allowed"), issueResponse("allowed-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "allowed-item" } } }]); + + const result = await messageHandler( + { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 42, + content_repo: "otherowner/otherrepo", + }, + {}, + new Map() + ); + + expect(result.success).toBe(true); + 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: 42 }); + }); + + it("rejects content_repo not in the allowed list", async () => { + messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["otherowner/otherrepo"] }, mockGithub); + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-rejected")]); + + const result = await messageHandler( + { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 42, + content_repo: "evilorg/evilrepo", + }, + {}, + new Map() + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("evilorg/evilrepo"); + expect(result.error).toContain("not in the allowed-repos list"); + }); + + it("always allows context.repo as content_repo even when allowed_repos is configured", async () => { + messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["someorg/other"] }, mockGithub); + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-default"), issueResponse("default-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "default-item" } } }]); + + // testowner/testrepo is context.repo - should always be allowed + const result = await messageHandler( + { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 7, + content_repo: "testowner/testrepo", + }, + {}, + new Map() + ); + + expect(result.success).toBe(true); + const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "testowner"); + expect(contentQuery).toBeDefined(); + }); + + it("allows any content_repo when allowed_repos is not configured", async () => { + messageHandler = await updateProjectHandlerFactory({}, mockGithub); + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-open"), issueResponse("open-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "open-item" } } }]); + + const result = await messageHandler( + { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 99, + content_repo: "anyorg/anyrepo", + }, + {}, + new Map() + ); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("logs allowed content repos at startup when configured", async () => { + messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["org/repo1", "org/repo2"] }, mockGithub); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Allowed content repos: org/repo1, org/repo2")); + }); +}); diff --git a/pkg/cli/workflows/test-update-project-cross-repo.md b/pkg/cli/workflows/test-update-project-cross-repo.md new file mode 100644 index 00000000000..3906258eee8 --- /dev/null +++ b/pkg/cli/workflows/test-update-project-cross-repo.md @@ -0,0 +1,63 @@ +--- +description: Test update-project with content_repo for cross-repo project item resolution +on: + workflow_dispatch: + inputs: + project_url: + description: "GitHub Projects v2 URL (e.g., https://github.com/orgs/myorg/projects/42)" + required: true + type: string + source_repo: + description: "Source repository for issues (e.g., myorg/other-repo)" + required: true + type: string + issue_number: + description: "Issue number in the source repository" + required: true + type: string +permissions: + contents: read + issues: read + pull-requests: read +engine: copilot +tools: + github: + mode: remote + toolsets: [default] +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + project: "https://github.com/orgs//projects/" + allowed-repos: + - ${{ inputs.source_repo }} +--- + +# Test Update Project with Cross-Repo Content Resolution + +This workflow tests the `content_repo` field on the `update_project` safe output, which +enables updating project fields for issues and pull requests that originate from repositories +other than the workflow's host repository. + +This is useful for organization-level projects that aggregate issues from multiple repos. + +## Task + +Use the `update_project` tool to add issue #${{ inputs.issue_number }} from `${{ inputs.source_repo }}` to the project at `${{ inputs.project_url }}` and set its status to "In Progress". + +You must include `content_repo: "${{ inputs.source_repo }}"` in the `update_project` output to resolve the issue from the correct repository. + +Example output: +```json +{ + "type": "update_project", + "project": "${{ inputs.project_url }}", + "content_type": "issue", + "content_number": ${{ inputs.issue_number }}, + "content_repo": "${{ inputs.source_repo }}", + "fields": { + "Status": "In Progress" + } +} +``` + +After updating the project item, report what you did. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 096121391a2..c2cb967ecfd 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4691,6 +4691,13 @@ }, "additionalProperties": false } + }, + "allowed-repos": { + "type": "array", + "description": "Optional list of additional repositories (in 'owner/repo' format) that are allowed as content_repo values for cross-repo issue/PR resolution. When configured, agents may only reference issues or pull requests from these repositories (plus the workflow host repository). Wildcards are supported (e.g., 'myorg/*'). Example: ['github/docs', 'github/github'].", + "items": { + "type": "string" + } } }, "additionalProperties": false, @@ -4701,6 +4708,11 @@ { "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", "max": 15 + }, + { + "allowed-repos": ["myorg/docs", "myorg/api"], + "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", + "max": 20 } ] }, diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 259bcd039a8..b8a2a17762d 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -663,7 +663,8 @@ var handlerRegistry = map[string]handlerBuilder{ builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project) + AddIfNotEmpty("project", c.Project). + AddStringSlice("allowed_repos", c.AllowedRepos) if len(c.Views) > 0 { builder.AddDefault("views", c.Views) } diff --git a/pkg/workflow/safe_outputs_cross_repo_config_test.go b/pkg/workflow/safe_outputs_cross_repo_config_test.go index cb7098f1869..64d6aa48915 100644 --- a/pkg/workflow/safe_outputs_cross_repo_config_test.go +++ b/pkg/workflow/safe_outputs_cross_repo_config_test.go @@ -420,9 +420,91 @@ func TestPushToPullRequestBranchCrossRepoInHandlerConfig(t *testing.T) { assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo") } -// TestHandlerManagerStepPerOutputTokenInHandlerConfig verifies that per-output tokens -// (e.g., add-comment.github-token) are wired into the handler config JSON (GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) -// but NOT used as the step-level with.github-token. The step-level token follows the same +// TestUpdateProjectAllowedReposConfig verifies that the `allowed-repos` key in the +// `update-project` frontmatter configuration is correctly parsed and populates the +// AllowedRepos field on UpdateProjectConfig. +func TestUpdateProjectAllowedReposConfig(t *testing.T) { + compiler := NewCompiler() + + tests := []struct { + name string + configMap map[string]any + expectedRepos []string + }{ + { + name: "allowed-repos configured", + configMap: map[string]any{ + "update-project": map[string]any{ + "allowed-repos": []any{"github/docs", "github/github"}, + }, + }, + expectedRepos: []string{"github/docs", "github/github"}, + }, + { + name: "allowed-repos with single repo", + configMap: map[string]any{ + "update-project": map[string]any{ + "allowed-repos": []any{"myorg/myrepo"}, + }, + }, + expectedRepos: []string{"myorg/myrepo"}, + }, + { + name: "no allowed-repos", + configMap: map[string]any{ + "update-project": map[string]any{ + "max": 5, + }, + }, + expectedRepos: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := compiler.parseUpdateProjectConfig(tt.configMap) + + require.NotNil(t, cfg, "config should not be nil") + assert.Equal(t, tt.expectedRepos, cfg.AllowedRepos, "AllowedRepos mismatch") + }) + } +} + +// TestUpdateProjectAllowedReposInHandlerConfig verifies the end-to-end flow: that +// allowed-repos on UpdateProjectConfig is compiled into the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG +// environment variable as `allowed_repos`, so the JS runtime receives the configuration. +func TestUpdateProjectAllowedReposInHandlerConfig(t *testing.T) { + compiler := NewCompiler() + + workflowData := &WorkflowData{ + Name: "Test", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + GitHubToken: "${{ secrets.PROJECT_GITHUB_TOKEN }}", + Project: "https://github.com/orgs/myorg/projects/42", + AllowedRepos: []string{"github/docs", "github/github"}, + }, + }, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + + require.NotEmpty(t, steps) + handlerConfig := extractHandlerConfig(t, strings.Join(steps, "")) + + updateProject, ok := handlerConfig["update_project"] + require.True(t, ok, "update_project config should be present") + + assert.Equal(t, "${{ secrets.PROJECT_GITHUB_TOKEN }}", updateProject["github-token"], "github-token should be in handler config") + assert.Equal(t, "https://github.com/orgs/myorg/projects/42", updateProject["project"], "project should be in handler config") + + allowedRepos, ok := updateProject["allowed_repos"] + require.True(t, ok, "allowed_repos should be present") + assert.Contains(t, allowedRepos, "github/docs", "allowed_repos should contain github/docs") + assert.Contains(t, allowedRepos, "github/github", "allowed_repos should contain github/github") +} + // precedence as github_token.go: project token > global safe-outputs token > magic secrets. func TestHandlerManagerStepPerOutputTokenInHandlerConfig(t *testing.T) { compiler := NewCompiler() diff --git a/pkg/workflow/update_project.go b/pkg/workflow/update_project.go index 3efc4cf111a..110481dcafa 100644 --- a/pkg/workflow/update_project.go +++ b/pkg/workflow/update_project.go @@ -25,7 +25,8 @@ type ProjectFieldDefinition struct { type UpdateProjectConfig struct { BaseSafeOutputConfig `yaml:",inline"` GitHubToken string `yaml:"github-token,omitempty"` - Project string `yaml:"project,omitempty"` // Default project URL for operations + Project string `yaml:"project,omitempty"` // Default project URL for operations + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // Optional list of repositories allowed as content_repo for cross-repo issue/PR resolution Views []ProjectView `yaml:"views,omitempty"` FieldDefinitions []ProjectFieldDefinition `yaml:"field-definitions,omitempty" json:"field_definitions,omitempty"` } @@ -57,6 +58,9 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro } } + // Parse allowed-repos for cross-repo content_repo validation + updateProjectConfig.AllowedRepos = parseAllowedReposFromConfig(configMap) + // Parse views if specified updateProjectConfig.Views = parseProjectViews(configMap, updateProjectLog) @@ -64,8 +68,8 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro updateProjectConfig.FieldDefinitions = parseProjectFieldDefinitions(configMap, updateProjectLog) } - updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, hasCustomProject=%v, viewCount=%d, fieldDefinitionCount=%d", - updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) + updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, hasCustomProject=%v, allowedReposCount=%d, viewCount=%d, fieldDefinitionCount=%d", + updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", len(updateProjectConfig.AllowedRepos), len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) return updateProjectConfig } updateProjectLog.Print("No update-project configuration found") From ba434cb5bd85693062c0e5778e0cee6cda9c0f5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:50:43 +0000 Subject: [PATCH 4/5] fix(update_project): add content-repo dash alias, trim whitespace, add missing test for invalid format Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 7 ++- actions/setup/js/update_project.test.cjs | 64 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index a2ab14a19a7..ebb6ed9c9ff 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -24,6 +24,8 @@ 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; @@ -1015,9 +1017,10 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = let contentOwner = owner; let contentRepo = repo; if (output.content_repo) { - const parts = output.content_repo.split("/"); + const trimmedContentRepo = output.content_repo.trim(); + const parts = trimmedContentRepo.split("/").map(p => p.trim()); if (parts.length === 2 && parts[0] && parts[1]) { - const requestedContentRepo = output.content_repo; + const requestedContentRepo = `${parts[0]}/${parts[1]}`; const defaultRepo = `${owner}/${repo}`; // Validate against allowed_repos when configured if (allowedContentRepos !== null && allowedContentRepos.size > 0) { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index cc82b2c9efe..b43d75ef2b9 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -1085,6 +1085,70 @@ describe("updateProject", () => { 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 = { From 282c9a53ce4c6df1a6bddb56c835e85c4753f05c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:03:58 +0000 Subject: [PATCH 5/5] revert: remove allowed-repos config from update_project, keep content-repo support only Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_project.cjs | 34 +----- actions/setup/js/update_project.test.cjs | 108 ------------------ .../test-update-project-cross-repo.md | 63 ---------- pkg/parser/schemas/main_workflow_schema.json | 12 -- pkg/workflow/compiler_safe_outputs_config.go | 3 +- .../safe_outputs_cross_repo_config_test.go | 88 +------------- pkg/workflow/update_project.go | 10 +- 7 files changed, 12 insertions(+), 306 deletions(-) delete mode 100644 pkg/cli/workflows/test-update-project-cross-repo.md diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index ebb6ed9c9ff..64599e2bf24 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -5,7 +5,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); 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_PERMISSION, ERR_VALIDATION } = require("./error_codes.cjs"); +const { ERR_API, ERR_CONFIG, ERR_NOT_FOUND, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); /** * Normalize agent output keys for update_project. @@ -462,10 +462,9 @@ async function fetchAllProjectFields(github, projectId) { * @param {any} output - Safe output configuration * @param {Map} temporaryIdMap - Map of temporary IDs to resolved issue numbers * @param {Object} githubClient - GitHub client (Octokit instance) to use for GraphQL queries - * @param {Set|null} allowedContentRepos - Optional set of allowed repos for content_repo validation. When non-empty, only repos in this set (plus the workflow's host repository from context.repo) are accepted as content_repo values. When null or empty, any repo is accepted. * @returns {Promise} Returns undefined for most operations, or an object with temporary ID mapping for draft issue creation */ -async function updateProject(output, temporaryIdMap = new Map(), githubClient = null, allowedContentRepos = null) { +async function updateProject(output, temporaryIdMap = new Map(), githubClient = null) { output = normalizeUpdateProjectOutput(output); // Use the provided github client, or fall back to the global github object @@ -1020,20 +1019,6 @@ async function updateProject(output, temporaryIdMap = new Map(), githubClient = const trimmedContentRepo = output.content_repo.trim(); const parts = trimmedContentRepo.split("/").map(p => p.trim()); if (parts.length === 2 && parts[0] && parts[1]) { - const requestedContentRepo = `${parts[0]}/${parts[1]}`; - const defaultRepo = `${owner}/${repo}`; - // Validate against allowed_repos when configured - if (allowedContentRepos !== null && allowedContentRepos.size > 0) { - const isDefaultRepo = requestedContentRepo === defaultRepo; - const isAllowed = isDefaultRepo || allowedContentRepos.has(requestedContentRepo); - if (!isAllowed) { - throw new Error( - `${ERR_PERMISSION}: content_repo "${requestedContentRepo}" is not in the allowed-repos list. ` + - `Allowed: ${defaultRepo}${allowedContentRepos.size > 0 ? ", " + Array.from(allowedContentRepos).join(", ") : ""}. ` + - `Configure allowed-repos under safe-outputs.update-project in your workflow frontmatter.` - ); - } - } contentOwner = parts[0]; contentRepo = parts[1]; core.info(`Using content_repo for resolution: ${contentOwner}/${contentRepo}`); @@ -1246,12 +1231,6 @@ async function main(config = {}, githubClient = null) { const configuredViews = Array.isArray(config.views) ? config.views : []; const configuredFieldDefinitions = Array.isArray(config.field_definitions) ? config.field_definitions : []; - // Parse allowed-repos for content_repo validation (cross-repo issue/PR resolution) - // When non-empty, only repos in this set (plus the workflow's host repository) are accepted as content_repo values. - const rawAllowedRepos = Array.isArray(config.allowed_repos) ? config.allowed_repos : []; - const sanitizedAllowedRepos = rawAllowedRepos.map(r => (typeof r === "string" ? r.trim() : "")).filter(r => r); - const allowedContentRepos = new Set(sanitizedAllowedRepos); - // Check if we're in staged mode const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; @@ -1261,9 +1240,6 @@ async function main(config = {}, githubClient = null) { if (configuredFieldDefinitions.length > 0) { core.info(`Found ${configuredFieldDefinitions.length} configured field definition(s) in frontmatter`); } - if (allowedContentRepos.size > 0) { - core.info(`Allowed content repos: ${Array.from(allowedContentRepos).join(", ")}`); - } core.info(`Max count: ${maxCount}`); // Track state @@ -1383,7 +1359,7 @@ async function main(config = {}, githubClient = null) { }; try { - await updateProject(fieldsOutput, tempIdMap, github, allowedContentRepos); + await updateProject(fieldsOutput, tempIdMap, github); core.info("✓ Created configured fields"); } catch (err) { // prettier-ignore @@ -1415,7 +1391,7 @@ async function main(config = {}, githubClient = null) { } // Process the update_project message - const updateResult = await updateProject(effectiveMessage, tempIdMap, github, allowedContentRepos); + const updateResult = await updateProject(effectiveMessage, tempIdMap, github); // After processing the first message, create configured views if any // Views are created after the first item is processed to ensure the project exists @@ -1440,7 +1416,7 @@ async function main(config = {}, githubClient = null) { }, }; - await updateProject(viewOutput, tempIdMap, github, allowedContentRepos); + await updateProject(viewOutput, tempIdMap, github); core.info(`✓ Created view ${i + 1}/${configuredViews.length}: ${viewConfig.name} (${viewConfig.layout})`); } catch (err) { // prettier-ignore diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index b43d75ef2b9..b10188ee19c 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -2128,111 +2128,3 @@ describe("update_project temporary project ID resolution", () => { expect(mockCore.info).not.toHaveBeenCalledWith(expect.stringContaining("Resolved temporary project ID")); }); }); - -describe("update_project allowed_repos for content_repo validation", () => { - let messageHandler; - - beforeEach(async () => { - vi.clearAllMocks(); - mockGithub.graphql.mockReset(); - }); - - it("allows content_repo in the allowed list", async () => { - messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["otherowner/otherrepo"] }, mockGithub); - const projectUrl = "https://github.com/orgs/testowner/projects/60"; - - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-allowed"), issueResponse("allowed-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "allowed-item" } } }]); - - const result = await messageHandler( - { - type: "update_project", - project: projectUrl, - content_type: "issue", - content_number: 42, - content_repo: "otherowner/otherrepo", - }, - {}, - new Map() - ); - - expect(result.success).toBe(true); - 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: 42 }); - }); - - it("rejects content_repo not in the allowed list", async () => { - messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["otherowner/otherrepo"] }, mockGithub); - const projectUrl = "https://github.com/orgs/testowner/projects/60"; - - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-rejected")]); - - const result = await messageHandler( - { - type: "update_project", - project: projectUrl, - content_type: "issue", - content_number: 42, - content_repo: "evilorg/evilrepo", - }, - {}, - new Map() - ); - - expect(result.success).toBe(false); - expect(result.error).toContain("evilorg/evilrepo"); - expect(result.error).toContain("not in the allowed-repos list"); - }); - - it("always allows context.repo as content_repo even when allowed_repos is configured", async () => { - messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["someorg/other"] }, mockGithub); - const projectUrl = "https://github.com/orgs/testowner/projects/60"; - - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-default"), issueResponse("default-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "default-item" } } }]); - - // testowner/testrepo is context.repo - should always be allowed - const result = await messageHandler( - { - type: "update_project", - project: projectUrl, - content_type: "issue", - content_number: 7, - content_repo: "testowner/testrepo", - }, - {}, - new Map() - ); - - expect(result.success).toBe(true); - const contentQuery = mockGithub.graphql.mock.calls.find(([query, vars]) => query.includes("issue(number:") && vars?.owner === "testowner"); - expect(contentQuery).toBeDefined(); - }); - - it("allows any content_repo when allowed_repos is not configured", async () => { - messageHandler = await updateProjectHandlerFactory({}, mockGithub); - const projectUrl = "https://github.com/orgs/testowner/projects/60"; - - queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-open"), issueResponse("open-issue-id"), emptyItemsResponse(), { addProjectV2ItemById: { item: { id: "open-item" } } }]); - - const result = await messageHandler( - { - type: "update_project", - project: projectUrl, - content_type: "issue", - content_number: 99, - content_repo: "anyorg/anyrepo", - }, - {}, - new Map() - ); - - expect(result.success).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it("logs allowed content repos at startup when configured", async () => { - messageHandler = await updateProjectHandlerFactory({ allowed_repos: ["org/repo1", "org/repo2"] }, mockGithub); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Allowed content repos: org/repo1, org/repo2")); - }); -}); diff --git a/pkg/cli/workflows/test-update-project-cross-repo.md b/pkg/cli/workflows/test-update-project-cross-repo.md deleted file mode 100644 index 3906258eee8..00000000000 --- a/pkg/cli/workflows/test-update-project-cross-repo.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -description: Test update-project with content_repo for cross-repo project item resolution -on: - workflow_dispatch: - inputs: - project_url: - description: "GitHub Projects v2 URL (e.g., https://github.com/orgs/myorg/projects/42)" - required: true - type: string - source_repo: - description: "Source repository for issues (e.g., myorg/other-repo)" - required: true - type: string - issue_number: - description: "Issue number in the source repository" - required: true - type: string -permissions: - contents: read - issues: read - pull-requests: read -engine: copilot -tools: - github: - mode: remote - toolsets: [default] -safe-outputs: - update-project: - github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} - project: "https://github.com/orgs//projects/" - allowed-repos: - - ${{ inputs.source_repo }} ---- - -# Test Update Project with Cross-Repo Content Resolution - -This workflow tests the `content_repo` field on the `update_project` safe output, which -enables updating project fields for issues and pull requests that originate from repositories -other than the workflow's host repository. - -This is useful for organization-level projects that aggregate issues from multiple repos. - -## Task - -Use the `update_project` tool to add issue #${{ inputs.issue_number }} from `${{ inputs.source_repo }}` to the project at `${{ inputs.project_url }}` and set its status to "In Progress". - -You must include `content_repo: "${{ inputs.source_repo }}"` in the `update_project` output to resolve the issue from the correct repository. - -Example output: -```json -{ - "type": "update_project", - "project": "${{ inputs.project_url }}", - "content_type": "issue", - "content_number": ${{ inputs.issue_number }}, - "content_repo": "${{ inputs.source_repo }}", - "fields": { - "Status": "In Progress" - } -} -``` - -After updating the project item, report what you did. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c2cb967ecfd..096121391a2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4691,13 +4691,6 @@ }, "additionalProperties": false } - }, - "allowed-repos": { - "type": "array", - "description": "Optional list of additional repositories (in 'owner/repo' format) that are allowed as content_repo values for cross-repo issue/PR resolution. When configured, agents may only reference issues or pull requests from these repositories (plus the workflow host repository). Wildcards are supported (e.g., 'myorg/*'). Example: ['github/docs', 'github/github'].", - "items": { - "type": "string" - } } }, "additionalProperties": false, @@ -4708,11 +4701,6 @@ { "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", "max": 15 - }, - { - "allowed-repos": ["myorg/docs", "myorg/api"], - "github-token": "${{ secrets.PROJECT_GITHUB_TOKEN }}", - "max": 20 } ] }, diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index b8a2a17762d..259bcd039a8 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -663,8 +663,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project). - AddStringSlice("allowed_repos", c.AllowedRepos) + AddIfNotEmpty("project", c.Project) if len(c.Views) > 0 { builder.AddDefault("views", c.Views) } diff --git a/pkg/workflow/safe_outputs_cross_repo_config_test.go b/pkg/workflow/safe_outputs_cross_repo_config_test.go index 64d6aa48915..cb7098f1869 100644 --- a/pkg/workflow/safe_outputs_cross_repo_config_test.go +++ b/pkg/workflow/safe_outputs_cross_repo_config_test.go @@ -420,91 +420,9 @@ func TestPushToPullRequestBranchCrossRepoInHandlerConfig(t *testing.T) { assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo") } -// TestUpdateProjectAllowedReposConfig verifies that the `allowed-repos` key in the -// `update-project` frontmatter configuration is correctly parsed and populates the -// AllowedRepos field on UpdateProjectConfig. -func TestUpdateProjectAllowedReposConfig(t *testing.T) { - compiler := NewCompiler() - - tests := []struct { - name string - configMap map[string]any - expectedRepos []string - }{ - { - name: "allowed-repos configured", - configMap: map[string]any{ - "update-project": map[string]any{ - "allowed-repos": []any{"github/docs", "github/github"}, - }, - }, - expectedRepos: []string{"github/docs", "github/github"}, - }, - { - name: "allowed-repos with single repo", - configMap: map[string]any{ - "update-project": map[string]any{ - "allowed-repos": []any{"myorg/myrepo"}, - }, - }, - expectedRepos: []string{"myorg/myrepo"}, - }, - { - name: "no allowed-repos", - configMap: map[string]any{ - "update-project": map[string]any{ - "max": 5, - }, - }, - expectedRepos: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := compiler.parseUpdateProjectConfig(tt.configMap) - - require.NotNil(t, cfg, "config should not be nil") - assert.Equal(t, tt.expectedRepos, cfg.AllowedRepos, "AllowedRepos mismatch") - }) - } -} - -// TestUpdateProjectAllowedReposInHandlerConfig verifies the end-to-end flow: that -// allowed-repos on UpdateProjectConfig is compiled into the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG -// environment variable as `allowed_repos`, so the JS runtime receives the configuration. -func TestUpdateProjectAllowedReposInHandlerConfig(t *testing.T) { - compiler := NewCompiler() - - workflowData := &WorkflowData{ - Name: "Test", - SafeOutputs: &SafeOutputsConfig{ - UpdateProjects: &UpdateProjectConfig{ - GitHubToken: "${{ secrets.PROJECT_GITHUB_TOKEN }}", - Project: "https://github.com/orgs/myorg/projects/42", - AllowedRepos: []string{"github/docs", "github/github"}, - }, - }, - } - - var steps []string - compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) - - require.NotEmpty(t, steps) - handlerConfig := extractHandlerConfig(t, strings.Join(steps, "")) - - updateProject, ok := handlerConfig["update_project"] - require.True(t, ok, "update_project config should be present") - - assert.Equal(t, "${{ secrets.PROJECT_GITHUB_TOKEN }}", updateProject["github-token"], "github-token should be in handler config") - assert.Equal(t, "https://github.com/orgs/myorg/projects/42", updateProject["project"], "project should be in handler config") - - allowedRepos, ok := updateProject["allowed_repos"] - require.True(t, ok, "allowed_repos should be present") - assert.Contains(t, allowedRepos, "github/docs", "allowed_repos should contain github/docs") - assert.Contains(t, allowedRepos, "github/github", "allowed_repos should contain github/github") -} - +// TestHandlerManagerStepPerOutputTokenInHandlerConfig verifies that per-output tokens +// (e.g., add-comment.github-token) are wired into the handler config JSON (GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG) +// but NOT used as the step-level with.github-token. The step-level token follows the same // precedence as github_token.go: project token > global safe-outputs token > magic secrets. func TestHandlerManagerStepPerOutputTokenInHandlerConfig(t *testing.T) { compiler := NewCompiler() diff --git a/pkg/workflow/update_project.go b/pkg/workflow/update_project.go index 110481dcafa..3efc4cf111a 100644 --- a/pkg/workflow/update_project.go +++ b/pkg/workflow/update_project.go @@ -25,8 +25,7 @@ type ProjectFieldDefinition struct { type UpdateProjectConfig struct { BaseSafeOutputConfig `yaml:",inline"` GitHubToken string `yaml:"github-token,omitempty"` - Project string `yaml:"project,omitempty"` // Default project URL for operations - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // Optional list of repositories allowed as content_repo for cross-repo issue/PR resolution + Project string `yaml:"project,omitempty"` // Default project URL for operations Views []ProjectView `yaml:"views,omitempty"` FieldDefinitions []ProjectFieldDefinition `yaml:"field-definitions,omitempty" json:"field_definitions,omitempty"` } @@ -58,9 +57,6 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro } } - // Parse allowed-repos for cross-repo content_repo validation - updateProjectConfig.AllowedRepos = parseAllowedReposFromConfig(configMap) - // Parse views if specified updateProjectConfig.Views = parseProjectViews(configMap, updateProjectLog) @@ -68,8 +64,8 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro updateProjectConfig.FieldDefinitions = parseProjectFieldDefinitions(configMap, updateProjectLog) } - updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, hasCustomProject=%v, allowedReposCount=%d, viewCount=%d, fieldDefinitionCount=%d", - updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", len(updateProjectConfig.AllowedRepos), len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) + updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, hasCustomProject=%v, viewCount=%d, fieldDefinitionCount=%d", + updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) return updateProjectConfig } updateProjectLog.Print("No update-project configuration found")