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/pkg/workflow/update_project_test.go b/pkg/workflow/update_project_test.go new file mode 100644 index 0000000000..d0fc6eb5ed --- /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") +}