diff --git a/.github/workflows/discussion-task-mining.campaign.lock.yml b/.github/workflows/discussion-task-mining.campaign.lock.yml index c2dd1ed264..535f408934 100644 --- a/.github/workflows/discussion-task-mining.campaign.lock.yml +++ b/.github/workflows/discussion-task-mining.campaign.lock.yml @@ -355,7 +355,7 @@ jobs: "name": "noop" }, { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Unified GitHub Projects v2 operations. Default behavior updates project items (add issue/PR/draft_issue and/or update custom fields). Also supports creating project views (table/board/roadmap) when operation=create_view.", "inputSchema": { "additionalProperties": false, "properties": { @@ -392,15 +392,61 @@ jobs: "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", "type": "object" }, + "operation": { + "description": "Optional operation selector. Default: update_item. Use create_view to create a project view (table/board/roadmap).", + "enum": [ + "update_item", + "create_view" + ], + "type": "string" + }, "project": { "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View configuration. Required when operation is create_view.", + "properties": { + "description": { + "description": "Optional human description for the view. Not supported by the GitHub Views API and may be ignored.", + "type": "string" + }, + "filter": { + "description": "Optional filter query for the view (e.g., 'is:issue is:open').", + "type": "string" + }, + "layout": { + "description": "The layout of the view.", + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "description": "The name of the view (e.g., 'Sprint Board').", + "type": "string" + }, + "visible_fields": { + "description": "Optional field IDs that should be visible in the view (table/board only). Not applicable to roadmap.", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" } }, "required": [ - "project", - "content_type" + "project" ], "type": "object" }, diff --git a/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml b/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml index d3df00795d..4cf070b437 100644 --- a/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml +++ b/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml @@ -355,7 +355,7 @@ jobs: "name": "noop" }, { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Unified GitHub Projects v2 operations. Default behavior updates project items (add issue/PR/draft_issue and/or update custom fields). Also supports creating project views (table/board/roadmap) when operation=create_view.", "inputSchema": { "additionalProperties": false, "properties": { @@ -392,15 +392,61 @@ jobs: "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", "type": "object" }, + "operation": { + "description": "Optional operation selector. Default: update_item. Use create_view to create a project view (table/board/roadmap).", + "enum": [ + "update_item", + "create_view" + ], + "type": "string" + }, "project": { "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View configuration. Required when operation is create_view.", + "properties": { + "description": { + "description": "Optional human description for the view. Not supported by the GitHub Views API and may be ignored.", + "type": "string" + }, + "filter": { + "description": "Optional filter query for the view (e.g., 'is:issue is:open').", + "type": "string" + }, + "layout": { + "description": "The layout of the view.", + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "description": "The name of the view (e.g., 'Sprint Board').", + "type": "string" + }, + "visible_fields": { + "description": "Optional field IDs that should be visible in the view (table/board only). Not applicable to roadmap.", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" } }, "required": [ - "project", - "content_type" + "project" ], "type": "object" }, diff --git a/.github/workflows/file-size-reduction-project71.campaign.lock.yml b/.github/workflows/file-size-reduction-project71.campaign.lock.yml index 5511cc4cba..c1751ab7b5 100644 --- a/.github/workflows/file-size-reduction-project71.campaign.lock.yml +++ b/.github/workflows/file-size-reduction-project71.campaign.lock.yml @@ -355,7 +355,7 @@ jobs: "name": "noop" }, { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Unified GitHub Projects v2 operations. Default behavior updates project items (add issue/PR/draft_issue and/or update custom fields). Also supports creating project views (table/board/roadmap) when operation=create_view.", "inputSchema": { "additionalProperties": false, "properties": { @@ -392,15 +392,61 @@ jobs: "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", "type": "object" }, + "operation": { + "description": "Optional operation selector. Default: update_item. Use create_view to create a project view (table/board/roadmap).", + "enum": [ + "update_item", + "create_view" + ], + "type": "string" + }, "project": { "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View configuration. Required when operation is create_view.", + "properties": { + "description": { + "description": "Optional human description for the view. Not supported by the GitHub Views API and may be ignored.", + "type": "string" + }, + "filter": { + "description": "Optional filter query for the view (e.g., 'is:issue is:open').", + "type": "string" + }, + "layout": { + "description": "The layout of the view.", + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "description": "The name of the view (e.g., 'Sprint Board').", + "type": "string" + }, + "visible_fields": { + "description": "Optional field IDs that should be visible in the view (table/board only). Not applicable to roadmap.", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" } }, "required": [ - "project", - "content_type" + "project" ], "type": "object" }, diff --git a/.github/workflows/playground-org-project-update-issue.lock.yml b/.github/workflows/playground-org-project-update-issue.lock.yml index fa4f57287e..7cd29f9da9 100644 --- a/.github/workflows/playground-org-project-update-issue.lock.yml +++ b/.github/workflows/playground-org-project-update-issue.lock.yml @@ -217,7 +217,7 @@ jobs: "name": "noop" }, { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Unified GitHub Projects v2 operations. Default behavior updates project items (add issue/PR/draft_issue and/or update custom fields). Also supports creating project views (table/board/roadmap) when operation=create_view.", "inputSchema": { "additionalProperties": false, "properties": { @@ -254,15 +254,61 @@ jobs: "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", "type": "object" }, + "operation": { + "description": "Optional operation selector. Default: update_item. Use create_view to create a project view (table/board/roadmap).", + "enum": [ + "update_item", + "create_view" + ], + "type": "string" + }, "project": { "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View configuration. Required when operation is create_view.", + "properties": { + "description": { + "description": "Optional human description for the view. Not supported by the GitHub Views API and may be ignored.", + "type": "string" + }, + "filter": { + "description": "Optional filter query for the view (e.g., 'is:issue is:open').", + "type": "string" + }, + "layout": { + "description": "The layout of the view.", + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "description": "The name of the view (e.g., 'Sprint Board').", + "type": "string" + }, + "visible_fields": { + "description": "Optional field IDs that should be visible in the view (table/board only). Not applicable to roadmap.", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" } }, "required": [ - "project", - "content_type" + "project" ], "type": "object" }, diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 5e63153567..85890dd8b9 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -324,6 +324,17 @@ async function updateProject(output) { const projectNumberFromUrl = projectInfo.projectNumber; const campaignId = output.campaign_id; + const wantsCreateView = + output?.operation === "create_view" || + (output?.view && + output?.content_type === undefined && + output?.content_number === undefined && + output?.issue === undefined && + output?.pull_request === undefined && + output?.fields === undefined && + output?.draft_title === undefined && + output?.draft_body === undefined); + try { core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`); core.info("[1/4] Fetching repository information..."); @@ -386,6 +397,76 @@ async function updateProject(output) { throw error; } + if (wantsCreateView) { + const view = output?.view; + if (!view || typeof view !== "object") { + throw new Error('Invalid view. When operation is "create_view", you must provide view: { name, layout, ... }.'); + } + + const name = typeof view.name === "string" ? view.name.trim() : ""; + if (!name) { + throw new Error('Invalid view.name. When operation is "create_view", view.name is required and must be a non-empty string.'); + } + + const layout = typeof view.layout === "string" ? view.layout.trim() : ""; + if (!layout || !["table", "board", "roadmap"].includes(layout)) { + throw new Error("Invalid view.layout. Must be one of: table, board, roadmap."); + } + + const filter = typeof view.filter === "string" ? view.filter : undefined; + let visibleFields = Array.isArray(view.visible_fields) ? view.visible_fields : undefined; + + if (visibleFields) { + const invalid = visibleFields.filter(v => typeof v !== "number" || !Number.isFinite(v)); + if (invalid.length > 0) { + throw new Error(`Invalid view.visible_fields. Must be an array of numbers (field IDs). Invalid values: ${invalid.map(v => JSON.stringify(v)).join(", ")}`); + } + } + + if (layout === "roadmap" && visibleFields && visibleFields.length > 0) { + core.warning('view.visible_fields is not applicable to layout "roadmap"; ignoring.'); + visibleFields = undefined; + } + + if (typeof view.description === "string" && view.description.trim()) { + core.warning("view.description is not supported by the GitHub Projects Views API; ignoring."); + } + + if (typeof github.request !== "function") { + throw new Error("GitHub client does not support github.request(); cannot call Projects Views REST API."); + } + + const route = projectInfo.scope === "orgs" ? "POST /orgs/{org}/projectsV2/{project_number}/views" : "POST /users/{user_id}/projectsV2/{project_number}/views"; + + const params = + projectInfo.scope === "orgs" + ? { + org: projectInfo.ownerLogin, + project_number: parseInt(resolvedProjectNumber, 10), + name, + layout, + ...(filter ? { filter } : {}), + ...(visibleFields ? { visible_fields: visibleFields } : {}), + } + : { + user_id: projectInfo.ownerLogin, + project_number: parseInt(resolvedProjectNumber, 10), + name, + layout, + ...(filter ? { filter } : {}), + ...(visibleFields ? { visible_fields: visibleFields } : {}), + }; + + core.info(`[3/4] Creating project view: ${name} (${layout})...`); + const response = await github.request(route, params); + const created = response?.data; + + if (created?.id) core.setOutput("view-id", created.id); + if (created?.url) core.setOutput("view-url", created.url); + core.info("✓ View created"); + return; + } + core.info("[3/4] Processing content (issue/PR/draft) if specified..."); const hasContentNumber = output.content_number !== undefined && output.content_number !== null; const hasIssue = output.issue !== undefined && output.issue !== null; diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 12d6c9245f..791d89000d 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -27,6 +27,7 @@ const mockGithub = { }, }, graphql: vi.fn(), + request: vi.fn(), }; const mockContext = { @@ -80,6 +81,7 @@ function clearCoreMocks() { beforeEach(() => { mockGithub.graphql.mockReset(); + mockGithub.request.mockReset(); mockGithub.rest.issues.addLabels.mockClear(); clearCoreMocks(); vi.useRealTimers(); @@ -211,6 +213,103 @@ describe("generateCampaignId", () => { }); describe("updateProject", () => { + it("creates a view for an org-owned project", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + operation: "create_view", + view: { + name: "Sprint Board", + layout: "board", + filter: "is:issue is:open label:sprint", + visible_fields: [123, 456, 789], + description: "Optional description (ignored)", + }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-view")]); + mockGithub.request.mockResolvedValueOnce({ data: { id: 101, name: "Sprint Board" } }); + + await updateProject(output); + + expect(mockGithub.request).toHaveBeenCalledWith( + "POST /orgs/{org}/projectsV2/{project_number}/views", + expect.objectContaining({ + org: "testowner", + project_number: 60, + name: "Sprint Board", + layout: "board", + filter: "is:issue is:open label:sprint", + visible_fields: [123, 456, 789], + }) + ); + + expect(getOutput("view-id")).toBe(101); + }); + + it("creates a view for a user-owned project", async () => { + const projectUrl = "https://github.com/users/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + operation: "create_view", + view: { + name: "All Issues", + layout: "table", + filter: "is:issue", + }, + }; + + queueResponses([repoResponse(), viewerResponse(), userProjectV2Response(projectUrl, 60, "project-user-view")]); + mockGithub.request.mockResolvedValueOnce({ data: { id: 202, name: "All Issues" } }); + + await updateProject(output); + + expect(mockGithub.request).toHaveBeenCalledWith( + "POST /users/{user_id}/projectsV2/{project_number}/views", + expect.objectContaining({ + user_id: "testowner", + project_number: 60, + name: "All Issues", + layout: "table", + filter: "is:issue", + }) + ); + + expect(getOutput("view-id")).toBe(202); + }); + + it("ignores visible_fields for roadmap views", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + operation: "create_view", + view: { + name: "Product Roadmap", + layout: "roadmap", + visible_fields: [123], + }, + }; + + queueResponses([repoResponse(), viewerResponse(), orgProjectV2Response(projectUrl, 60, "project-roadmap")]); + mockGithub.request.mockResolvedValueOnce({ data: { id: 303, name: "Product Roadmap" } }); + + await updateProject(output); + + const callArgs = mockGithub.request.mock.calls[0][1]; + expect(callArgs).toEqual( + expect.objectContaining({ + org: "testowner", + project_number: 60, + name: "Product Roadmap", + layout: "roadmap", + }) + ); + expect(callArgs.visible_fields).toBeUndefined(); + }); + it("rejects project URL when project not found", async () => { const projectUrl = "https://github.com/orgs/testowner/projects/99"; diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 5ec4534602..768ad2ce3c 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -786,19 +786,65 @@ }, { "name": "update_project", - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Unified GitHub Projects v2 operations. Default behavior updates project items (add issue/PR/draft_issue and/or update custom fields). Also supports creating project views (table/board/roadmap) when operation=create_view.", "inputSchema": { "type": "object", "required": [ - "project", - "content_type" + "project" ], "properties": { + "operation": { + "type": "string", + "enum": [ + "update_item", + "create_view" + ], + "description": "Optional operation selector. Default: update_item. Use create_view to create a project view (table/board/roadmap)." + }, "project": { "type": "string", "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted." }, + "view": { + "type": "object", + "description": "View configuration. Required when operation is create_view.", + "required": [ + "name", + "layout" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the view (e.g., 'Sprint Board')." + }, + "layout": { + "type": "string", + "enum": [ + "table", + "board", + "roadmap" + ], + "description": "The layout of the view." + }, + "filter": { + "type": "string", + "description": "Optional filter query for the view (e.g., 'is:issue is:open')." + }, + "visible_fields": { + "type": "array", + "items": { + "type": "number" + }, + "description": "Optional field IDs that should be visible in the view (table/board only). Not applicable to roadmap." + }, + "description": { + "type": "string", + "description": "Optional human description for the view. Not supported by the GitHub Views API and may be ignored." + } + }, + "additionalProperties": false + }, "content_type": { "type": "string", "enum": [ diff --git a/pkg/workflow/update_project_test.go b/pkg/workflow/update_project_test.go new file mode 100644 index 0000000000..5952488739 --- /dev/null +++ b/pkg/workflow/update_project_test.go @@ -0,0 +1,300 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseUpdateProjectConfig(t *testing.T) { + tests := []struct { + name string + outputMap map[string]any + expectedConfig *UpdateProjectConfig + expectedNil bool + }{ + { + name: "basic config with max", + outputMap: map[string]any{ + "update-project": map[string]any{ + "max": 5, + }, + }, + expectedConfig: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 5, + }, + GitHubToken: "", + }, + }, + { + name: "config with custom github-token", + outputMap: map[string]any{ + "update-project": map[string]any{ + "max": 3, + "github-token": "${{ secrets.PROJECTS_PAT }}", + }, + }, + expectedConfig: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 3, + }, + GitHubToken: "${{ secrets.PROJECTS_PAT }}", + }, + }, + { + name: "config with default max when not specified", + outputMap: map[string]any{ + "update-project": map[string]any{}, + }, + expectedConfig: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + GitHubToken: "", + }, + }, + { + name: "config with only github-token", + outputMap: map[string]any{ + "update-project": map[string]any{ + "github-token": "${{ secrets.MY_TOKEN }}", + }, + }, + expectedConfig: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + GitHubToken: "${{ secrets.MY_TOKEN }}", + }, + }, + { + name: "no update-project config", + outputMap: map[string]any{ + "create-issue": map[string]any{}, + }, + expectedNil: true, + }, + { + name: "empty outputMap", + outputMap: map[string]any{}, + expectedNil: true, + }, + { + name: "update-project is nil", + outputMap: map[string]any{ + "update-project": nil, + }, + expectedConfig: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + GitHubToken: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(false, "", "test") + config := compiler.parseUpdateProjectConfig(tt.outputMap) + + if tt.expectedNil { + assert.Nil(t, config, "Expected nil config") + } else { + require.NotNil(t, config, "Expected non-nil config") + assert.Equal(t, tt.expectedConfig.Max, config.Max, "Max should match") + assert.Equal(t, tt.expectedConfig.GitHubToken, config.GitHubToken, "GitHubToken should match") + } + }) + } +} + +func TestUpdateProjectConfig_DefaultMax(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + outputMap := map[string]any{ + "update-project": map[string]any{ + "github-token": "${{ secrets.TOKEN }}", + }, + } + + config := compiler.parseUpdateProjectConfig(outputMap) + require.NotNil(t, config) + + // Default max should be 10 when not specified + assert.Equal(t, 10, config.Max, "Default max should be 10") +} + +func TestUpdateProjectConfig_TokenPrecedence(t *testing.T) { + tests := []struct { + name string + configToken string + expectedToken string + }{ + { + name: "custom token specified", + configToken: "${{ secrets.CUSTOM_PAT }}", + expectedToken: "${{ secrets.CUSTOM_PAT }}", + }, + { + name: "empty token", + configToken: "", + expectedToken: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + outputMap := map[string]any{ + "update-project": map[string]any{ + "github-token": tt.configToken, + }, + } + + config := compiler.parseUpdateProjectConfig(outputMap) + require.NotNil(t, config) + assert.Equal(t, tt.expectedToken, config.GitHubToken) + }) + } +} + +func TestBuildUpdateProjectJob(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + expectError bool + errorMsg string + }{ + { + name: "valid config", + workflowData: &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 5, + }, + GitHubToken: "${{ secrets.PROJECTS_PAT }}", + }, + }, + }, + expectError: false, + }, + { + name: "missing safe outputs config", + workflowData: &WorkflowData{ + Name: "test-workflow", + SafeOutputs: nil, + }, + expectError: true, + errorMsg: "safe-outputs.update-project configuration is required", + }, + { + name: "missing update project config", + workflowData: &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: nil, + }, + }, + expectError: true, + errorMsg: "safe-outputs.update-project configuration is required", + }, + { + name: "with default max", + workflowData: &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + job, err := compiler.buildUpdateProjectJob(tt.workflowData, "main") + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + assert.Nil(t, job) + } else { + require.NoError(t, err) + require.NotNil(t, job) + + // Verify job has basic structure + assert.NotEmpty(t, job.Steps, "Job should have steps") + } + }) + } +} + +func TestUpdateProjectJob_EnvironmentVariables(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 5, + }, + GitHubToken: "${{ secrets.PROJECTS_PAT }}", + }, + }, + } + + job, err := compiler.buildUpdateProjectJob(workflowData, "main") + require.NoError(t, err) + require.NotNil(t, job) + + // Job should contain steps + assert.NotEmpty(t, job.Steps, "Job should have steps") + + // Check that GH_AW_PROJECT_GITHUB_TOKEN is set in the environment + hasProjectToken := false + for _, step := range job.Steps { + if strings.Contains(step, "GH_AW_PROJECT_GITHUB_TOKEN") { + hasProjectToken = true + break + } + } + assert.True(t, hasProjectToken, "Job should set GH_AW_PROJECT_GITHUB_TOKEN environment variable") +} + +func TestUpdateProjectJob_Permissions(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: 10, + }, + }, + }, + } + + job, err := compiler.buildUpdateProjectJob(workflowData, "main") + require.NoError(t, err) + require.NotNil(t, job) + + // Verify permissions are set correctly + // update_project requires contents: read permission + require.NotEmpty(t, job.Permissions, "Job should have permissions") + assert.Contains(t, job.Permissions, "contents: read", "Should have contents: read permission") +}