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
717 changes: 713 additions & 4 deletions .github/workflows/security-alert-burndown.lock.yml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion .github/workflows/security-alert-burndown.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ permissions:
issues: read
pull-requests: read
contents: read
project: https://github.com/orgs/githubnext/projects/144
project:
url: https://github.com/orgs/githubnext/projects/144
---

# Security Alert Burndown Campaign
Expand Down
70 changes: 66 additions & 4 deletions pkg/campaign/injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package campaign

import (
"fmt"
"regexp"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
Expand All @@ -10,6 +11,18 @@ import (

var injectionLog = logger.New("campaign:injection")

var campaignIDSanitizer = regexp.MustCompile(`[^a-z0-9-]+`)

func normalizeCampaignID(id string) string {
// Keep IDs stable and safe for labels/paths.
id = strings.ToLower(strings.TrimSpace(id))
id = strings.ReplaceAll(id, "_", "-")
id = strings.ReplaceAll(id, " ", "-")
id = campaignIDSanitizer.ReplaceAllString(id, "-")
id = strings.Trim(id, "-")
return id
}

// InjectOrchestratorFeatures detects if a workflow has project field with campaign
// configuration and injects orchestrator features directly into the workflow during compilation.
// This transforms the workflow into a campaign orchestrator without generating separate files.
Expand All @@ -24,20 +37,42 @@ func InjectOrchestratorFeatures(workflowData *workflow.WorkflowData) error {

project := workflowData.ParsedFrontmatter.Project

// Determine whether the project config looks like "project tracking" only.
// A minimal campaign can specify only the project URL (either short or long form) and omit
// campaign fields like id/workflows; in that case we infer the campaign ID from the workflow filename.
hasTrackingOnlySettings := len(project.Scope) > 0 ||
project.MaxUpdates > 0 ||
project.MaxStatusUpdates > 0 ||
strings.TrimSpace(project.GitHubToken) != "" ||
project.DoNotDowngradeDoneItems != nil

// Check if project has any campaign orchestration fields to determine if this is a campaign
// Campaign indicators (any of these present means it's a campaign orchestrator):
// - explicit campaign ID
// - workflows list (predefined workers)
// - governance policies (campaign-specific constraints)
// - bootstrap configuration (initial work item generation)
// - memory-paths, metrics-glob, cursor-glob (campaign state tracking)
// If only URL and scope are present, it's simple project tracking, not a campaign
isCampaign := len(project.Workflows) > 0 ||
hasCampaignIndicators := strings.TrimSpace(project.ID) != "" ||
len(project.Workflows) > 0 ||
project.Governance != nil ||
project.Bootstrap != nil ||
len(project.MemoryPaths) > 0 ||
project.MetricsGlob != "" ||
project.CursorGlob != ""

// If the user used the object form with only a URL (no tracking-only knobs), treat it as a campaign
// and infer the campaign ID from the workflow filename (minus .md).
if !hasCampaignIndicators && !hasTrackingOnlySettings {
if workflowData.WorkflowID != "" {
project.ID = workflowData.WorkflowID
hasCampaignIndicators = true
}
}

isCampaign := hasCampaignIndicators

if !isCampaign {
injectionLog.Print("Project field present but no campaign indicators, treating as simple project tracking")
return nil
Expand All @@ -46,10 +81,37 @@ func InjectOrchestratorFeatures(workflowData *workflow.WorkflowData) error {
injectionLog.Printf("Detected campaign orchestrator: workflows=%d, has_governance=%v, has_bootstrap=%v",
len(project.Workflows), project.Governance != nil, project.Bootstrap != nil)

// Derive campaign ID from workflow name or use explicit ID
campaignID := workflowData.FrontmatterName
if project.ID != "" {
// Derive campaign ID (prefer explicit id, then workflow filename, then workflow name).
// Note: workflowData.FrontmatterName is the *frontmatter name field* (display name), not the file basename.
campaignID := ""
if strings.TrimSpace(project.ID) != "" {
campaignID = project.ID
} else if strings.TrimSpace(workflowData.WorkflowID) != "" {
campaignID = workflowData.WorkflowID
} else if strings.TrimSpace(workflowData.Name) != "" {
campaignID = workflowData.Name
} else {
campaignID = "campaign"
}
campaignID = normalizeCampaignID(campaignID)
if campaignID == "" {
campaignID = "campaign"
}

// Apply campaign defaults (matching the historical .campaign.md defaults) when omitted.
// This keeps project-based campaigns minimal: users can specify just url + id.
project.ID = campaignID
if strings.TrimSpace(project.TrackerLabel) == "" {
project.TrackerLabel = fmt.Sprintf("z_campaign_%s", campaignID)
}
if len(project.MemoryPaths) == 0 {
project.MemoryPaths = []string{fmt.Sprintf("memory/campaigns/%s/**", campaignID)}
}
if strings.TrimSpace(project.MetricsGlob) == "" {
project.MetricsGlob = fmt.Sprintf("memory/campaigns/%s/metrics/*.json", campaignID)
}
if strings.TrimSpace(project.CursorGlob) == "" {
project.CursorGlob = fmt.Sprintf("memory/campaigns/%s/cursor.json", campaignID)
}

// Build campaign prompt data from project configuration
Expand Down
60 changes: 60 additions & 0 deletions pkg/campaign/injection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package campaign

import (
"testing"

"github.com/githubnext/gh-aw/pkg/workflow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInjectOrchestratorFeatures_ProjectIDTriggersCampaignAndDefaults(t *testing.T) {
data := &workflow.WorkflowData{
Name: "Security Alert Burndown",
WorkflowID: "security-alert-burndown",
MarkdownContent: "# Test",
ParsedFrontmatter: &workflow.FrontmatterConfig{
Project: &workflow.ProjectConfig{
URL: "https://github.com/orgs/githubnext/projects/144",
},
},
}

err := InjectOrchestratorFeatures(data)
require.NoError(t, err, "campaign injection should succeed")
require.NotNil(t, data.ParsedFrontmatter, "ParsedFrontmatter should remain")
require.NotNil(t, data.ParsedFrontmatter.Project, "Project should remain")

project := data.ParsedFrontmatter.Project
assert.Equal(t, "security-alert-burndown", project.ID, "Campaign ID should be inferred and normalized")
assert.Equal(t, "z_campaign_security-alert-burndown", project.TrackerLabel, "TrackerLabel should be defaulted")
assert.Equal(t, []string{"memory/campaigns/security-alert-burndown/**"}, project.MemoryPaths, "MemoryPaths should be defaulted")
assert.Equal(t, "memory/campaigns/security-alert-burndown/metrics/*.json", project.MetricsGlob, "MetricsGlob should be defaulted")
assert.Equal(t, "memory/campaigns/security-alert-burndown/cursor.json", project.CursorGlob, "CursorGlob should be defaulted")

assert.Contains(t, data.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "Markdown should have orchestrator instructions injected")
}

func TestInjectOrchestratorFeatures_ProjectTrackingOnly_DoesNotInject(t *testing.T) {
data := &workflow.WorkflowData{
Name: "Project Tracking Only",
FrontmatterName: "project-tracking-only",
MarkdownContent: "# Test",
ParsedFrontmatter: &workflow.FrontmatterConfig{
Project: &workflow.ProjectConfig{
URL: "https://github.com/orgs/githubnext/projects/144",
Scope: []string{"githubnext/gh-aw"},
},
},
}

err := InjectOrchestratorFeatures(data)
require.NoError(t, err, "non-campaign project tracking should be a no-op")
assert.NotContains(t, data.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "Should not inject campaign sections")
}

func TestNormalizeCampaignID(t *testing.T) {
assert.Equal(t, "security-alert-burndown", normalizeCampaignID("Security Alert Burndown"))
assert.Equal(t, "security-alert-burndown", normalizeCampaignID("security_alert_burndown"))
assert.Equal(t, "security-alert-burndown", normalizeCampaignID(" security---alert@@burndown "))
}
2 changes: 2 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)

// Build initial workflow data structure
workflowData := c.buildInitialWorkflowData(result, toolsResult, engineSetup, engineSetup.importsResult)
// Store a stable workflow identifier derived from the file name.
workflowData.WorkflowID = GetWorkflowIDFromPath(cleanPath)

// Use shared action cache and resolver from the compiler
actionCache, actionResolver := c.getSharedActionResolver()
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ type SkipIfNoMatchConfig struct {
// WorkflowData holds all the data needed to generate a GitHub Actions workflow
type WorkflowData struct {
Name string
WorkflowID string // workflow identifier derived from markdown filename (basename without extension)
TrialMode bool // whether the workflow is running in trial mode
TrialLogicalRepo string // target repository slug for trial mode (owner/repo)
FrontmatterName string // name field from frontmatter (for code scanning alert driver default)
Expand Down
23 changes: 22 additions & 1 deletion pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package workflow
import (
"encoding/json"
"fmt"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
)
Expand Down Expand Up @@ -249,9 +250,29 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
frontmatterTypesLog.Printf("Parsing frontmatter config with %d fields", len(frontmatter))
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.
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)
if projectURL == "" {
delete(normalizedFrontmatter, "project")
} else {
normalizedFrontmatter["project"] = map[string]any{"url": projectURL}
}
}
}

// Use JSON marshaling for the entire frontmatter conversion
// This automatically handles all field mappings
jsonBytes, err := json.Marshal(frontmatter)
jsonBytes, err := json.Marshal(normalizedFrontmatter)
if err != nil {
frontmatterTypesLog.Printf("Failed to marshal frontmatter: %v", err)
return nil, fmt.Errorf("failed to marshal frontmatter to JSON: %w", err)
Expand Down
19 changes: 19 additions & 0 deletions pkg/workflow/frontmatter_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ func TestParseFrontmatterConfig(t *testing.T) {
}
})

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

config, err := ParseFrontmatterConfig(frontmatter)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

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")
}
})

t.Run("parses complete workflow config", func(t *testing.T) {
frontmatter := map[string]any{
"name": "full-workflow",
Expand Down
Loading