diff --git a/docs/src/content/docs/examples/project-tracking.md b/docs/src/content/docs/examples/project-tracking.md new file mode 100644 index 0000000000..0f09f8cef0 --- /dev/null +++ b/docs/src/content/docs/examples/project-tracking.md @@ -0,0 +1,340 @@ +--- +title: Project Tracking +description: Automatically track issues and pull requests in GitHub Projects boards +sidebar: + badge: { text: 'Project', variant: 'tip' } +--- + +The `project` frontmatter field enables automatic tracking of workflow-created items in GitHub Projects boards. When configured, workflows automatically get project management capabilities including item addition, field updates, and status reporting. + +## Quick Start + +Add the `project` field to your workflow frontmatter to enable project tracking: + +```yaml +--- +on: + issues: + types: [opened] +project: https://github.com/orgs/github/projects/123 +safe-outputs: + create-issue: + max: 3 +--- +``` + +This automatically enables: +- **update-project** - Add items to projects, update fields (status, priority, etc.) +- **create-project-status-update** - Post status updates to project boards + +## Configuration Options + +### Simple Format (String) + +Use a GitHub Project URL directly: + +```yaml +project: https://github.com/orgs/github/projects/123 +``` + +### Full Configuration (Object) + +Customize behavior with additional options: + +```yaml +project: + url: https://github.com/orgs/github/projects/123 + scope: + - owner/repo1 + - owner/repo2 + - org:myorg + max-updates: 50 + max-status-updates: 2 + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + do-not-downgrade-done-items: true +``` + +### Configuration Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | string | (required) | GitHub Project URL (user or organization) | +| `scope` | array | current repo | Repositories/organizations this workflow can operate on (e.g., `owner/repo`, `org:name`) | +| `max-updates` | integer | 100 | Maximum project updates per workflow run | +| `max-status-updates` | integer | 1 | Maximum status updates per workflow run | +| `github-token` | string | `GITHUB_TOKEN` | Custom token with Projects permissions | +| `do-not-downgrade-done-items` | boolean | false | Prevent moving completed items backward | + +## Prerequisites + +### 1. Create a GitHub Project + +Create a Projects V2 board in the GitHub UI before configuring your workflow. You'll need the Project URL from the browser address bar. + +### 2. Set Up Authentication + +#### For User-Owned Projects + +Use a **classic PAT** with scopes: +- `project` (required) +- `repo` (if accessing private repositories) + +#### For Organization-Owned Projects + +Use a **fine-grained PAT** with: +- Repository access: Select specific repos +- Repository permissions: + - Contents: Read + - Issues: Read (if workflow triggers on issues) + - Pull requests: Read (if workflow triggers on pull requests) +- Organization permissions: + - Projects: Read & Write + +### 3. Store the Token + +```bash +gh aw secrets set GH_AW_PROJECT_GITHUB_TOKEN --value "YOUR_PROJECT_TOKEN" +``` + +See the [GitHub Projects V2 token reference](/gh-aw/reference/tokens/#gh_aw_project_github_token-github-projects-v2) for complete details. + +## Example: Issue Triage + +Automatically add new issues to a project board with intelligent categorization: + +```aw wrap +--- +on: + issues: + types: [opened] +permissions: + contents: read + actions: read + issues: read +tools: + github: + toolsets: [default, projects] + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +project: + url: https://github.com/orgs/myorg/projects/1 + max-updates: 10 + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +safe-outputs: + add-comment: + max: 1 +--- + +# Smart Issue Triage + +When a new issue is created, analyze it and add to the project board. + +## Task + +Examine the issue title and description to determine its type: +- **Bug reports** → Add to project, set status="Needs Triage", priority="High" +- **Feature requests** → Add to project, set status="Backlog", priority="Medium" +- **Documentation** → Add to project, set status="Todo", priority="Low" + +After adding to the project board, comment on the issue confirming where it was added. +``` + +## Example: Pull Request Tracking + +Track pull requests through the development workflow: + +```aw wrap +--- +on: + pull_request: + types: [opened, review_requested] +permissions: + contents: read + actions: read + pull-requests: read +tools: + github: + toolsets: [default, projects] + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +project: + url: https://github.com/orgs/myorg/projects/2 + max-updates: 5 + do-not-downgrade-done-items: true +--- + +# PR Project Tracker + +Track pull requests in the development project board. + +## Task + +When a pull request is opened or reviews are requested: +1. Add the PR to the project board +2. Set status based on PR state: + - Just opened → "In Progress" + - Reviews requested → "In Review" +3. Set priority based on PR labels: + - Has "urgent" label → "High" + - Has "enhancement" label → "Medium" + - Default → "Low" +``` + +## Automatic Safe Outputs + +When you configure the `project` field, the compiler automatically adds these safe-output operations if not already configured: + +### update-project + +Manages project items (add, update fields, views): + +```yaml +# Automatically configured with project field +update-project: + max: 100 # Default from project.max-updates + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +``` + +Operations: +- `create` - Create a new project +- `add` - Add items to project +- `update` - Update project fields (status, priority, custom fields) +- `create_fields` - Create custom fields +- `create_views` - Create project views + +### create-project-status-update + +Posts status updates to project boards: + +```yaml +# Automatically configured with project field +create-project-status-update: + max: 1 # Default from project.max-status-updates + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +``` + +Use for campaign progress reports, milestone summaries, or workflow health indicators. + +## Overriding Auto-Configuration + +If you need custom configuration, define safe-outputs explicitly. Your configuration takes precedence: + +```yaml +project: + url: https://github.com/orgs/github/projects/123 +safe-outputs: + update-project: + max: 25 # Custom max overrides project.max-updates + views: + - name: "Triage View" + layout: board + filter: "status:Needs Triage" + create-project-status-update: + max: 3 # Custom max overrides project.max-status-updates +``` + +## Relationship with Campaigns + +The `project` field brings project tracking capabilities from [campaign orchestrators](/gh-aw/examples/campaigns/) to regular agentic workflows: + +**Campaign orchestrators** (campaign.md files): +- Use `project-url` in campaign spec +- Automatically coordinate multiple workflows +- Track campaign-wide progress + +**Agentic workflows** (regular .md files): +- Use `project` in frontmatter +- Focus on single workflow operations +- Track workflow-specific items + +Both use the same underlying safe-output operations (`update-project`, `create-project-status-update`). + +## Common Patterns + +### Progressive Status Updates + +Move items through workflow stages: + +```aw +Analyze the issue and determine its current state: +- If new and unreviewed → status="Needs Triage" +- If reviewed and accepted → status="Todo" +- If work started → status="In Progress" +- If PR merged → status="Done" + +Update the project item with the appropriate status. +``` + +### Priority Assignment + +Set priority based on content analysis: + +```aw +Examine the issue for urgency indicators: +- Contains "critical", "urgent", "blocker" → priority="High" +- Contains "important", "soon" → priority="Medium" +- Default → priority="Low" + +Update the project item with the assigned priority. +``` + +### Field-Based Routing + +Use custom fields for workflow routing: + +```aw +Determine the team that should handle this issue: +- Security-related → team="Security" +- UI/UX changes → team="Design" +- API changes → team="Backend" +- Default → team="General" + +Update the project item with the team field. +``` + +## Best Practices + +1. **Use specific project URLs** - Reference the exact project board to avoid ambiguity +2. **Set reasonable limits** - Use `max-updates` to prevent runaway operations +3. **Secure tokens properly** - Store project tokens as repository/organization secrets +4. **Enable do-not-downgrade** - Prevent accidental status regression on completed items +5. **Test with dry runs** - Use `staged: true` in safe-outputs to preview changes +6. **Document field mappings** - Comment your workflow to explain project field choices + +## Troubleshooting + +### Items Not Added to Project + +**Symptoms**: Workflow runs successfully but items don't appear in project board + +**Solutions**: +- Verify project URL is correct (check browser address bar) +- Confirm token has Projects: Read & Write permissions +- Check that organization allows Projects access for the token +- Review workflow logs for safe_outputs job errors + +### Permission Errors + +**Symptoms**: Workflow fails with "Resource not accessible" or "Insufficient permissions" + +**Solutions**: +- For organization projects: Use fine-grained PAT with organization Projects permission +- For user projects: Use classic PAT with `project` scope +- Ensure token is stored in correct secret name +- Verify repository settings allow Actions to access secrets + +### Token Not Resolved + +**Symptoms**: Workflow fails with "invalid token" or token appears as literal string + +**Solutions**: +- Use GitHub expression syntax: `${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}` +- Don't quote the expression in YAML +- Ensure secret name matches exactly (case-sensitive) +- Check secret is set at repository or organization level + +## See Also + +- [Safe Outputs Reference](/gh-aw/reference/safe-outputs/) - Complete safe-outputs documentation +- [update-project](/gh-aw/reference/safe-outputs/#project-board-updates-update-project) - Detailed update-project configuration +- [create-project-status-update](/gh-aw/reference/safe-outputs/#project-status-updates-create-project-status-update) - Status update configuration +- [GitHub Projects V2 Tokens](/gh-aw/reference/tokens/#gh_aw_project_github_token-github-projects-v2) - Token setup guide +- [Campaigns](/gh-aw/examples/campaigns/) - Campaign orchestrator documentation diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 0152daa26b..4079794feb 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -22,7 +22,7 @@ tools: ## Frontmatter Elements -The frontmatter combines standard GitHub Actions properties (`on`, `permissions`, `run-name`, `runs-on`, `timeout-minutes`, `concurrency`, `env`, `environment`, `container`, `services`, `if`, `steps`, `cache`) with GitHub Agentic Workflows-specific elements (`description`, `source`, `github-token`, `imports`, `engine`, `strict`, `roles`, `features`, `safe-inputs`, `safe-outputs`, `network`, `tools`). +The frontmatter combines standard GitHub Actions properties (`on`, `permissions`, `run-name`, `runs-on`, `timeout-minutes`, `concurrency`, `env`, `environment`, `container`, `services`, `if`, `steps`, `cache`) with GitHub Agentic Workflows-specific elements (`description`, `source`, `github-token`, `imports`, `engine`, `strict`, `roles`, `features`, `project`, `safe-inputs`, `safe-outputs`, `network`, `tools`). Tool configurations (such as `bash`, `edit`, `github`, `web-fetch`, `web-search`, `playwright`, `cache-memory`, and custom [Model Context Protocol](/gh-aw/reference/glossary/#mcp-model-context-protocol) (MCP) [servers](/gh-aw/reference/glossary/#mcp-server)) are specified under the `tools:` key. Custom inline tools can be defined with the [`safe-inputs:`](/gh-aw/reference/safe-inputs/) (custom tools defined inline) key. See [Tools](/gh-aw/reference/tools/) and [Safe Inputs](/gh-aw/reference/safe-inputs/) for complete documentation. @@ -239,6 +239,29 @@ network: - "api.example.com" # Custom domain ``` +### Project Tracking (`project:`) + +Automatically enables project board management operations for tracking workflow-created items. See [Project Tracking](/gh-aw/examples/project-tracking/) for complete documentation. + +```yaml wrap +# Simple format - just the URL +project: https://github.com/orgs/github/projects/123 + +# Full configuration with custom settings +project: + url: https://github.com/orgs/github/projects/123 + scope: + - owner/repo1 + - org:myorg + max-updates: 50 + max-status-updates: 2 + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} +``` + +When configured, automatically enables: +- **update-project** - Add items to projects, update fields (status, priority, etc.) +- **create-project-status-update** - Post status updates to project boards + ### Safe Inputs (`safe-inputs:`) Enables defining custom MCP tools inline using JavaScript or shell scripts. See [Safe Inputs](/gh-aw/reference/safe-inputs/) for complete documentation on creating custom tools with controlled secret access. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index a02aa70bd8..a99560ac42 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3645,6 +3645,77 @@ } ] }, + "project": { + "oneOf": [ + { + "type": "string", + "description": "GitHub Project URL for tracking workflow-created items. When configured, automatically enables project tracking operations (update-project, create-project-status-update) to manage project boards similar to campaign orchestrators.", + "pattern": "^https://github\\.com/(users|orgs)/[^/]+/projects/\\d+$", + "examples": ["https://github.com/orgs/github/projects/123", "https://github.com/users/username/projects/456"] + }, + { + "type": "object", + "description": "Project tracking configuration with custom settings for managing GitHub Project boards. Automatically enables update-project and create-project-status-update operations.", + "required": ["url"], + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "GitHub Project URL (required). Must be a valid GitHub Projects V2 URL.", + "pattern": "^https://github\\.com/(users|orgs)/[^/]+/projects/\\d+$", + "examples": ["https://github.com/orgs/github/projects/123", "https://github.com/users/username/projects/456"] + }, + "max-updates": { + "type": "integer", + "description": "Maximum number of project update operations per workflow run (default: 100). Controls the update-project safe-output maximum.", + "minimum": 1, + "maximum": 1000, + "default": 100 + }, + "scope": { + "type": "array", + "description": "Optional list of repositories and organizations this workflow can operate on. Supports 'owner/repo' for specific repositories and 'org:name' for all repositories in an organization. When omitted, defaults to the current repository.", + "items": { + "type": "string", + "pattern": "^([a-zA-Z0-9][-a-zA-Z0-9]{0,38}/[a-zA-Z0-9._-]+|org:[a-zA-Z0-9][-a-zA-Z0-9]{0,38})$" + }, + "examples": [["owner/repo"], ["org:github"], ["owner/repo1", "owner/repo2", "org:myorg"]] + }, + "max-status-updates": { + "type": "integer", + "description": "Maximum number of project status update operations per workflow run (default: 1). Controls the create-project-status-update safe-output maximum.", + "minimum": 1, + "maximum": 10, + "default": 1 + }, + "github-token": { + "type": "string", + "description": "Optional custom GitHub token for project operations. Should reference a secret with Projects: Read & Write permissions (e.g., ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}).", + "examples": ["${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}"] + }, + "do-not-downgrade-done-items": { + "type": "boolean", + "description": "When true, prevents moving items backward in workflow status (e.g., Done → In Progress). Useful for maintaining completed state integrity.", + "default": false + } + }, + "examples": [ + { + "url": "https://github.com/orgs/github/projects/123", + "scope": ["owner/repo1", "owner/repo2"], + "max-updates": 50, + "github-token": "${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}" + }, + { + "url": "https://github.com/users/username/projects/456", + "scope": ["org:myorg"], + "max-status-updates": 2, + "do-not-downgrade-done-items": true + } + ] + } + ] + }, "safe-outputs": { "type": "object", "$comment": "Required if workflow creates or modifies GitHub resources. Operations requiring safe-outputs: autofix-code-scanning-alert, add-comment, add-labels, add-reviewer, assign-milestone, assign-to-agent, close-discussion, close-issue, close-pull-request, create-agent-session, create-agent-task (deprecated, use create-agent-session), create-code-scanning-alert, create-discussion, copy-project, create-issue, create-project-status-update, create-pull-request, create-pull-request-review-comment, hide-comment, link-sub-issue, mark-pull-request-as-ready-for-review, missing-tool, noop, push-to-pull-request-branch, remove-labels, threat-detection, update-discussion, update-issue, update-project, update-pull-request, update-release, upload-asset. See documentation for complete details.", diff --git a/pkg/workflow/compiler_orchestrator_tools.go b/pkg/workflow/compiler_orchestrator_tools.go index ba6488c2f2..c5e25f873e 100644 --- a/pkg/workflow/compiler_orchestrator_tools.go +++ b/pkg/workflow/compiler_orchestrator_tools.go @@ -47,6 +47,9 @@ func (c *Compiler) processToolsAndMarkdown(result *parser.FrontmatterResult, cle // Extract SafeOutputs configuration early so we can use it when applying default tools safeOutputs := c.extractSafeOutputsConfig(result.Frontmatter) + // Check for project field and auto-configure safe-outputs for project tracking + safeOutputs = c.applyProjectSafeOutputs(result.Frontmatter, safeOutputs) + // Extract SecretMasking configuration secretMasking := c.extractSecretMaskingConfig(result.Frontmatter) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 3fbc8de5ae..f5e40f230d 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -49,6 +49,17 @@ type PermissionsConfig struct { OrganizationPackages string `json:"organization-packages,omitempty"` } +// ProjectConfig represents the project tracking configuration for a workflow +// When configured, this automatically enables project board management operations +type ProjectConfig struct { + URL string `json:"url,omitempty"` // GitHub Project URL + Scope []string `json:"scope,omitempty"` // Repositories/organizations this workflow can operate on (e.g., ["owner/repo", "org:name"]) + MaxUpdates int `json:"max-updates,omitempty"` // Maximum number of project updates per run (default: 100) + MaxStatusUpdates int `json:"max-status-updates,omitempty"` // Maximum number of status updates per run (default: 1) + GitHubToken string `json:"github-token,omitempty"` // Optional custom GitHub token for project operations + DoNotDowngradeDoneItems *bool `json:"do-not-downgrade-done-items,omitempty"` // Prevent moving items backward (e.g., Done -> In Progress) +} + // FrontmatterConfig represents the structured configuration from workflow frontmatter // This provides compile-time type safety and clearer error messages compared to map[string]any type FrontmatterConfig struct { @@ -70,7 +81,8 @@ type FrontmatterConfig struct { Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` SafeInputs *SafeInputsConfig `json:"safe-inputs,omitempty"` - PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) + PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) + Project *ProjectConfig `json:"project,omitempty"` // Project tracking configuration // Event and trigger configuration On map[string]any `json:"on,omitempty"` // Complex trigger config with many variants (too dynamic to type) @@ -438,6 +450,9 @@ func (fc *FrontmatterConfig) ToMap() map[string]any { // Convert SafeInputsConfig to map - would need a ToMap method result["safe-inputs"] = fc.SafeInputs } + if fc.Project != nil { + result["project"] = projectConfigToMap(fc.Project) + } // Event and trigger configuration if fc.On != nil { @@ -646,3 +661,37 @@ func permissionsConfigToMap(config *PermissionsConfig) map[string]any { return result } + +// projectConfigToMap converts ProjectConfig back to map[string]any +func projectConfigToMap(config *ProjectConfig) map[string]any { + if config == nil { + return nil + } + + result := make(map[string]any) + + if config.URL != "" { + result["url"] = config.URL + } + if len(config.Scope) > 0 { + result["scope"] = config.Scope + } + if config.MaxUpdates > 0 { + result["max-updates"] = config.MaxUpdates + } + if config.MaxStatusUpdates > 0 { + result["max-status-updates"] = config.MaxStatusUpdates + } + if config.GitHubToken != "" { + result["github-token"] = config.GitHubToken + } + if config.DoNotDowngradeDoneItems != nil { + result["do-not-downgrade-done-items"] = *config.DoNotDowngradeDoneItems + } + + if len(result) == 0 { + return nil + } + + return result +} diff --git a/pkg/workflow/project_safe_outputs.go b/pkg/workflow/project_safe_outputs.go new file mode 100644 index 0000000000..780eb9b709 --- /dev/null +++ b/pkg/workflow/project_safe_outputs.go @@ -0,0 +1,152 @@ +package workflow + +import "github.com/githubnext/gh-aw/pkg/logger" + +var projectSafeOutputsLog = logger.New("workflow:project_safe_outputs") + +// applyProjectSafeOutputs checks for a project field in the frontmatter and automatically +// configures safe-outputs for project tracking when present. This provides the same +// project tracking behavior that campaign orchestrators have. +// +// When a project field is detected: +// - Automatically adds update-project safe-output if not already configured +// - Automatically adds create-project-status-update safe-output if not already configured +// - Applies project-specific settings (max-updates, github-token, etc.) +func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingSafeOutputs *SafeOutputsConfig) *SafeOutputsConfig { + projectSafeOutputsLog.Print("Checking for project field in frontmatter") + + // Check if project field exists + projectData, hasProject := frontmatter["project"] + if !hasProject || projectData == nil { + projectSafeOutputsLog.Print("No project field found in frontmatter") + return existingSafeOutputs + } + + projectSafeOutputsLog.Print("Project field found, parsing configuration") + + // Parse project configuration + var projectConfig *ProjectConfig + if projectMap, ok := projectData.(map[string]any); ok { + projectConfig = c.parseProjectConfig(projectMap) + } else if projectStr, ok := projectData.(string); ok { + // Simple string format: just a URL + projectConfig = &ProjectConfig{ + URL: projectStr, + } + } else { + projectSafeOutputsLog.Print("Invalid project field format, skipping") + return existingSafeOutputs + } + + if projectConfig == nil || projectConfig.URL == "" { + projectSafeOutputsLog.Print("No valid project URL found, skipping") + return existingSafeOutputs + } + + projectSafeOutputsLog.Printf("Project URL configured: %s", projectConfig.URL) + + // Create or update SafeOutputsConfig + safeOutputs := existingSafeOutputs + if safeOutputs == nil { + safeOutputs = &SafeOutputsConfig{} + projectSafeOutputsLog.Print("Created new SafeOutputsConfig for project tracking") + } + + // Apply defaults if not specified + maxUpdates := projectConfig.MaxUpdates + if maxUpdates == 0 { + maxUpdates = 100 // Default for project updates (same as campaign orchestrators) + } + + maxStatusUpdates := projectConfig.MaxStatusUpdates + if maxStatusUpdates == 0 { + maxStatusUpdates = 1 // Default for status updates + } + + // Configure update-project if not already configured + if safeOutputs.UpdateProjects == nil { + projectSafeOutputsLog.Printf("Adding update-project safe-output (max: %d)", maxUpdates) + safeOutputs.UpdateProjects = &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: maxUpdates, + }, + GitHubToken: projectConfig.GitHubToken, + } + } else { + projectSafeOutputsLog.Print("update-project already configured, preserving existing configuration") + } + + // Configure create-project-status-update if not already configured + if safeOutputs.CreateProjectStatusUpdates == nil { + projectSafeOutputsLog.Printf("Adding create-project-status-update safe-output (max: %d)", maxStatusUpdates) + safeOutputs.CreateProjectStatusUpdates = &CreateProjectStatusUpdateConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Max: maxStatusUpdates, + }, + GitHubToken: projectConfig.GitHubToken, + } + } else { + projectSafeOutputsLog.Print("create-project-status-update already configured, preserving existing configuration") + } + + return safeOutputs +} + +// parseProjectConfig parses project configuration from a map +func (c *Compiler) parseProjectConfig(projectMap map[string]any) *ProjectConfig { + config := &ProjectConfig{} + + // Parse URL (required) + if url, exists := projectMap["url"]; exists { + if urlStr, ok := url.(string); ok { + config.URL = urlStr + } + } + + // Parse scope (optional) + if scope, exists := projectMap["scope"]; exists { + if scopeList, ok := scope.([]any); ok { + for _, item := range scopeList { + if scopeStr, ok := item.(string); ok { + config.Scope = append(config.Scope, scopeStr) + } + } + } + } + + // Parse max-updates (optional) + if maxUpdates, exists := projectMap["max-updates"]; exists { + switch v := maxUpdates.(type) { + case int: + config.MaxUpdates = v + case float64: + config.MaxUpdates = int(v) + } + } + + // Parse max-status-updates (optional) + if maxStatusUpdates, exists := projectMap["max-status-updates"]; exists { + switch v := maxStatusUpdates.(type) { + case int: + config.MaxStatusUpdates = v + case float64: + config.MaxStatusUpdates = int(v) + } + } + + // Parse github-token (optional) + if token, exists := projectMap["github-token"]; exists { + if tokenStr, ok := token.(string); ok { + config.GitHubToken = tokenStr + } + } + + // Parse do-not-downgrade-done-items (optional) + if doNotDowngrade, exists := projectMap["do-not-downgrade-done-items"]; exists { + if doNotDowngradeBool, ok := doNotDowngrade.(bool); ok { + config.DoNotDowngradeDoneItems = &doNotDowngradeBool + } + } + + return config +} diff --git a/pkg/workflow/project_safe_outputs_test.go b/pkg/workflow/project_safe_outputs_test.go new file mode 100644 index 0000000000..6a0953ce3e --- /dev/null +++ b/pkg/workflow/project_safe_outputs_test.go @@ -0,0 +1,256 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseProjectConfig(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + projectMap map[string]any + expectedConfig *ProjectConfig + }{ + { + name: "complete configuration", + projectMap: map[string]any{ + "url": "https://github.com/orgs/github/projects/123", + "scope": []any{"owner/repo1", "owner/repo2", "org:github"}, + "max-updates": 50, + "max-status-updates": 2, + "github-token": "${{ secrets.PROJECT_TOKEN }}", + "do-not-downgrade-done-items": true, + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/orgs/github/projects/123", + Scope: []string{"owner/repo1", "owner/repo2", "org:github"}, + MaxUpdates: 50, + MaxStatusUpdates: 2, + GitHubToken: "${{ secrets.PROJECT_TOKEN }}", + DoNotDowngradeDoneItems: boolPtr(true), + }, + }, + { + name: "configuration with scope only", + projectMap: map[string]any{ + "url": "https://github.com/orgs/github/projects/999", + "scope": []any{"org:myorg", "owner/special-repo"}, + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/orgs/github/projects/999", + Scope: []string{"org:myorg", "owner/special-repo"}, + }, + }, + { + name: "minimal configuration with URL only", + projectMap: map[string]any{ + "url": "https://github.com/users/username/projects/456", + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/users/username/projects/456", + }, + }, + { + name: "URL with max-updates", + projectMap: map[string]any{ + "url": "https://github.com/orgs/github/projects/789", + "max-updates": 100, + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/orgs/github/projects/789", + MaxUpdates: 100, + }, + }, + { + name: "URL with custom token", + projectMap: map[string]any{ + "url": "https://github.com/orgs/github/projects/123", + "github-token": "${{ secrets.CUSTOM_TOKEN }}", + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/orgs/github/projects/123", + GitHubToken: "${{ secrets.CUSTOM_TOKEN }}", + }, + }, + { + name: "numeric max-updates as float64", + projectMap: map[string]any{ + "url": "https://github.com/orgs/github/projects/123", + "max-updates": 75.0, + }, + expectedConfig: &ProjectConfig{ + URL: "https://github.com/orgs/github/projects/123", + MaxUpdates: 75, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := compiler.parseProjectConfig(tt.projectMap) + + require.NotNil(t, config, "parseProjectConfig() should not return nil") + assert.Equal(t, tt.expectedConfig.URL, config.URL, "URL should match") + assert.Equal(t, tt.expectedConfig.Scope, config.Scope, "Scope should match") + assert.Equal(t, tt.expectedConfig.MaxUpdates, config.MaxUpdates, "MaxUpdates should match") + assert.Equal(t, tt.expectedConfig.MaxStatusUpdates, config.MaxStatusUpdates, "MaxStatusUpdates should match") + assert.Equal(t, tt.expectedConfig.GitHubToken, config.GitHubToken, "GitHubToken should match") + + if tt.expectedConfig.DoNotDowngradeDoneItems != nil { + require.NotNil(t, config.DoNotDowngradeDoneItems, "DoNotDowngradeDoneItems should not be nil") + assert.Equal(t, *tt.expectedConfig.DoNotDowngradeDoneItems, *config.DoNotDowngradeDoneItems, "DoNotDowngradeDoneItems should match") + } else { + assert.Nil(t, config.DoNotDowngradeDoneItems, "DoNotDowngradeDoneItems should be nil") + } + }) + } +} + +func TestApplyProjectSafeOutputs(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + tests := []struct { + name string + frontmatter map[string]any + existingSafeOutputs *SafeOutputsConfig + expectUpdateProject bool + expectStatusUpdate bool + expectedMaxUpdates int + expectedMaxStatus int + }{ + { + name: "project with URL string - creates safe-outputs", + frontmatter: map[string]any{ + "project": "https://github.com/orgs/github/projects/123", + }, + existingSafeOutputs: nil, + expectUpdateProject: true, + expectStatusUpdate: true, + expectedMaxUpdates: 100, // default + expectedMaxStatus: 1, // default + }, + { + name: "project with full config object", + frontmatter: map[string]any{ + "project": map[string]any{ + "url": "https://github.com/orgs/github/projects/456", + "max-updates": 50, + "max-status-updates": 2, + "github-token": "${{ secrets.PROJECT_TOKEN }}", + }, + }, + existingSafeOutputs: nil, + expectUpdateProject: true, + expectStatusUpdate: true, + expectedMaxUpdates: 50, + expectedMaxStatus: 2, + }, + { + name: "project with existing safe-outputs preserves existing", + frontmatter: map[string]any{ + "project": "https://github.com/orgs/github/projects/789", + }, + existingSafeOutputs: &SafeOutputsConfig{ + UpdateProjects: &UpdateProjectConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 25}, + }, + CreateProjectStatusUpdates: &CreateProjectStatusUpdateConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: 3}, + }, + }, + expectUpdateProject: true, + expectStatusUpdate: true, + expectedMaxUpdates: 25, // preserved from existing + expectedMaxStatus: 3, // preserved from existing + }, + { + name: "no project field - returns existing", + frontmatter: map[string]any{ + "name": "test-workflow", + }, + existingSafeOutputs: nil, + expectUpdateProject: false, + expectStatusUpdate: false, + }, + { + name: "empty project map - returns existing", + frontmatter: map[string]any{ + "project": map[string]any{}, + }, + existingSafeOutputs: nil, + expectUpdateProject: false, + expectStatusUpdate: false, + }, + { + name: "project with no URL - returns existing", + frontmatter: map[string]any{ + "project": map[string]any{ + "max-updates": 50, + }, + }, + existingSafeOutputs: nil, + expectUpdateProject: false, + expectStatusUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := compiler.applyProjectSafeOutputs(tt.frontmatter, tt.existingSafeOutputs) + + if tt.expectUpdateProject { + require.NotNil(t, result, "Safe outputs should be created") + require.NotNil(t, result.UpdateProjects, "UpdateProjects should be configured") + assert.Equal(t, tt.expectedMaxUpdates, result.UpdateProjects.Max, "UpdateProjects max should match expected") + } else if result != nil && result.UpdateProjects != nil { + // Only check if update-project wasn't expected but was present in existing config + if tt.existingSafeOutputs != nil && tt.existingSafeOutputs.UpdateProjects != nil { + assert.NotNil(t, result.UpdateProjects, "Existing UpdateProjects should be preserved") + } + } + + if tt.expectStatusUpdate { + require.NotNil(t, result, "Safe outputs should be created") + require.NotNil(t, result.CreateProjectStatusUpdates, "CreateProjectStatusUpdates should be configured") + assert.Equal(t, tt.expectedMaxStatus, result.CreateProjectStatusUpdates.Max, "CreateProjectStatusUpdates max should match expected") + } else if result != nil && result.CreateProjectStatusUpdates != nil { + // Only check if status-update wasn't expected but was present in existing config + if tt.existingSafeOutputs != nil && tt.existingSafeOutputs.CreateProjectStatusUpdates != nil { + assert.NotNil(t, result.CreateProjectStatusUpdates, "Existing CreateProjectStatusUpdates should be preserved") + } + } + }) + } +} + +func TestProjectConfigIntegration(t *testing.T) { + compiler := NewCompiler(false, "", "test") + + // Test full integration: frontmatter -> safe-outputs config + frontmatter := map[string]any{ + "project": map[string]any{ + "url": "https://github.com/orgs/test/projects/100", + "max-updates": 75, + "max-status-updates": 2, + "github-token": "${{ secrets.TEST_TOKEN }}", + }, + } + + result := compiler.applyProjectSafeOutputs(frontmatter, nil) + + require.NotNil(t, result, "Safe outputs should be created") + require.NotNil(t, result.UpdateProjects, "UpdateProjects should be configured") + require.NotNil(t, result.CreateProjectStatusUpdates, "CreateProjectStatusUpdates should be configured") + + // Check update-project configuration + assert.Equal(t, 75, result.UpdateProjects.Max, "UpdateProjects max should match") + assert.Equal(t, "${{ secrets.TEST_TOKEN }}", result.UpdateProjects.GitHubToken, "UpdateProjects token should match") + + // Check create-project-status-update configuration + assert.Equal(t, 2, result.CreateProjectStatusUpdates.Max, "CreateProjectStatusUpdates max should match") + assert.Equal(t, "${{ secrets.TEST_TOKEN }}", result.CreateProjectStatusUpdates.GitHubToken, "CreateProjectStatusUpdates token should match") +}