diff --git a/.changeset/patch-update-project-content-repo.md b/.changeset/patch-update-project-content-repo.md new file mode 100644 index 00000000000..24801ef8c69 --- /dev/null +++ b/.changeset/patch-update-project-content-repo.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Added `target_repo` field to `update_project` safe output for cross-repository content resolution. Organization-level projects can now update fields for items from any configured repository by specifying `target_repo: "owner/repo"` in agent output. Configure `allowed-repos` in frontmatter to control which repositories are permitted. diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index f4da663e56e..9254786db96 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -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"], @@ -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)." diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 638083f2bfd..34a467aaf53 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -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. @@ -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; @@ -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; @@ -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) { @@ -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"; @@ -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`); diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 62f71b539b5..98d358350eb 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -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); + }); +}); diff --git a/docs/src/content/docs/examples/multi-repo.md b/docs/src/content/docs/examples/multi-repo.md index 7618bcc2d87..cfb2306c3e9 100644 --- a/docs/src/content/docs/examples/multi-repo.md +++ b/docs/src/content/docs/examples/multi-repo.md @@ -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:** diff --git a/docs/src/content/docs/reference/safe-outputs-specification.md b/docs/src/content/docs/reference/safe-outputs-specification.md index 3f096010a56..94d0ad69dc1 100644 --- a/docs/src/content/docs/reference/safe-outputs-specification.md +++ b/docs/src/content/docs/reference/safe-outputs-specification.md @@ -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**: @@ -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 --- diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index a3ef9091a2f..265aa6b8233 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -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 @@ -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: diff --git a/pkg/cli/workflows/test-copilot-update-project-cross-repo.md b/pkg/cli/workflows/test-copilot-update-project-cross-repo.md new file mode 100644 index 00000000000..7bebc64f002 --- /dev/null +++ b/pkg/cli/workflows/test-copilot-update-project-cross-repo.md @@ -0,0 +1,39 @@ +--- +description: Test update-project with target_repo for cross-repo project item resolution +on: + workflow_dispatch: +permissions: + contents: read +name: Test Copilot Update Project Cross Repo +engine: copilot +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }} + project: "https://github.com/orgs/myorg/projects/42" + target-repo: myorg/backend + allowed-repos: + - myorg/docs + - myorg/frontend +--- + +# Test Cross-Repo Project Item Update + +This workflow demonstrates updating project fields on issues that live in +repositories other than the workflow host repo (cross-repo org-level project). + +Update project "https://github.com/orgs/myorg/projects/42": +- Set the Status field to "In Progress" for issue #123 in myorg/docs +- Set the Status field to "Done" for issue #456 in myorg/frontend + +Use the following output format for each update: + +```json +{ + "type": "update_project", + "project": "https://github.com/orgs/myorg/projects/42", + "content_type": "issue", + "content_number": 123, + "target_repo": "myorg/docs", + "fields": { "Status": "In Progress" } +} +``` diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 096121391a2..33b61993573 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4629,6 +4629,37 @@ "pattern": "^https://github\\.com/(users|orgs)/([^/]+|<[A-Z_]+>)/projects/(\\d+|<[A-Z_]+>)$", "examples": ["https://github.com/orgs/myorg/projects/123", "https://github.com/users/username/projects/456"] }, + "target-repo": { + "description": "Default repository in format 'owner/repo' for cross-repository content resolution. When specified, the agent can use 'target_repo' in agent output to resolve issues or PRs from this repository. Wildcards ('*') are not allowed. Supports GitHub Actions expression syntax (e.g., '${{ vars.TARGET_REPO }}').", + "oneOf": [ + { + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression that resolves to owner/repo at runtime" + } + ] + }, + "allowed-repos": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "pattern": "^(\\*|([a-zA-Z0-9_.-]+|\\*)/([a-zA-Z0-9_.-]+|\\*))$" + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression that resolves to owner/repo at runtime" + } + ] + }, + "description": "List of additional repositories in format 'owner/repo' allowed for cross-repository content resolution via 'target_repo'. The target-repo (or current repo) is always implicitly allowed. Supports wildcard patterns (e.g., 'org/*', '*/repo', '*') and GitHub Actions expression syntax for individual entries." + }, "views": { "type": "array", "description": "Optional array of project views to create. Each view must have a name and layout. Views are created during project setup.", diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 259bcd039a8..54282dbd603 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -663,7 +663,9 @@ var handlerRegistry = map[string]handlerBuilder{ builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project) + AddIfNotEmpty("project", c.Project). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos) if len(c.Views) > 0 { builder.AddDefault("views", c.Views) } diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 55537e67542..6523fa8349c 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -363,7 +363,7 @@ "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": [ @@ -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'." }, + "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. The repository must be permitted by either safe-outputs.update-project.target-repo (including the default host repository) or safe-outputs.update-project.allowed-repos." + }, "draft_title": { "type": "string", "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'." diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index ea266114240..8b745fa4d40 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -327,9 +327,14 @@ func generateSafeOutputsConfig(data *WorkflowData) string { safeOutputsConfig["missing_data"] = missingDataConfig } if data.SafeOutputs.UpdateProjects != nil { - safeOutputsConfig["update_project"] = generateMaxConfig( + safeOutputsConfig["update_project"] = generateTargetConfigWithRepos( + SafeOutputTargetConfig{ + TargetRepoSlug: data.SafeOutputs.UpdateProjects.TargetRepoSlug, + AllowedRepos: data.SafeOutputs.UpdateProjects.AllowedRepos, + }, data.SafeOutputs.UpdateProjects.Max, 10, // default max + nil, ) } if data.SafeOutputs.CreateProjectStatusUpdates != nil { diff --git a/pkg/workflow/update_project.go b/pkg/workflow/update_project.go index 3efc4cf111a..8c22e2bde10 100644 --- a/pkg/workflow/update_project.go +++ b/pkg/workflow/update_project.go @@ -25,7 +25,9 @@ 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 + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Default repository for cross-repo content resolution in "owner/repo" format + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories allowed for target_repo resolution Views []ProjectView `yaml:"views,omitempty"` FieldDefinitions []ProjectFieldDefinition `yaml:"field-definitions,omitempty" json:"field_definitions,omitempty"` } @@ -57,6 +59,16 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro } } + // Parse target-repo for cross-repo content resolution (no wildcard allowed) + targetRepoSlug, isInvalid := parseTargetRepoWithValidation(configMap) + if isInvalid { + return nil + } + updateProjectConfig.TargetRepoSlug = targetRepoSlug + + // Parse allowed-repos for cross-repo content resolution + updateProjectConfig.AllowedRepos = parseAllowedReposFromConfig(configMap) + // Parse views if specified updateProjectConfig.Views = parseProjectViews(configMap, updateProjectLog) @@ -64,8 +76,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, targetRepo=%q, allowedReposCount=%d, viewCount=%d, fieldDefinitionCount=%d", + updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", updateProjectConfig.Project != "", updateProjectConfig.TargetRepoSlug, len(updateProjectConfig.AllowedRepos), len(updateProjectConfig.Views), len(updateProjectConfig.FieldDefinitions)) return updateProjectConfig } updateProjectLog.Print("No update-project configuration found") diff --git a/pkg/workflow/update_project_target_repo_integration_test.go b/pkg/workflow/update_project_target_repo_integration_test.go new file mode 100644 index 00000000000..d86c3594e2c --- /dev/null +++ b/pkg/workflow/update_project_target_repo_integration_test.go @@ -0,0 +1,184 @@ +//go:build integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUpdateProjectTargetRepoInCompiledConfig verifies that when a workflow is compiled with +// update-project.target-repo and update-project.allowed-repos, those values are present in +// both the config.json written to disk and in the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG env var. +func TestUpdateProjectTargetRepoInCompiledConfig(t *testing.T) { + tests := []struct { + name string + workflowContent string + expectedInYAML []string + notExpectedInYAML []string + }{ + { + name: "target-repo and allowed-repos appear in compiled config", + workflowContent: `--- +name: Test Update Project Cross Repo +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }} + project: "https://github.com/orgs/myorg/projects/42" + target-repo: myorg/backend + allowed-repos: + - myorg/docs + - myorg/frontend +--- + +Test workflow for cross-repo project item resolution. +`, + expectedInYAML: []string{ + // config.json written in safe_outputs job + `"target-repo":"myorg/backend"`, + `"allowed_repos":["myorg/docs","myorg/frontend"]`, + // handler config env var (JSON-encoded, quotes escaped) + `\"target-repo\":\"myorg/backend\"`, + `\"allowed_repos\":[\"myorg/docs\",\"myorg/frontend\"]`, + }, + notExpectedInYAML: nil, + }, + { + name: "without target-repo and allowed-repos, neither appears in compiled config", + workflowContent: `--- +name: Test Update Project No Cross Repo +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }} + project: "https://github.com/orgs/myorg/projects/42" +--- + +Test workflow without cross-repo configuration. +`, + expectedInYAML: []string{ + // project URL and update_project key are still present + `update_project`, + `https://github.com/orgs/myorg/projects/42`, + }, + notExpectedInYAML: []string{ + `"target-repo"`, + `"allowed_repos"`, + `\"target-repo\"`, + `\"allowed_repos\"`, + }, + }, + { + name: "target-repo only (no allowed-repos) appears in compiled config", + workflowContent: `--- +name: Test Update Project Target Repo Only +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_WRITE_PROJECT_TOKEN }} + project: "https://github.com/orgs/myorg/projects/42" + target-repo: myorg/backend +--- + +Test workflow with only target-repo (no allowed-repos list). +`, + expectedInYAML: []string{ + `"target-repo":"myorg/backend"`, + `\"target-repo\":\"myorg/backend\"`, + }, + notExpectedInYAML: []string{ + `"allowed_repos"`, + `\"allowed_repos\"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "update-project-target-repo-test") + + mdFile := filepath.Join(tmpDir, "test-workflow.md") + err := os.WriteFile(mdFile, []byte(tt.workflowContent), 0600) + require.NoError(t, err, "Failed to write test markdown file") + + compiler := NewCompiler() + err = compiler.CompileWorkflow(mdFile) + require.NoError(t, err, "Failed to compile workflow") + + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + compiledBytes, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read compiled lock file") + + compiledStr := string(compiledBytes) + + for _, expected := range tt.expectedInYAML { + assert.True(t, + strings.Contains(compiledStr, expected), + "Expected compiled YAML to contain %q\nCompiled output:\n%s", expected, compiledStr, + ) + } + + for _, notExpected := range tt.notExpectedInYAML { + assert.False(t, + strings.Contains(compiledStr, notExpected), + "Expected compiled YAML NOT to contain %q\nCompiled output:\n%s", notExpected, compiledStr, + ) + } + }) + } +} + +// TestUpdateProjectTargetRepoWorkflowFile verifies that the sample workflow file in +// pkg/cli/workflows compiles successfully and produces config with target-repo set. +func TestUpdateProjectTargetRepoWorkflowFile(t *testing.T) { + workflowFile := "../cli/workflows/test-copilot-update-project-cross-repo.md" + + compiler := NewCompiler() + // Set a temporary output dir so we don't write .lock.yml next to the source + tmpDir := testutil.TempDir(t, "update-project-workflow-file-test") + mdDst := filepath.Join(tmpDir, "test-copilot-update-project-cross-repo.md") + + src, err := os.ReadFile(workflowFile) + require.NoError(t, err, "Failed to read sample workflow file %s", workflowFile) + + err = os.WriteFile(mdDst, src, 0600) + require.NoError(t, err, "Failed to copy sample workflow file") + + err = compiler.CompileWorkflow(mdDst) + require.NoError(t, err, "Sample workflow %s should compile without errors", workflowFile) + + lockFile := filepath.Join(tmpDir, "test-copilot-update-project-cross-repo.lock.yml") + compiledBytes, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read compiled lock file") + + compiledStr := string(compiledBytes) + + // The workflow declares target-repo: myorg/backend and allowed-repos: [myorg/docs, myorg/frontend] + assert.Contains(t, compiledStr, `"target-repo":"myorg/backend"`, + "config.json should contain target-repo") + assert.Contains(t, compiledStr, `"allowed_repos":["myorg/docs","myorg/frontend"]`, + "config.json should contain allowed_repos list") + assert.Contains(t, compiledStr, `\"target-repo\":\"myorg/backend\"`, + "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG should contain target-repo") + assert.Contains(t, compiledStr, `update_project`, + "Compiled workflow should reference update_project handler") +}