diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 012e103e8f..6e9f59cb2e 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -955,8 +955,25 @@ async function main() { if (!result.success) return; const updateProjectItems = result.items.filter(item => item.type === "update_project"); - if (updateProjectItems.length === 0) return; + // Check if views are configured in frontmatter + const configuredViews = process.env.GH_AW_PROJECT_VIEWS; + let viewsToCreate = []; + if (configuredViews) { + try { + viewsToCreate = JSON.parse(configuredViews); + if (Array.isArray(viewsToCreate) && viewsToCreate.length > 0) { + core.info(`Found ${viewsToCreate.length} configured view(s) in frontmatter`); + } + } catch (parseError) { + core.warning(`Failed to parse GH_AW_PROJECT_VIEWS: ${getErrorMessage(parseError)}`); + } + } + + // If no update_project items and no configured views, nothing to do + if (updateProjectItems.length === 0 && viewsToCreate.length === 0) return; + + // Process update_project items from agent output for (let i = 0; i < updateProjectItems.length; i++) { const output = updateProjectItems[i]; try { @@ -968,6 +985,47 @@ async function main() { logGraphQLError(error, `Processing update_project item ${i + 1}`); } } + + // Create views from frontmatter configuration if any + // Views are created after items are processed to ensure the project exists + if (viewsToCreate.length > 0) { + // Get project URL from the first update_project item or fail if none + const projectUrl = updateProjectItems.length > 0 ? updateProjectItems[0].project : null; + + if (!projectUrl) { + core.warning("Cannot create configured views: no project URL found in update_project items. Views require at least one update_project operation to determine the target project."); + return; + } + + core.info(`Creating ${viewsToCreate.length} configured view(s) on project: ${projectUrl}`); + + for (let i = 0; i < viewsToCreate.length; i++) { + const viewConfig = viewsToCreate[i]; + try { + // Create a synthetic output item for view creation + const viewOutput = { + type: "update_project", + project: projectUrl, + operation: "create_view", + view: { + name: viewConfig.name, + layout: viewConfig.layout, + filter: viewConfig.filter, + visible_fields: viewConfig.visible_fields, + description: viewConfig.description, + }, + }; + + await updateProject(viewOutput); + core.info(`✓ Created view ${i + 1}/${viewsToCreate.length}: ${viewConfig.name} (${viewConfig.layout})`); + } catch (err) { + // prettier-ignore + const error = /** @type {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} */ (err); + core.error(`Failed to create configured view ${i + 1}: ${viewConfig.name}`); + logGraphQLError(error, `Creating configured view: ${viewConfig.name}`); + } + } + } } module.exports = { updateProject, parseProjectInput, generateCampaignId, main }; diff --git a/docs/package-lock.json b/docs/package-lock.json index 4b70cd48e8..7c841e7ce4 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -165,6 +165,7 @@ "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.35.2.tgz", "integrity": "sha512-curGghoW4s5pCbW2tINsJPoxEYPan87ptCOv7GZ+S24N3J6AyaOu/OsjZDEMaIpo3ZlObM5DQn+w7iXl3drDhQ==", "license": "MIT", + "peer": true, "dependencies": { "@astrojs/markdown-remark": "^6.3.1", "@astrojs/mdx": "^4.2.3", @@ -399,6 +400,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -1396,6 +1398,7 @@ "integrity": "sha512-DNCbwkAKugzCtiHJg/7DciIRwnKwAI2QH3VWWC1cVxoBBQTPnH5D9HcWqpDdduUqnCuW2PY78afVo+QlaInDdQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/postcss-is-pseudo-class": "^5.0.3", "cssesc": "^3.0.0", @@ -2437,6 +2440,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2620,6 +2624,7 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.15.9.tgz", "integrity": "sha512-XLDXxu0282cC/oYHswWZm3johGlRvk9rLRS7pWVWSne+HsZe9JgrpHI+vewAJSSNHBGd1aCyaQOElT5RNGe7IQ==", "license": "MIT", + "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -3206,6 +3211,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -3600,6 +3606,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -4000,6 +4007,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4240,7 +4248,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dfa": { "version": "1.2.0", @@ -6153,6 +6162,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6555,6 +6565,7 @@ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", "license": "MIT", + "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", @@ -7972,6 +7983,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8086,6 +8098,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8723,6 +8736,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9998,6 +10012,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10288,6 +10303,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -10426,6 +10442,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/docs/src/content/docs/guides/campaigns/project-management.md b/docs/src/content/docs/guides/campaigns/project-management.md index 90257ec5b2..e9ff0410d5 100644 --- a/docs/src/content/docs/guides/campaigns/project-management.md +++ b/docs/src/content/docs/guides/campaigns/project-management.md @@ -168,6 +168,27 @@ The campaign generator creates three views automatically: 2. **Task Tracker** (Table view) - Detailed tracking with filtering 3. **Progress Board** (Board view) - Kanban-style progress tracking +### Declarative view configuration + +Views can be declared directly in workflow frontmatter using the `views` property: + +```yaml +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + views: + - name: "Sprint Board" + layout: board + filter: "is:issue is:open" + - name: "Task Tracker" + layout: table + filter: "is:issue,is:pull_request" + - name: "Timeline" + layout: roadmap +``` + +Views are automatically created when the workflow runs. This declarative approach is simpler than programmatic view creation and ensures views are configured consistently across workflow runs. + **Customization tips:** **Multi-Workflow Campaign**: Use Roadmap grouped by Worker/Workflow for timeline distribution, Task Tracker sliced by Priority+Status for urgent items, Progress Board grouped by Status for progress tracking. diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index db4377baec..74b427183a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -1671,6 +1671,20 @@ safe-outputs: # (optional) github-token: "${{ secrets.GITHUB_TOKEN }}" + # Optional array of project views to create automatically. Each view must have a + # name and layout. Views are created during workflow execution after processing + # agent output items. + # (optional) + views: + - name: "Sprint Board" + layout: board # table, board, or roadmap + filter: "is:issue is:open" # optional filter query + - name: "Task Tracker" + layout: table + filter: "is:issue,is:pull_request" + - name: "Campaign Timeline" + layout: roadmap + # Option 2: Enable project management with default configuration (max=10) update-project: null diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 62855748c0..efc089c9e9 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -321,6 +321,14 @@ safe-outputs: update-project: max: 20 # max operations (default: 10) github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + views: # optional: auto-create views + - name: "Sprint Board" + layout: board + filter: "is:issue is:open" + - name: "Task Tracker" + layout: table + - name: "Campaign Roadmap" + layout: roadmap ``` Agent must provide full project URL (e.g., `https://github.com/orgs/myorg/projects/42`). Optional `campaign_id` applies `campaign:` labels for [Campaign Workflows](/gh-aw/guides/campaigns/). Exposes outputs: `project-id`, `project-number`, `project-url`, `campaign-id`, `item-id`. @@ -348,6 +356,49 @@ fields: > [!NOTE] > Field names are case-insensitive and automatically normalized (e.g., `story_points` matches `Story Points`). +#### Creating Project Views + +Project views can be created automatically by declaring them in the `views` array. Views are created when the workflow runs, after processing update_project items from the agent. + +**View configuration:** +```yaml +safe-outputs: + update-project: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + views: + - name: "Sprint Board" # required: view name + layout: board # required: table, board, or roadmap + filter: "is:issue is:open" # optional: filter query + - name: "Task Tracker" + layout: table + filter: "is:issue,is:pull_request" + - name: "Campaign Timeline" + layout: roadmap +``` + +**View properties:** + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `name` | string | Yes | View name (e.g., "Sprint Board", "Task Tracker") | +| `layout` | string | Yes | View layout: `table`, `board`, or `roadmap` | +| `filter` | string | No | Filter query (e.g., `is:issue is:open`, `label:bug`) | +| `visible-fields` | array | No | Field IDs to display (table/board only, not roadmap) | + +**Layout types:** +- **`table`** — List view with customizable columns for detailed tracking +- **`board`** — Kanban-style cards grouped by status or custom field +- **`roadmap`** — Timeline visualization with date-based swimlanes + +**Filter syntax examples:** +- `is:issue is:open` — Open issues only +- `is:pull_request` — Pull requests only +- `is:issue,is:pull_request` — Both issues and PRs +- `label:bug` — Items with bug label +- `assignee:@me` — Items assigned to viewer + +Views are created automatically during workflow execution. The workflow must include at least one `update_project` operation to provide the target project URL. See [Project Management Guide](/gh-aw/guides/campaigns/project-management/) for view customization strategies. + ### Project Board Copy (`copy-project:`) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0762613d40..3c002aa51d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3694,7 +3694,7 @@ "oneOf": [ { "type": "object", - "description": "Configuration for managing GitHub Projects v2 boards. Smart tool that can add issue/PR items and update custom fields on existing items. By default it is update-only: if the project does not exist, the job fails with instructions to create it manually. To allow workflows to create missing projects, explicitly opt in via the agent output field create_if_missing=true (and/or provide a github-token override). NOTE: Projects v2 requires a Personal Access Token (PAT) or GitHub App token with appropriate permissions; the GITHUB_TOKEN cannot be used for Projects v2. Safe output items produced by the agent use type=update_project and may include: project (board name), content_type (issue|pull_request), content_number, fields, campaign_id, and create_if_missing.", + "description": "Configuration for managing GitHub Projects v2 boards. Smart tool that can add issue/PR items and update custom fields on existing items. By default it is update-only: if the project does not exist, the job fails with instructions to create it manually. To allow workflows to create missing projects, explicitly opt in via the agent output field create_if_missing=true (and/or provide a github-token override). NOTE: Projects v2 requires a Personal Access Token (PAT) or GitHub App token with appropriate permissions; the GITHUB_TOKEN cannot be used for Projects v2. Safe output items produced by the agent use type=update_project Configuration also supports an optional views array for declaring project views to create. Safe output items produced by the agent use type=update_project and may include: project (board name), content_type (issue|pull_request), content_number, fields, campaign_id, and create_if_missing.", "properties": { "max": { "type": "integer", @@ -3705,6 +3705,42 @@ "github-token": { "$ref": "#/$defs/github_token", "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." + }, + "views": { + "type": "array", + "description": "Optional array of project views to create. Each view must have a name and layout. Views are created during project setup.", + "items": { + "type": "object", + "description": "View configuration for creating project views", + "required": ["name", "layout"], + "properties": { + "name": { + "type": "string", + "description": "The name of the view (e.g., 'Sprint Board', 'Campaign Roadmap')" + }, + "layout": { + "type": "string", + "enum": ["table", "board", "roadmap"], + "description": "The layout type of the view" + }, + "filter": { + "type": "string", + "description": "Optional filter query for the view (e.g., 'is:issue is:open', 'label:bug')" + }, + "visible-fields": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Optional array of 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 + } } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_safe_outputs_specialized.go b/pkg/workflow/compiler_safe_outputs_specialized.go index 8b81f4af5f..0d919aee2a 100644 --- a/pkg/workflow/compiler_safe_outputs_specialized.go +++ b/pkg/workflow/compiler_safe_outputs_specialized.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "fmt" ) @@ -57,6 +58,14 @@ func (c *Compiler) buildUpdateProjectStepConfig(data *WorkflowData, mainJobName var customEnvVars []string customEnvVars = append(customEnvVars, c.buildStepLevelSafeOutputEnvVars(data, "")...) + // If views are configured in frontmatter, pass them to the JavaScript via environment variable + if cfg != nil && len(cfg.Views) > 0 { + viewsJSON, err := json.Marshal(cfg.Views) + if err == nil { + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_PROJECT_VIEWS: '%s'\n", string(viewsJSON))) + } + } + condition := BuildSafeOutputType("update_project") return SafeOutputStepConfig{ diff --git a/pkg/workflow/update_project.go b/pkg/workflow/update_project.go index 3531105240..f2127d3f6f 100644 --- a/pkg/workflow/update_project.go +++ b/pkg/workflow/update_project.go @@ -4,10 +4,20 @@ import "github.com/githubnext/gh-aw/pkg/logger" var updateProjectLog = logger.New("workflow:update_project") +// ProjectView defines a project view configuration +type ProjectView struct { + Name string `yaml:"name" json:"name"` + Layout string `yaml:"layout" json:"layout"` + Filter string `yaml:"filter,omitempty" json:"filter,omitempty"` + VisibleFields []int `yaml:"visible-fields,omitempty" json:"visible_fields,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` +} + // UpdateProjectConfig holds configuration for unified project board management type UpdateProjectConfig struct { BaseSafeOutputConfig `yaml:",inline"` - GitHubToken string `yaml:"github-token,omitempty"` + GitHubToken string `yaml:"github-token,omitempty"` + Views []ProjectView `yaml:"views,omitempty"` } // parseUpdateProjectConfig handles update-project configuration @@ -28,10 +38,68 @@ func (c *Compiler) parseUpdateProjectConfig(outputMap map[string]any) *UpdatePro updateProjectLog.Print("Using custom GitHub token for update-project") } } + + // Parse views if specified + if viewsData, exists := configMap["views"]; exists { + if viewsList, ok := viewsData.([]any); ok { + for i, viewItem := range viewsList { + if viewMap, ok := viewItem.(map[string]any); ok { + view := ProjectView{} + + // Parse name (required) + if name, exists := viewMap["name"]; exists { + if nameStr, ok := name.(string); ok { + view.Name = nameStr + } + } + + // Parse layout (required) + if layout, exists := viewMap["layout"]; exists { + if layoutStr, ok := layout.(string); ok { + view.Layout = layoutStr + } + } + + // Parse filter (optional) + if filter, exists := viewMap["filter"]; exists { + if filterStr, ok := filter.(string); ok { + view.Filter = filterStr + } + } + + // Parse visible-fields (optional) + if visibleFields, exists := viewMap["visible-fields"]; exists { + if fieldsList, ok := visibleFields.([]any); ok { + for _, field := range fieldsList { + if fieldInt, ok := field.(int); ok { + view.VisibleFields = append(view.VisibleFields, fieldInt) + } + } + } + } + + // Parse description (optional) + if description, exists := viewMap["description"]; exists { + if descStr, ok := description.(string); ok { + view.Description = descStr + } + } + + // Only add view if it has required fields + if view.Name != "" && view.Layout != "" { + updateProjectConfig.Views = append(updateProjectConfig.Views, view) + updateProjectLog.Printf("Parsed view %d: %s (%s)", i+1, view.Name, view.Layout) + } else { + updateProjectLog.Printf("Skipping invalid view %d: missing required fields", i+1) + } + } + } + } + } } - updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v", - updateProjectConfig.Max, updateProjectConfig.GitHubToken != "") + updateProjectLog.Printf("Parsed update-project config: max=%d, hasCustomToken=%v, viewCount=%d", + updateProjectConfig.Max, updateProjectConfig.GitHubToken != "", len(updateProjectConfig.Views)) return updateProjectConfig } updateProjectLog.Print("No update-project configuration found") diff --git a/pkg/workflow/update_project_job.go b/pkg/workflow/update_project_job.go index 1c57cdd4db..5dbf9e7f02 100644 --- a/pkg/workflow/update_project_job.go +++ b/pkg/workflow/update_project_job.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "fmt" ) @@ -37,6 +38,15 @@ func (c *Compiler) buildUpdateProjectJob(data *WorkflowData, mainJobName string) // The JavaScript code checks process.env.GH_AW_PROJECT_GITHUB_TOKEN to provide helpful error messages customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_PROJECT_GITHUB_TOKEN: %s\n", effectiveToken)) + // If views are configured in frontmatter, pass them to the JavaScript via environment variable + if data.SafeOutputs.UpdateProjects != nil && len(data.SafeOutputs.UpdateProjects.Views) > 0 { + viewsJSON, err := json.Marshal(data.SafeOutputs.UpdateProjects.Views) + if err != nil { + return nil, fmt.Errorf("failed to marshal views configuration: %w", err) + } + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_PROJECT_VIEWS: '%s'\n", string(viewsJSON))) + } + jobCondition := BuildSafeOutputType("update_project") permissions := NewPermissionsContentsReadProjectsWrite()