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

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

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

20 changes: 10 additions & 10 deletions .github/workflows/mcp-inspector.lock.yml

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

64 changes: 63 additions & 1 deletion pkg/campaign/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,64 @@ import (

var orchestratorLog = logger.New("campaign:orchestrator")

// extractFileGlobPattern extracts the file glob pattern from memory-paths or
// metrics-glob configuration. This pattern is used for the file-glob filter in
// repo-memory configuration to match files that the agent creates.
//
// For campaigns that use dated directory patterns (e.g., campaign-id-*/), this
// function preserves the wildcard pattern instead of using just the campaign ID.
//
// Examples:
// - memory-paths: ["memory/campaigns/project64-*/**"] -> "project64-*/**"
// - metrics-glob: "memory/campaigns/project64-*/metrics/*.json" -> "project64-*/**"
// - no patterns with wildcards -> "project64/**" (fallback to ID)
func extractFileGlobPattern(spec *CampaignSpec) string {
// Try to extract pattern from memory-paths first
// Prefer patterns with wildcards in the directory name (e.g., campaign-id-*)
var firstValidPattern string
for _, memPath := range spec.MemoryPaths {
// Remove "memory/campaigns/" prefix if present
pattern := strings.TrimPrefix(memPath, "memory/campaigns/")
// If pattern has both wildcards and slashes, it's a valid pattern
if strings.Contains(pattern, "*") && strings.Contains(pattern, "/") {
// Check if wildcard is in the directory name (not just in **)
if strings.Contains(strings.Split(pattern, "/")[0], "*") {
// This pattern has a wildcard in the directory name - prefer it
orchestratorLog.Printf("Extracted file-glob pattern from memory-paths (with wildcard): %s", pattern)
return pattern
}
// Save this as a fallback valid pattern
if firstValidPattern == "" {
firstValidPattern = pattern
}
}
}

// If we found a valid pattern (even without directory wildcard), use it
if firstValidPattern != "" {
orchestratorLog.Printf("Extracted file-glob pattern from memory-paths: %s", firstValidPattern)
return firstValidPattern
}

// Try to extract pattern from metrics-glob
if spec.MetricsGlob != "" {
pattern := strings.TrimPrefix(spec.MetricsGlob, "memory/campaigns/")
if strings.Contains(pattern, "*") {
// Extract the base directory pattern (everything before /metrics/ or first file-specific part)
if idx := strings.Index(pattern, "/metrics/"); idx > 0 {
basePattern := pattern[:idx] + "/**"
orchestratorLog.Printf("Extracted file-glob pattern from metrics-glob: %s", basePattern)
return basePattern
}
}
}

// Fallback to simple ID-based pattern
fallbackPattern := fmt.Sprintf("%s/**", spec.ID)
orchestratorLog.Printf("Using fallback file-glob pattern: %s", fallbackPattern)
return fallbackPattern
}

// BuildOrchestrator constructs a minimal agentic workflow representation for a
// given CampaignSpec. The resulting WorkflowData is compiled via the standard
// CompileWorkflowDataWithValidation pipeline, and the orchestratorPath
Expand Down Expand Up @@ -201,6 +259,10 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W

orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with safe outputs enabled", spec.ID)

// Extract file-glob pattern from memory-paths or metrics-glob to support
// dated campaign directory patterns like "campaign-id-*/**"
fileGlobPattern := extractFileGlobPattern(spec)

data := &workflow.WorkflowData{
Name: name,
Description: description,
Expand All @@ -221,7 +283,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
map[string]any{
"id": "campaigns",
"branch-name": "memory/campaigns",
"file-glob": []any{fmt.Sprintf("%s/**", spec.ID)},
"file-glob": []any{fileGlobPattern},
},
},
"bash": []any{"*"},
Expand Down
131 changes: 131 additions & 0 deletions pkg/campaign/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,134 @@ func TestBuildOrchestrator_GovernanceOverridesSafeOutputMaxima(t *testing.T) {
t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4)
}
}

func TestExtractFileGlobPattern(t *testing.T) {
tests := []struct {
name string
spec *CampaignSpec
expectedGlob string
expectedLogMsg string
}{
{
name: "dated pattern in memory-paths",
spec: &CampaignSpec{
ID: "go-file-size-reduction-project64",
MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64-*/**"},
MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json",
},
expectedGlob: "go-file-size-reduction-project64-*/**",
expectedLogMsg: "Extracted file-glob pattern from memory-paths",
},
{
name: "dated pattern in metrics-glob only",
spec: &CampaignSpec{
ID: "go-file-size-reduction-project64",
MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json",
},
expectedGlob: "go-file-size-reduction-project64-*/**",
expectedLogMsg: "Extracted file-glob pattern from metrics-glob",
},
{
name: "simple pattern without wildcards",
spec: &CampaignSpec{
ID: "simple-campaign",
MemoryPaths: []string{"memory/campaigns/simple-campaign/**"},
},
expectedGlob: "simple-campaign/**",
expectedLogMsg: "Using fallback file-glob pattern",
},
{
name: "no memory paths or metrics glob",
spec: &CampaignSpec{
ID: "minimal-campaign",
},
expectedGlob: "minimal-campaign/**",
expectedLogMsg: "Using fallback file-glob pattern",
},
{
name: "multiple memory paths with wildcard",
spec: &CampaignSpec{
ID: "multi-path",
MemoryPaths: []string{
"memory/campaigns/multi-path-staging/**",
"memory/campaigns/multi-path-*/data/**",
},
},
expectedGlob: "multi-path-*/data/**",
expectedLogMsg: "Extracted file-glob pattern from memory-paths",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractFileGlobPattern(tt.spec)
if result != tt.expectedGlob {
t.Errorf("extractFileGlobPattern(%q) = %q, want %q", tt.spec.ID, result, tt.expectedGlob)
}
})
}
}

func TestBuildOrchestrator_FileGlobMatchesMemoryPaths(t *testing.T) {
// This test verifies that the file-glob pattern in repo-memory configuration
// matches the pattern defined in memory-paths, including wildcards
spec := &CampaignSpec{
ID: "go-file-size-reduction-project64",
Name: "Go File Size Reduction Campaign",
Description: "Test campaign with dated memory paths",
ProjectURL: "https://github.com/orgs/githubnext/projects/64",
Workflows: []string{"daily-file-diet"},
MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64-*/**"},
MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json",
TrackerLabel: "campaign:go-file-size-reduction-project64",
}

mdPath := ".github/workflows/go-file-size-reduction-project64.campaign.md"
data, _ := BuildOrchestrator(spec, mdPath)

if data == nil {
t.Fatalf("expected non-nil WorkflowData")
}

// Extract repo-memory configuration from Tools
repoMemoryConfig, ok := data.Tools["repo-memory"]
if !ok {
t.Fatalf("expected repo-memory to be configured in Tools")
}

repoMemoryArray, ok := repoMemoryConfig.([]any)
if !ok || len(repoMemoryArray) == 0 {
t.Fatalf("expected repo-memory to be an array with at least one entry")
}

repoMemoryEntry, ok := repoMemoryArray[0].(map[string]any)
if !ok {
t.Fatalf("expected repo-memory entry to be a map")
}

fileGlob, ok := repoMemoryEntry["file-glob"]
if !ok {
t.Fatalf("expected file-glob to be present in repo-memory entry")
}

fileGlobArray, ok := fileGlob.([]any)
if !ok || len(fileGlobArray) == 0 {
t.Fatalf("expected file-glob to be an array with at least one entry")
}

fileGlobPattern, ok := fileGlobArray[0].(string)
if !ok {
t.Fatalf("expected file-glob pattern to be a string")
}

// Verify that the file-glob pattern includes the wildcard for dated directories
expectedPattern := "go-file-size-reduction-project64-*/**"
if fileGlobPattern != expectedPattern {
t.Errorf("file-glob pattern = %q, want %q", fileGlobPattern, expectedPattern)
}

// Verify that the pattern would match dated directories
if !strings.Contains(fileGlobPattern, "*") {
t.Errorf("file-glob pattern should include wildcard for dated directories, got %q", fileGlobPattern)
}
}
Loading