Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Add markdown fallback for campaign project URLs
- Frontmatter 'project' field is now the source of truth
- If not in frontmatter, fall back to searching markdown body
- Add tests for markdown fallback functionality
- Update error messages to mention both options

Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com>
  • Loading branch information
Copilot and mnkiefer committed Jan 30, 2026
commit 3ca85314c18772a8a2e8bea608f4d01025401348
75 changes: 52 additions & 23 deletions pkg/workflow/campaign_project_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ import (
var campaignProjectValidationLog = logger.New("workflow:campaign_project_validation")

// validateCampaignProject checks if a workflow with campaign characteristics has a project URL configured
func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error {
// The project URL can be specified in two places with the following precedence:
// 1. Frontmatter 'project' field (source of truth)
// 2. Markdown body content (fallback)
func (c *Compiler) validateCampaignProject(frontmatter map[string]any, markdownContent string) error {
campaignProjectValidationLog.Print("Checking campaign project requirements")

// Check if this workflow has campaign characteristics
Expand All @@ -53,33 +56,42 @@ func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error {

campaignProjectValidationLog.Printf("Detected campaign workflow via %s", campaignSource)

// Check if project field exists
// Check if project field exists in frontmatter (source of truth)
projectData, hasProject := frontmatter["project"]
if !hasProject || projectData == nil {
return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123). Campaign detected via: %s", campaignSource)
if hasProject && projectData != nil {
campaignProjectValidationLog.Print("Project field found in frontmatter (source of truth)")
// Validate frontmatter project field is not empty
switch v := projectData.(type) {
case string:
if strings.TrimSpace(v) == "" {
return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource)
}
campaignProjectValidationLog.Printf("Valid project URL found in frontmatter: %s", v)
case map[string]any:
// Check if object has a URL field
if url, hasURL := v["url"]; !hasURL || url == nil {
return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource)
} else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" {
return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource)
}
campaignProjectValidationLog.Print("Valid project configuration object found in frontmatter")
default:
return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource)
}
campaignProjectValidationLog.Print("Campaign project validation passed (frontmatter)")
return nil
}

// Validate project field is not empty
switch v := projectData.(type) {
case string:
if strings.TrimSpace(v) == "" {
return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource)
}
campaignProjectValidationLog.Printf("Valid project URL found: %s", v)
case map[string]any:
// Check if object has a URL field
if url, hasURL := v["url"]; !hasURL || url == nil {
return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource)
} else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" {
return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource)
}
campaignProjectValidationLog.Print("Valid project configuration object found")
default:
return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource)
// Fallback: Look for project URL in markdown content
campaignProjectValidationLog.Print("No project field in frontmatter, checking markdown content for project URL")
if hasProjectURLInMarkdown(markdownContent) {
campaignProjectValidationLog.Print("Valid project URL found in markdown content (fallback)")
campaignProjectValidationLog.Print("Campaign project validation passed (markdown fallback)")
return nil
}

campaignProjectValidationLog.Print("Campaign project validation passed")
return nil
// No project URL found in either frontmatter or markdown
return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123), or include a project URL in the markdown body. Campaign detected via: %s", campaignSource)
}

// detectCampaignWorkflow checks if a workflow has campaign characteristics
Expand All @@ -98,6 +110,23 @@ func detectCampaignWorkflow(frontmatter map[string]any) (bool, string) {
return false, ""
}

// hasProjectURLInMarkdown checks if the markdown content contains a GitHub Project URL
// This serves as a fallback when the project field is not in the frontmatter
func hasProjectURLInMarkdown(markdownContent string) bool {
// Use a simple string search for performance
// Check for the distinctive pattern of GitHub Project URLs
// Matches: https://github.com/orgs/{org}/projects/{number}
// or: https://github.com/users/{user}/projects/{number}
if strings.Contains(markdownContent, "https://github.com/orgs/") && strings.Contains(markdownContent, "/projects/") {
return true
}
if strings.Contains(markdownContent, "https://github.com/users/") && strings.Contains(markdownContent, "/projects/") {
return true
}

return false
}

// hasCampaignLabels checks if safe-outputs configuration includes campaign labels
func hasCampaignLabels(frontmatter map[string]any) bool {
safeOutputs, ok := frontmatter["safe-outputs"].(map[string]any)
Expand Down
162 changes: 138 additions & 24 deletions pkg/workflow/campaign_project_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (

func TestValidateCampaignProject(t *testing.T) {
tests := []struct {
name string
frontmatter map[string]any
expectError bool
errorMsg string
name string
frontmatter map[string]any
markdownContent string
expectError bool
errorMsg string
}{
{
name: "campaign with agentic-campaign label and project URL - valid",
Expand All @@ -27,7 +28,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "https://github.com/orgs/test/projects/123",
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with z_campaign_ label and project URL - valid",
Expand All @@ -39,7 +41,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "https://github.com/orgs/test/projects/456",
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with campaign-id in repo-memory and project URL - valid",
Expand All @@ -51,7 +54,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "https://github.com/orgs/test/projects/789",
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with agentic-campaign label but no project - error",
Expand All @@ -62,8 +66,9 @@ func TestValidateCampaignProject(t *testing.T) {
},
},
},
expectError: true,
errorMsg: "campaign orchestrator requires a GitHub Project URL",
markdownContent: "",
expectError: true,
errorMsg: "campaign orchestrator requires a GitHub Project URL",
},
{
name: "campaign with z_campaign_ label but empty project URL - error",
Expand All @@ -75,8 +80,9 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "",
},
expectError: true,
errorMsg: "requires a non-empty GitHub Project URL",
markdownContent: "",
expectError: true,
errorMsg: "requires a non-empty GitHub Project URL",
},
{
name: "campaign with campaign-id but nil project - error",
Expand All @@ -88,8 +94,9 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": nil,
},
expectError: true,
errorMsg: "campaign orchestrator requires a GitHub Project URL",
markdownContent: "",
expectError: true,
errorMsg: "campaign orchestrator requires a GitHub Project URL",
},
{
name: "campaign with project config object - valid",
Expand All @@ -104,7 +111,8 @@ func TestValidateCampaignProject(t *testing.T) {
"max-updates": 100,
},
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with project config but missing URL - error",
Expand All @@ -120,8 +128,9 @@ func TestValidateCampaignProject(t *testing.T) {
"max-updates": 100,
},
},
expectError: true,
errorMsg: "project configuration must include a 'url' field",
markdownContent: "",
expectError: true,
errorMsg: "project configuration must include a 'url' field",
},
{
name: "non-campaign workflow without project - valid",
Expand All @@ -132,7 +141,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
},
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "workflow with regular labels - valid",
Expand All @@ -143,7 +153,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
},
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with multiple repo-memory entries - valid",
Expand All @@ -160,7 +171,8 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "https://github.com/orgs/test/projects/999",
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign via create-discussion labels - valid",
Expand All @@ -172,14 +184,65 @@ func TestValidateCampaignProject(t *testing.T) {
},
"project": "https://github.com/orgs/test/projects/111",
},
expectError: false,
markdownContent: "",
expectError: false,
},
{
name: "campaign with project URL in markdown (orgs) - valid fallback",
frontmatter: map[string]any{
"safe-outputs": map[string]any{
"add-labels": map[string]any{
"allowed": []any{"agentic-campaign"},
},
},
},
markdownContent: "Track progress at https://github.com/orgs/myorg/projects/144",
expectError: false,
},
{
name: "campaign with project URL in markdown (users) - valid fallback",
frontmatter: map[string]any{
"tools": map[string]any{
"repo-memory": map[string]any{
"campaign-id": "test",
},
},
},
markdownContent: "See https://github.com/users/myuser/projects/42 for details",
expectError: false,
},
{
name: "campaign with frontmatter project (source of truth) even if markdown has URL",
frontmatter: map[string]any{
"safe-outputs": map[string]any{
"add-labels": map[string]any{
"allowed": []any{"agentic-campaign"},
},
},
"project": "https://github.com/orgs/correct/projects/1",
},
markdownContent: "Old URL: https://github.com/orgs/wrong/projects/999",
expectError: false,
},
{
name: "campaign without project in frontmatter or markdown - error",
frontmatter: map[string]any{
"safe-outputs": map[string]any{
"add-labels": map[string]any{
"allowed": []any{"agentic-campaign"},
},
},
},
markdownContent: "No project URL here",
expectError: true,
errorMsg: "campaign orchestrator requires a GitHub Project URL",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompiler()
err := compiler.validateCampaignProject(tt.frontmatter)
err := compiler.validateCampaignProject(tt.frontmatter, tt.markdownContent)

if tt.expectError {
require.Error(t, err, "Expected error but got none")
Expand All @@ -191,6 +254,57 @@ func TestValidateCampaignProject(t *testing.T) {
}
}

func TestHasProjectURLInMarkdown(t *testing.T) {
tests := []struct {
name string
markdown string
expected bool
}{
{
name: "project URL with orgs",
markdown: "Track at https://github.com/orgs/myorg/projects/123",
expected: true,
},
{
name: "project URL with users",
markdown: "See https://github.com/users/john/projects/42",
expected: true,
},
{
name: "no project URL",
markdown: "This is a regular workflow without project tracking",
expected: false,
},
{
name: "github URL but not a project",
markdown: "See https://github.com/owner/repo",
expected: false,
},
{
name: "partial match - has orgs but no projects",
markdown: "Visit https://github.com/orgs/myorg for more info",
expected: false,
},
{
name: "partial match - has projects but no orgs",
markdown: "Check out our projects folder",
expected: false,
},
{
name: "multiple project URLs",
markdown: "Old: https://github.com/orgs/old/projects/1\nNew: https://github.com/orgs/new/projects/2",
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasProjectURLInMarkdown(tt.markdown)
assert.Equal(t, tt.expected, result, "Expected %v for markdown: %s", tt.expected, tt.markdown)
})
}
}

func TestDetectCampaignWorkflow(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -487,13 +601,13 @@ func TestCampaignValidationIntegration(t *testing.T) {
}

compiler := NewCompiler()
err := compiler.validateCampaignProject(frontmatter)
err := compiler.validateCampaignProject(frontmatter, "")
require.Error(t, err, "Should fail validation without project URL")
assert.Contains(t, err.Error(), "campaign orchestrator requires a GitHub Project URL")

// Add project URL and verify it passes
frontmatter["project"] = "https://github.com/orgs/test/projects/144"
err = compiler.validateCampaignProject(frontmatter)
err = compiler.validateCampaignProject(frontmatter, "")
assert.NoError(t, err, "Should pass validation with project URL")
}

Expand Down Expand Up @@ -538,7 +652,7 @@ func TestCampaignValidationErrorMessages(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompiler()
err := compiler.validateCampaignProject(tt.frontmatter)
err := compiler.validateCampaignProject(tt.frontmatter, "")
require.Error(t, err, "Should fail validation")

errMsg := err.Error()
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_orchestrator_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean

// Validate campaign orchestrator project requirements (applies to all workflows, not just strict mode)
orchestratorEngineLog.Print("Validating campaign orchestrator project requirements")
if err := c.validateCampaignProject(result.Frontmatter); err != nil {
if err := c.validateCampaignProject(result.Frontmatter, result.Markdown); err != nil {
orchestratorEngineLog.Printf("Campaign project validation failed: %v", err)
return nil, err
}
Expand Down