Skip to content

Commit b092a4d

Browse files
committed
feat(campaign): add orchestrator workflow generation
- Implemented BuildOrchestrator function to create a minimal agentic workflow representation for CampaignSpec. - Added orchestrator markdown path generation to avoid collisions with existing workflows. - Included basic metadata such as name, description, and associated workflows in the generated workflow. - Created unit tests for BuildOrchestrator to ensure correct behavior and output. fix(cli): handle campaign spec files in compile command - Updated CompileWorkflows to validate campaign spec files and optionally synthesize orchestrator workflows. - Ensured that campaign spec files are skipped during watch mode to prevent unexpected behavior. chore(cli): add cobra import for command line functionality
1 parent 10c2185 commit b092a4d

File tree

8 files changed

+9808
-4
lines changed

8 files changed

+9808
-4
lines changed

.github/workflows/code-health-file-diet-campaign.lock.yml

Lines changed: 2405 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/incident-response-campaign.lock.yml

Lines changed: 2403 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/org-modernization-campaign.lock.yml

Lines changed: 2405 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/security-compliance-campaign.lock.yml

Lines changed: 2405 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/campaign/orchestrator.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package campaign
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/githubnext/gh-aw/pkg/workflow"
8+
)
9+
10+
// BuildOrchestrator constructs a minimal agentic workflow representation for a
11+
// given CampaignSpec. The resulting WorkflowData is compiled via the standard
12+
// CompileWorkflowDataWithValidation pipeline, and the orchestratorPath
13+
// determines the emitted .lock.yml name.
14+
func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.WorkflowData, string) {
15+
// Derive orchestrator markdown path alongside the campaign spec, using a
16+
// distinct suffix to avoid colliding with existing workflows.
17+
base := strings.TrimSuffix(campaignFilePath, ".campaign.md")
18+
orchestratorPath := base + "-campaign.md"
19+
20+
name := spec.Name
21+
if strings.TrimSpace(name) == "" {
22+
name = fmt.Sprintf("Campaign: %s", spec.ID)
23+
}
24+
25+
description := spec.Description
26+
if strings.TrimSpace(description) == "" {
27+
description = fmt.Sprintf("Orchestrator workflow for campaign '%s' (tracker: %s)", spec.ID, spec.TrackerLabel)
28+
}
29+
30+
// Minimal on: section - workflow_dispatch trigger with no inputs.
31+
onSection := "on:\n workflow_dispatch:\n"
32+
33+
// Simple markdown body giving the agent context about the campaign.
34+
markdownBuilder := &strings.Builder{}
35+
markdownBuilder.WriteString("# Campaign Orchestrator\n\n")
36+
markdownBuilder.WriteString(fmt.Sprintf("This workflow orchestrates the '%s' campaign.\n\n", name))
37+
if spec.TrackerLabel != "" {
38+
markdownBuilder.WriteString(fmt.Sprintf("- Tracker label: `%s`\n", spec.TrackerLabel))
39+
}
40+
if len(spec.Workflows) > 0 {
41+
markdownBuilder.WriteString("- Associated workflows: ")
42+
markdownBuilder.WriteString(strings.Join(spec.Workflows, ", "))
43+
markdownBuilder.WriteString("\n")
44+
}
45+
if len(spec.MemoryPaths) > 0 {
46+
markdownBuilder.WriteString("- Memory paths: ")
47+
markdownBuilder.WriteString(strings.Join(spec.MemoryPaths, ", "))
48+
markdownBuilder.WriteString("\n")
49+
}
50+
if spec.MetricsGlob != "" {
51+
markdownBuilder.WriteString(fmt.Sprintf("- Metrics glob: `%s`\n", spec.MetricsGlob))
52+
}
53+
markdownBuilder.WriteString("\nUse these details to coordinate workers, update metrics, and track progress for this campaign.\n")
54+
55+
data := &workflow.WorkflowData{
56+
Name: name,
57+
Description: description,
58+
MarkdownContent: markdownBuilder.String(),
59+
On: onSection,
60+
}
61+
62+
return data, orchestratorPath
63+
}

pkg/campaign/orchestrator_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package campaign
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestBuildOrchestrator_BasicShape(t *testing.T) {
9+
spec := &CampaignSpec{
10+
ID: "security-q1-2025",
11+
Name: "Security Q1 2025",
12+
Description: "Security compliance campaign",
13+
Workflows: []string{"security-compliance"},
14+
MemoryPaths: []string{"memory/campaigns/security-q1-2025/**"},
15+
MetricsGlob: "memory/campaigns/security-q1-2025/*.json",
16+
TrackerLabel: "campaign:security-q1-2025",
17+
}
18+
19+
mdPath := ".github/workflows/security-compliance.campaign.md"
20+
data, orchestratorPath := BuildOrchestrator(spec, mdPath)
21+
22+
if orchestratorPath != ".github/workflows/security-compliance-campaign.md" {
23+
t.Fatalf("unexpected orchestrator path: got %q", orchestratorPath)
24+
}
25+
26+
if data == nil {
27+
t.Fatalf("expected non-nil WorkflowData")
28+
}
29+
30+
if data.Name != spec.Name {
31+
t.Fatalf("unexpected workflow name: got %q, want %q", data.Name, spec.Name)
32+
}
33+
34+
if strings.TrimSpace(data.On) == "" || !strings.Contains(data.On, "workflow_dispatch") {
35+
t.Fatalf("expected On section with workflow_dispatch trigger, got %q", data.On)
36+
}
37+
38+
if !strings.Contains(data.MarkdownContent, "Security Q1 2025") {
39+
t.Fatalf("expected markdown content to mention campaign name, got: %q", data.MarkdownContent)
40+
}
41+
42+
if !strings.Contains(data.MarkdownContent, spec.TrackerLabel) {
43+
t.Fatalf("expected markdown content to mention tracker label %q, got: %q", spec.TrackerLabel, data.MarkdownContent)
44+
}
45+
}

pkg/cli/compile_command.go

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
392392
// Handle campaign spec files separately from regular workflows
393393
if strings.HasSuffix(resolvedFile, ".campaign.md") {
394394
// Validate the campaign spec file and referenced workflows instead of
395-
// compiling it as a regular workflow YAML.
395+
// compiling it directly as a regular workflow YAML.
396396
spec, problems, vErr := campaign.ValidateSpecFromFile(resolvedFile)
397397
if vErr != nil {
398398
errMsg := fmt.Sprintf("failed to validate campaign spec %s: %v", resolvedFile, vErr)
@@ -433,7 +433,40 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
433433
errorCount++
434434
stats.Errors++
435435
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(resolvedFile))
436-
} else if verbose && !jsonOutput {
436+
validationResults = append(validationResults, result)
437+
continue
438+
}
439+
440+
// Campaign spec is valid and referenced workflows exist.
441+
// Optionally synthesize an orchestrator workflow for this campaign
442+
// and compile it via the standard workflow pipeline.
443+
if !noEmit {
444+
orchestratorData, orchestratorPath := campaign.BuildOrchestrator(spec, resolvedFile)
445+
lockFile := strings.TrimSuffix(orchestratorPath, ".md") + ".lock.yml"
446+
result.CompiledFile = lockFile
447+
448+
if err := CompileWorkflowDataWithValidation(compiler, orchestratorData, orchestratorPath, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
449+
if !jsonOutput {
450+
fmt.Fprintln(os.Stderr, err.Error())
451+
}
452+
errorMessages = append(errorMessages, err.Error())
453+
errorCount++
454+
stats.Errors++
455+
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(orchestratorPath))
456+
457+
result.Valid = false
458+
result.Errors = append(result.Errors, ValidationError{
459+
Type: "compilation_error",
460+
Message: err.Error(),
461+
})
462+
validationResults = append(validationResults, result)
463+
continue
464+
}
465+
466+
compiledCount++
467+
}
468+
469+
if verbose && !jsonOutput {
437470
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(resolvedFile))))
438471
}
439472

@@ -669,7 +702,7 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
669702
// Handle campaign spec files separately from regular workflows
670703
if strings.HasSuffix(file, ".campaign.md") {
671704
// Validate the campaign spec file and referenced workflows instead of
672-
// compiling it as a regular workflow YAML.
705+
// compiling it directly as a regular workflow YAML.
673706
spec, problems, vErr := campaign.ValidateSpecFromFile(file)
674707
if vErr != nil {
675708
if !jsonOutput {
@@ -707,7 +740,39 @@ func CompileWorkflows(config CompileConfig) ([]*workflow.WorkflowData, error) {
707740
errorCount++
708741
stats.Errors++
709742
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(file))
710-
} else if verbose && !jsonOutput {
743+
validationResults = append(validationResults, result)
744+
continue
745+
}
746+
747+
// Campaign spec is valid and referenced workflows exist.
748+
// Optionally synthesize an orchestrator workflow for this campaign
749+
// and compile it via the standard workflow pipeline.
750+
if !noEmit {
751+
orchestratorData, orchestratorPath := campaign.BuildOrchestrator(spec, file)
752+
lockFile := strings.TrimSuffix(orchestratorPath, ".md") + ".lock.yml"
753+
result.CompiledFile = lockFile
754+
755+
if err := CompileWorkflowDataWithValidation(compiler, orchestratorData, orchestratorPath, verbose && !jsonOutput, zizmor && !noEmit, poutine && !noEmit, actionlint && !noEmit, strict, validate && !noEmit); err != nil {
756+
if !jsonOutput {
757+
fmt.Fprintln(os.Stderr, err.Error())
758+
}
759+
errorCount++
760+
stats.Errors++
761+
stats.FailedWorkflows = append(stats.FailedWorkflows, filepath.Base(orchestratorPath))
762+
763+
result.Valid = false
764+
result.Errors = append(result.Errors, ValidationError{
765+
Type: "compilation_error",
766+
Message: err.Error(),
767+
})
768+
validationResults = append(validationResults, result)
769+
continue
770+
}
771+
772+
successCount++
773+
}
774+
775+
if verbose && !jsonOutput {
711776
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(file))))
712777
}
713778

@@ -1125,6 +1190,18 @@ func compileSingleFile(compiler *workflow.Compiler, file string, stats *Compilat
11251190
}
11261191
}
11271192

1193+
// Skip campaign spec files here; they are validated via the campaign
1194+
// pipeline and are not compiled into GitHub Actions workflows in watch
1195+
// mode. Orchestrators for campaigns are generated only in non-watch
1196+
// compile paths to avoid surprising behavior while editing.
1197+
if strings.HasSuffix(file, ".campaign.md") {
1198+
compileLog.Printf("Skipping campaign spec file (not a workflow): %s", file)
1199+
if verbose {
1200+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping campaign spec (not a workflow): %s", filepath.Base(file))))
1201+
}
1202+
return true
1203+
}
1204+
11281205
stats.Total++
11291206

11301207
compileLog.Printf("Compiling: %s", file)

pkg/cli/tokens_bootstrap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66

77
"github.com/githubnext/gh-aw/pkg/console"
8+
"github.com/spf13/cobra"
89
)
910

1011
// tokenSpec describes a recommended token secret for gh-aw

0 commit comments

Comments
 (0)