Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions .github/workflows/dependabot-burner.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions .github/workflows/dependabot-burner.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: Dependabot Burner
description: Burns down open open dependabot pull requests.
description: Burns down open Dependabot pull requests.

on:
schedule: daily
Expand All @@ -15,13 +15,12 @@ permissions:

imports:
- shared/campaign.md

project: https://github.com/orgs/githubnext/projects/144
---

# Dependabot Burner

- Project URL: https://github.com/orgs/githubnext/projects/144
- Campaign ID: dependabot-burner

- Find all open Dependabot PRs and add them to the project.
- Create bundle issues, each for exactly **one runtime + one manifest file**.
- Add bundle issues to the project, and assign them to Copilot.
6 changes: 4 additions & 2 deletions .github/workflows/test-project-url-default.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions .github/workflows/test-project-url-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ engine: copilot
on:
workflow_dispatch:

project:
url: "https://github.com/orgs/githubnext/projects/1"
project: "https://github.com/orgs/<ORG>/projects/<NUMBER>"

safe-outputs:
update-project:
Expand Down Expand Up @@ -43,7 +42,9 @@ URL as a default when the message doesn't specify a project field.
}
```

This will automatically use `https://github.com/orgs/githubnext/projects/1` from the frontmatter.
This will automatically use `https://github.com/orgs/<ORG>/projects/<NUMBER>` from the frontmatter.

Important: this is a placeholder. Replace it with a real GitHub Projects v2 URL before running the workflow.

```json
{
Expand Down
8 changes: 7 additions & 1 deletion docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,12 @@ network:
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
# Simple format - just the URL (quotes optional)
project: https://github.com/orgs/github/projects/123

# With placeholder values (quotes recommended for angle brackets)
project: "https://github.com/orgs/<ORG>/projects/<NUMBER>"

# Full configuration with custom settings
project:
url: https://github.com/orgs/github/projects/123
Expand All @@ -258,6 +261,9 @@ project:
github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }}
```

> [!NOTE]
> Quotes are optional for plain URLs but recommended when using placeholder values with angle brackets (`<ORG>`, `<NUMBER>`) to ensure proper YAML parsing.

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
Expand Down
8 changes: 4 additions & 4 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3657,8 +3657,8 @@
{
"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"]
"pattern": "^https://github\\.com/(users|orgs)/([^/]+|<[A-Z_]+>)/projects/(\\d+|<[A-Z_]+>)$",
"examples": ["https://github.com/orgs/github/projects/123", "https://github.com/users/username/projects/456", "https://github.com/orgs/<ORG>/projects/<NUMBER>"]
},
{
"type": "object",
Expand All @@ -3669,8 +3669,8 @@
"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"]
"pattern": "^https://github\\.com/(users|orgs)/([^/]+|<[A-Z_]+>)/projects/(\\d+|<[A-Z_]+>)$",
"examples": ["https://github.com/orgs/github/projects/123", "https://github.com/users/username/projects/456", "https://github.com/orgs/<ORG>/projects/<NUMBER>"]
},
"max-updates": {
"type": "integer",
Expand Down
18 changes: 12 additions & 6 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,22 +251,28 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
var config FrontmatterConfig

// Normalize mixed-type fields before unmarshaling into typed structs.
// In YAML frontmatter, "project" can be either:
// - a URL string (short form): project: https://github.com/orgs/.../projects/123
// - an object (long form): project: { url: ... , ... }
// The typed struct expects an object, so convert the short form to the long form.
// In YAML frontmatter, "project" must be a URL string:
// project: https://github.com/orgs/.../projects/123
normalizedFrontmatter := make(map[string]any, len(frontmatter))
for k, v := range frontmatter {
normalizedFrontmatter[k] = v
}
if projectValue, ok := frontmatter["project"]; ok {
if projectURL, ok := projectValue.(string); ok {
projectURL = strings.TrimSpace(projectURL)
switch v := projectValue.(type) {
case nil:
delete(normalizedFrontmatter, "project")
case string:
projectURL := strings.TrimSpace(v)
if projectURL == "" {
delete(normalizedFrontmatter, "project")
} else {
// Normalize string value into the typed struct shape.
normalizedFrontmatter["project"] = map[string]any{"url": projectURL}
}
case map[string]any, map[any]any:
return nil, fmt.Errorf("invalid frontmatter field 'project': expected URL string, got mapping")
default:
return nil, fmt.Errorf("invalid frontmatter field 'project': expected URL string, got %T", projectValue)
}
}

Expand Down
27 changes: 22 additions & 5 deletions pkg/workflow/frontmatter_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ func TestParseFrontmatterConfig(t *testing.T) {
}
})

t.Run("parses project as URL string (short form)", func(t *testing.T) {
t.Run("parses project as URL string", func(t *testing.T) {
frontmatter := map[string]any{
"name": "project-short-form",
"project": "https://github.com/orgs/githubnext/projects/144",
"name": "project-string",
"project": "https://github.com/orgs/<ORG>/projects/<NUMBER>",
}

config, err := ParseFrontmatterConfig(frontmatter)
Expand All @@ -160,8 +160,25 @@ func TestParseFrontmatterConfig(t *testing.T) {
if config.Project == nil {
t.Fatal("Project should not be nil")
}
if config.Project.URL != "https://github.com/orgs/githubnext/projects/144" {
t.Errorf("Project.URL = %q, want %q", config.Project.URL, "https://github.com/orgs/githubnext/projects/144")
if config.Project.URL != "https://github.com/orgs/<ORG>/projects/<NUMBER>" {
t.Errorf("Project.URL = %q, want %q", config.Project.URL, "https://github.com/orgs/<ORG>/projects/<NUMBER>")
}
})

t.Run("rejects project as mapping", func(t *testing.T) {
frontmatter := map[string]any{
"name": "project-mapping",
"project": map[string]any{
"url": "https://github.com/orgs/<ORG>/projects/<NUMBER>",
},
}

_, err := ParseFrontmatterConfig(frontmatter)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "expected URL string") {
t.Fatalf("expected type error, got: %v", err)
}
})

Expand Down
105 changes: 17 additions & 88 deletions pkg/workflow/project_safe_outputs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package workflow

import "github.com/githubnext/gh-aw/pkg/logger"
import (
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
)

var projectSafeOutputsLog = logger.New("workflow:project_safe_outputs")

Expand All @@ -22,28 +26,21 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS
return existingSafeOutputs
}

projectSafeOutputsLog.Print("Project field found, parsing configuration")
projectSafeOutputsLog.Print("Project field found")

// 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")
projectURL, ok := projectData.(string)
if !ok {
// NOTE: Only string project URLs are supported.
projectSafeOutputsLog.Print("Invalid project field format (expected string), skipping")
return existingSafeOutputs
}

if projectConfig == nil || projectConfig.URL == "" {
projectSafeOutputsLog.Print("No valid project URL found, skipping")
projectURL = strings.TrimSpace(projectURL)
if projectURL == "" {
projectSafeOutputsLog.Print("Empty project URL, skipping")
return existingSafeOutputs
}

projectSafeOutputsLog.Printf("Project URL configured: %s", projectConfig.URL)
projectSafeOutputsLog.Printf("Project URL configured: %s", projectURL)

// Create or update SafeOutputsConfig
safeOutputs := existingSafeOutputs
Expand All @@ -52,16 +49,9 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS
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
}
// Defaults match campaign orchestrator behavior.
maxUpdates := 100
maxStatusUpdates := 1

// Configure update-project if not already configured
if safeOutputs.UpdateProjects == nil {
Expand All @@ -70,7 +60,6 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS
BaseSafeOutputConfig: BaseSafeOutputConfig{
Max: maxUpdates,
},
GitHubToken: projectConfig.GitHubToken,
}
} else {
projectSafeOutputsLog.Print("update-project already configured, preserving existing configuration")
Expand All @@ -83,70 +72,10 @@ func (c *Compiler) applyProjectSafeOutputs(frontmatter map[string]any, existingS
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
}
Loading
Loading