diff --git a/.github/workflows/agentic-campaign-generator.lock.yml b/.github/workflows/agentic-campaign-generator.lock.yml index dc128624f20..0b0c286f842 100644 --- a/.github/workflows/agentic-campaign-generator.lock.yml +++ b/.github/workflows/agentic-campaign-generator.lock.yml @@ -1510,7 +1510,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project\":{\"github-token\":\"${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}\",\"max\":1,\"target_owner\":\"${{ github.repository_owner }}\",\"views\":[{\"name\":\"Progress Board\",\"layout\":\"board\",\"filter\":\"is:issue is:pr\"},{\"name\":\"Task Tracker\",\"layout\":\"table\",\"filter\":\"is:issue is:pr\"},{\"name\":\"Campaign Roadmap\",\"layout\":\"roadmap\",\"filter\":\"is:issue is:pr\"}]},\"update_project\":{\"field_definitions\":[{\"name\":\"Campaign Id\",\"data_type\":\"TEXT\"},{\"name\":\"Worker Workflow\",\"data_type\":\"TEXT\"},{\"name\":\"Priority\",\"data_type\":\"SINGLE_SELECT\",\"options\":[\"High\",\"Medium\",\"Low\"]},{\"name\":\"Size\",\"data_type\":\"SINGLE_SELECT\",\"options\":[\"Small\",\"Medium\",\"Large\"]},{\"name\":\"Start Date\",\"data_type\":\"DATE\"},{\"name\":\"End Date\",\"data_type\":\"DATE\"}],\"github-token\":\"${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}\",\"max\":10}}" + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project\":{\"field_definitions\":[{\"name\":\"Campaign Id\",\"data_type\":\"TEXT\"},{\"name\":\"Worker Workflow\",\"data_type\":\"TEXT\"},{\"name\":\"Priority\",\"data_type\":\"SINGLE_SELECT\",\"options\":[\"High\",\"Medium\",\"Low\"]},{\"name\":\"Size\",\"data_type\":\"SINGLE_SELECT\",\"options\":[\"Small\",\"Medium\",\"Large\"]},{\"name\":\"Start Date\",\"data_type\":\"DATE\"},{\"name\":\"End Date\",\"data_type\":\"DATE\"}],\"github-token\":\"${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}\",\"max\":1,\"target_owner\":\"${{ github.repository_owner }}\",\"views\":[{\"name\":\"Progress Board\",\"layout\":\"board\",\"filter\":\"is:issue is:pr\"},{\"name\":\"Task Tracker\",\"layout\":\"table\",\"filter\":\"is:issue is:pr\"},{\"name\":\"Campaign Roadmap\",\"layout\":\"roadmap\",\"filter\":\"is:issue is:pr\"}]},\"update_project\":{\"github-token\":\"${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}\",\"max\":10}}" GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} diff --git a/.github/workflows/agentic-campaign-generator.md b/.github/workflows/agentic-campaign-generator.md index 64801ac5fb8..94f9352ccea 100644 --- a/.github/workflows/agentic-campaign-generator.md +++ b/.github/workflows/agentic-campaign-generator.md @@ -33,9 +33,6 @@ safe-outputs: - name: "Campaign Roadmap" layout: "roadmap" filter: "is:issue is:pr" - update-project: - max: 10 - github-token: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" field-definitions: - name: "Campaign Id" data-type: "TEXT" @@ -57,6 +54,9 @@ safe-outputs: data-type: "DATE" - name: "End Date" data-type: "DATE" + update-project: + max: 10 + github-token: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" messages: run-started: "### :rocket: Campaign setup started diff --git a/pkg/campaign/generator.go b/pkg/campaign/generator.go index e8d3ff12939..f2757fcac2b 100644 --- a/pkg/campaign/generator.go +++ b/pkg/campaign/generator.go @@ -86,9 +86,6 @@ func buildGeneratorSafeOutputs() *workflow.SafeOutputsConfig { Filter: "is:issue is:pr", }, }, - }, - UpdateProjects: &workflow.UpdateProjectConfig{ - GitHubToken: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", FieldDefinitions: []workflow.ProjectFieldDefinition{ { Name: "Campaign Id", @@ -118,6 +115,9 @@ func buildGeneratorSafeOutputs() *workflow.SafeOutputsConfig { }, }, }, + UpdateProjects: &workflow.UpdateProjectConfig{ + GitHubToken: "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}", + }, Messages: &workflow.SafeOutputMessagesConfig{ AppendOnlyComments: true, RunStarted: "### :rocket: Campaign setup started\n\n" + diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 72ffd0554c2..15ae0ffc954 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -919,6 +919,19 @@ func renderCampaignGeneratorMarkdown(data *workflow.WorkflowData) string { fmt.Fprintf(&b, " filter: \"%s\"\n", view.Filter) } } + if len(data.SafeOutputs.CreateProjects.FieldDefinitions) > 0 { + b.WriteString(" field-definitions:\n") + for _, field := range data.SafeOutputs.CreateProjects.FieldDefinitions { + fmt.Fprintf(&b, " - name: \"%s\"\n", field.Name) + fmt.Fprintf(&b, " data-type: \"%s\"\n", field.DataType) + if len(field.Options) > 0 { + b.WriteString(" options:\n") + for _, opt := range field.Options { + fmt.Fprintf(&b, " - \"%s\"\n", opt) + } + } + } + } } if data.SafeOutputs.UpdateProjects != nil { diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 23c1ea32126..0fc80f6f58d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4016,6 +4016,33 @@ }, "additionalProperties": false } + }, + "field-definitions": { + "type": "array", + "description": "Optional array of project custom fields to create automatically after project creation. Useful for campaign projects that require a fixed set of fields.", + "items": { + "type": "object", + "required": ["name", "data-type"], + "properties": { + "name": { + "type": "string", + "description": "The field name to create (e.g., 'Campaign Id', 'Priority')" + }, + "data-type": { + "type": "string", + "enum": ["DATE", "TEXT", "NUMBER", "SINGLE_SELECT", "ITERATION"], + "description": "The GitHub Projects v2 custom field type" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Options for SINGLE_SELECT fields. GitHub does not support adding options later." + } + }, + "additionalProperties": false + } } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index c9db00636d9..e492c21f775 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -572,6 +572,9 @@ func (c *Compiler) addProjectHandlerManagerConfigEnvVar(steps *[]string, data *W if len(cfg.Views) > 0 { handlerConfig["views"] = cfg.Views } + if len(cfg.FieldDefinitions) > 0 { + handlerConfig["field_definitions"] = cfg.FieldDefinitions + } config["create_project"] = handlerConfig } diff --git a/pkg/workflow/create_project.go b/pkg/workflow/create_project.go index 2c014e500e5..a5e8f4fdf68 100644 --- a/pkg/workflow/create_project.go +++ b/pkg/workflow/create_project.go @@ -7,10 +7,11 @@ var createProjectLog = logger.New("workflow:create_project") // CreateProjectsConfig holds configuration for creating GitHub Projects V2 type CreateProjectsConfig struct { BaseSafeOutputConfig `yaml:",inline"` - GitHubToken string `yaml:"github-token,omitempty"` - TargetOwner string `yaml:"target-owner,omitempty"` // Default target owner (org/user) for the new project - TitlePrefix string `yaml:"title-prefix,omitempty"` // Default prefix for auto-generated project titles - Views []ProjectView `yaml:"views,omitempty"` // Project views to create automatically after project creation + GitHubToken string `yaml:"github-token,omitempty"` + TargetOwner string `yaml:"target-owner,omitempty"` // Default target owner (org/user) for the new project + TitlePrefix string `yaml:"title-prefix,omitempty"` // Default prefix for auto-generated project titles + Views []ProjectView `yaml:"views,omitempty"` // Project views to create automatically after project creation + FieldDefinitions []ProjectFieldDefinition `yaml:"field-definitions,omitempty"` // Project field definitions to create automatically after project creation } // parseCreateProjectsConfig handles create-project configuration @@ -105,10 +106,58 @@ func (c *Compiler) parseCreateProjectsConfig(outputMap map[string]any) *CreatePr } } } + + // Parse field-definitions if specified + fieldsData, hasFields := configMap["field-definitions"] + if !hasFields { + // Allow underscore variant as well + fieldsData, hasFields = configMap["field_definitions"] + } + if hasFields { + if fieldsList, ok := fieldsData.([]any); ok { + for i, fieldItem := range fieldsList { + fieldMap, ok := fieldItem.(map[string]any) + if !ok { + continue + } + + field := ProjectFieldDefinition{} + + if name, exists := fieldMap["name"]; exists { + if nameStr, ok := name.(string); ok { + field.Name = nameStr + } + } + + dataType, hasDataType := fieldMap["data-type"] + if !hasDataType { + dataType = fieldMap["data_type"] + } + if dataTypeStr, ok := dataType.(string); ok { + field.DataType = dataTypeStr + } + + if options, exists := fieldMap["options"]; exists { + if optionsList, ok := options.([]any); ok { + for _, opt := range optionsList { + if optStr, ok := opt.(string); ok { + field.Options = append(field.Options, optStr) + } + } + } + } + + if field.Name != "" && field.DataType != "" { + createProjectsConfig.FieldDefinitions = append(createProjectsConfig.FieldDefinitions, field) + createProjectLog.Printf("Parsed field definition %d: %s (%s)", i+1, field.Name, field.DataType) + } + } + } + } } - createProjectLog.Printf("Parsed create-project config: max=%d, hasCustomToken=%v, hasTargetOwner=%v, hasTitlePrefix=%v, viewCount=%d", - createProjectsConfig.Max, createProjectsConfig.GitHubToken != "", createProjectsConfig.TargetOwner != "", createProjectsConfig.TitlePrefix != "", len(createProjectsConfig.Views)) + createProjectLog.Printf("Parsed create-project config: max=%d, hasCustomToken=%v, hasTargetOwner=%v, hasTitlePrefix=%v, viewCount=%d, fieldDefinitionCount=%d", + createProjectsConfig.Max, createProjectsConfig.GitHubToken != "", createProjectsConfig.TargetOwner != "", createProjectsConfig.TitlePrefix != "", len(createProjectsConfig.Views), len(createProjectsConfig.FieldDefinitions)) return createProjectsConfig } createProjectLog.Print("No create-project configuration found") diff --git a/pkg/workflow/create_project_test.go b/pkg/workflow/create_project_test.go index 6e3f6b2bf7f..c53f630f154 100644 --- a/pkg/workflow/create_project_test.go +++ b/pkg/workflow/create_project_test.go @@ -198,6 +198,14 @@ func TestParseCreateProjectsConfig(t *testing.T) { assert.Equal(t, expectedView.VisibleFields, config.Views[i].VisibleFields, "View visible fields should match") assert.Equal(t, expectedView.Description, config.Views[i].Description, "View description should match") } + + // Check field definitions + assert.Len(t, config.FieldDefinitions, len(tt.expectedConfig.FieldDefinitions), "Field definitions count should match") + for i, expectedField := range tt.expectedConfig.FieldDefinitions { + assert.Equal(t, expectedField.Name, config.FieldDefinitions[i].Name, "Field name should match") + assert.Equal(t, expectedField.DataType, config.FieldDefinitions[i].DataType, "Field data type should match") + assert.Equal(t, expectedField.Options, config.FieldDefinitions[i].Options, "Field options should match") + } } }) } @@ -253,3 +261,115 @@ func TestCreateProjectsConfig_ViewsParsing(t *testing.T) { assert.Equal(t, "roadmap", config.Views[1].Layout) assert.Empty(t, config.Views[1].Filter) // No filter specified } + +func TestCreateProjectsConfig_FieldDefinitionsParsing(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + outputMap := map[string]any{ + "create-project": map[string]any{ + "max": 1, + "field-definitions": []any{ + map[string]any{ + "name": "Campaign Id", + "data-type": "TEXT", + }, + map[string]any{ + "name": "Priority", + "data-type": "SINGLE_SELECT", + "options": []any{"High", "Medium", "Low"}, + }, + map[string]any{ + "name": "Start Date", + "data-type": "DATE", + }, + }, + }, + } + + config := compiler.parseCreateProjectsConfig(outputMap) + require.NotNil(t, config, "Config should not be nil") + require.Len(t, config.FieldDefinitions, 3, "Should parse 3 field definitions") + + // Check first field + assert.Equal(t, "Campaign Id", config.FieldDefinitions[0].Name) + assert.Equal(t, "TEXT", config.FieldDefinitions[0].DataType) + assert.Empty(t, config.FieldDefinitions[0].Options) + + // Check second field + assert.Equal(t, "Priority", config.FieldDefinitions[1].Name) + assert.Equal(t, "SINGLE_SELECT", config.FieldDefinitions[1].DataType) + assert.Equal(t, []string{"High", "Medium", "Low"}, config.FieldDefinitions[1].Options) + + // Check third field + assert.Equal(t, "Start Date", config.FieldDefinitions[2].Name) + assert.Equal(t, "DATE", config.FieldDefinitions[2].DataType) + assert.Empty(t, config.FieldDefinitions[2].Options) +} + +func TestCreateProjectsConfig_FieldDefinitionsWithUnderscores(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + // Test underscore variant of field-definitions and data-type + outputMap := map[string]any{ + "create-project": map[string]any{ + "max": 1, + "field_definitions": []any{ + map[string]any{ + "name": "Worker Workflow", + "data_type": "TEXT", + }, + }, + }, + } + + config := compiler.parseCreateProjectsConfig(outputMap) + require.NotNil(t, config, "Config should not be nil") + require.Len(t, config.FieldDefinitions, 1, "Should parse 1 field definition") + + assert.Equal(t, "Worker Workflow", config.FieldDefinitions[0].Name) + assert.Equal(t, "TEXT", config.FieldDefinitions[0].DataType) +} + +func TestCreateProjectsConfig_ViewsAndFieldDefinitions(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + outputMap := map[string]any{ + "create-project": map[string]any{ + "max": 1, + "target-owner": "myorg", + "views": []any{ + map[string]any{ + "name": "Campaign Board", + "layout": "board", + }, + }, + "field-definitions": []any{ + map[string]any{ + "name": "Campaign Id", + "data-type": "TEXT", + }, + map[string]any{ + "name": "Size", + "data-type": "SINGLE_SELECT", + "options": []any{"Small", "Medium", "Large"}, + }, + }, + }, + } + + config := compiler.parseCreateProjectsConfig(outputMap) + require.NotNil(t, config, "Config should not be nil") + + // Check views + require.Len(t, config.Views, 1, "Should have 1 view") + assert.Equal(t, "Campaign Board", config.Views[0].Name) + assert.Equal(t, "board", config.Views[0].Layout) + + // Check field definitions + require.Len(t, config.FieldDefinitions, 2, "Should have 2 field definitions") + assert.Equal(t, "Campaign Id", config.FieldDefinitions[0].Name) + assert.Equal(t, "TEXT", config.FieldDefinitions[0].DataType) + assert.Equal(t, "Size", config.FieldDefinitions[1].Name) + assert.Equal(t, "SINGLE_SELECT", config.FieldDefinitions[1].DataType) + assert.Equal(t, []string{"Small", "Medium", "Large"}, config.FieldDefinitions[1].Options) +}