Skip to content

Commit 7cf926b

Browse files
authored
Add project field campaign orchestration support to workflows (#12053)
1 parent 9526e1d commit 7cf926b

10 files changed

Lines changed: 2324 additions & 14 deletions

File tree

.github/workflows/security-alert-burndown.lock.yml

Lines changed: 1673 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
name: Security Alert Burndown
3+
description: Discovers Dependabot PRs and assigns them to Copilot for review
4+
on:
5+
schedule:
6+
- cron: "0 * * * *"
7+
workflow_dispatch:
8+
project:
9+
url: https://github.com/orgs/githubnext/projects/134
10+
scope:
11+
- githubnext/gh-aw
12+
id: security-alert-burndown
13+
governance:
14+
max-new-items-per-run: 3
15+
max-discovery-items-per-run: 100
16+
max-discovery-pages-per-run: 5
17+
max-project-updates-per-run: 10
18+
---
19+
20+
# Security Alert Burndown Campaign
21+
22+
This campaign discovers Dependabot-created pull requests for JavaScript dependencies and assigns them to the Copilot coding agent for automated review and merging.
23+
24+
## Objective
25+
26+
Systematically process Dependabot dependency update PRs to keep JavaScript dependencies up-to-date and secure.
27+
28+
## Discovery Strategy
29+
30+
The orchestrator will:
31+
32+
1. **Discover** pull requests opened by the `dependabot` bot
33+
2. **Filter** to PRs with labels `dependencies` and `javascript`
34+
3. **Assign** discovered PRs to the Copilot coding agent using `assign-to-agent`
35+
4. **Track** progress in the project board
36+
37+
## Campaign Execution
38+
39+
Each run:
40+
- Discovers up to 100 Dependabot PRs with specified labels
41+
- Processes up to 5 pages of results
42+
- Assigns up to 3 new items to Copilot
43+
- Updates project board with up to 10 status changes
44+
45+
## Success Criteria
46+
47+
- JavaScript dependency PRs are automatically triaged
48+
- Copilot agent reviews and processes assigned PRs
49+
- Project board reflects current state of dependency updates

pkg/campaign/injection.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package campaign
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/githubnext/gh-aw/pkg/logger"
8+
"github.com/githubnext/gh-aw/pkg/workflow"
9+
)
10+
11+
var injectionLog = logger.New("campaign:injection")
12+
13+
// InjectOrchestratorFeatures detects if a workflow has project field with campaign
14+
// configuration and injects orchestrator features directly into the workflow during compilation.
15+
// This transforms the workflow into a campaign orchestrator without generating separate files.
16+
func InjectOrchestratorFeatures(workflowData *workflow.WorkflowData) error {
17+
injectionLog.Print("Checking workflow for campaign orchestrator features")
18+
19+
// Check if this workflow has project configuration with campaign fields
20+
if workflowData.ParsedFrontmatter == nil || workflowData.ParsedFrontmatter.Project == nil {
21+
injectionLog.Print("No project field detected, skipping campaign injection")
22+
return nil
23+
}
24+
25+
project := workflowData.ParsedFrontmatter.Project
26+
27+
// Check if project has any campaign orchestration fields to determine if this is a campaign
28+
// Campaign indicators (any of these present means it's a campaign orchestrator):
29+
// - workflows list (predefined workers)
30+
// - governance policies (campaign-specific constraints)
31+
// - bootstrap configuration (initial work item generation)
32+
// - memory-paths, metrics-glob, cursor-glob (campaign state tracking)
33+
// If only URL and scope are present, it's simple project tracking, not a campaign
34+
isCampaign := len(project.Workflows) > 0 ||
35+
project.Governance != nil ||
36+
project.Bootstrap != nil ||
37+
len(project.MemoryPaths) > 0 ||
38+
project.MetricsGlob != "" ||
39+
project.CursorGlob != ""
40+
41+
if !isCampaign {
42+
injectionLog.Print("Project field present but no campaign indicators, treating as simple project tracking")
43+
return nil
44+
}
45+
46+
injectionLog.Printf("Detected campaign orchestrator: workflows=%d, has_governance=%v, has_bootstrap=%v",
47+
len(project.Workflows), project.Governance != nil, project.Bootstrap != nil)
48+
49+
// Derive campaign ID from workflow name or use explicit ID
50+
campaignID := workflowData.FrontmatterName
51+
if project.ID != "" {
52+
campaignID = project.ID
53+
}
54+
55+
// Build campaign prompt data from project configuration
56+
promptData := CampaignPromptData{
57+
CampaignID: campaignID,
58+
CampaignName: workflowData.Name,
59+
ProjectURL: project.URL,
60+
CursorGlob: project.CursorGlob,
61+
MetricsGlob: project.MetricsGlob,
62+
Workflows: project.Workflows,
63+
}
64+
65+
if project.Governance != nil {
66+
promptData.MaxDiscoveryItemsPerRun = project.Governance.MaxDiscoveryItemsPerRun
67+
promptData.MaxDiscoveryPagesPerRun = project.Governance.MaxDiscoveryPagesPerRun
68+
promptData.MaxProjectUpdatesPerRun = project.Governance.MaxProjectUpdatesPerRun
69+
promptData.MaxProjectCommentsPerRun = project.Governance.MaxCommentsPerRun
70+
}
71+
72+
if project.Bootstrap != nil {
73+
promptData.BootstrapMode = project.Bootstrap.Mode
74+
if project.Bootstrap.SeederWorker != nil {
75+
promptData.SeederWorkerID = project.Bootstrap.SeederWorker.WorkflowID
76+
promptData.SeederMaxItems = project.Bootstrap.SeederWorker.MaxItems
77+
}
78+
if project.Bootstrap.ProjectTodos != nil {
79+
promptData.StatusField = project.Bootstrap.ProjectTodos.StatusField
80+
if promptData.StatusField == "" {
81+
promptData.StatusField = "Status"
82+
}
83+
promptData.TodoValue = project.Bootstrap.ProjectTodos.TodoValue
84+
if promptData.TodoValue == "" {
85+
promptData.TodoValue = "Todo"
86+
}
87+
promptData.TodoMaxItems = project.Bootstrap.ProjectTodos.MaxItems
88+
promptData.RequireFields = project.Bootstrap.ProjectTodos.RequireFields
89+
}
90+
}
91+
92+
if len(project.Workers) > 0 {
93+
promptData.WorkerMetadata = make([]WorkerMetadata, len(project.Workers))
94+
for i, w := range project.Workers {
95+
promptData.WorkerMetadata[i] = WorkerMetadata{
96+
ID: w.ID,
97+
Name: w.Name,
98+
Description: w.Description,
99+
Capabilities: w.Capabilities,
100+
IdempotencyStrategy: w.IdempotencyStrategy,
101+
Priority: w.Priority,
102+
}
103+
// Convert payload schema
104+
if len(w.PayloadSchema) > 0 {
105+
promptData.WorkerMetadata[i].PayloadSchema = make(map[string]WorkerPayloadField)
106+
for key, field := range w.PayloadSchema {
107+
promptData.WorkerMetadata[i].PayloadSchema[key] = WorkerPayloadField{
108+
Type: field.Type,
109+
Description: field.Description,
110+
Required: field.Required,
111+
Example: field.Example,
112+
}
113+
}
114+
}
115+
// Convert output labeling
116+
promptData.WorkerMetadata[i].OutputLabeling = WorkerOutputLabeling{
117+
Labels: w.OutputLabeling.Labels,
118+
KeyInTitle: w.OutputLabeling.KeyInTitle,
119+
KeyFormat: w.OutputLabeling.KeyFormat,
120+
MetadataFields: w.OutputLabeling.MetadataFields,
121+
}
122+
}
123+
}
124+
125+
// Append orchestrator instructions to markdown content
126+
markdownBuilder := &strings.Builder{}
127+
markdownBuilder.WriteString(workflowData.MarkdownContent)
128+
markdownBuilder.WriteString("\n\n")
129+
130+
// Add bootstrap instructions if configured
131+
if project.Bootstrap != nil && project.Bootstrap.Mode != "" {
132+
bootstrapInstructions := RenderBootstrapInstructions(promptData)
133+
if bootstrapInstructions != "" {
134+
AppendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions)
135+
}
136+
}
137+
138+
// Add workflow execution instructions
139+
workflowExecution := RenderWorkflowExecution(promptData)
140+
if workflowExecution != "" {
141+
AppendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution)
142+
}
143+
144+
// Add orchestrator instructions
145+
orchestratorInstructions := RenderOrchestratorInstructions(promptData)
146+
if orchestratorInstructions != "" {
147+
AppendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions)
148+
}
149+
150+
// Add project update instructions
151+
projectInstructions := RenderProjectUpdateInstructions(promptData)
152+
if projectInstructions != "" {
153+
AppendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions)
154+
}
155+
156+
// Add closing instructions
157+
closingInstructions := RenderClosingInstructions()
158+
if closingInstructions != "" {
159+
AppendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions)
160+
}
161+
162+
// Update the workflow markdown content with injected instructions
163+
workflowData.MarkdownContent = markdownBuilder.String()
164+
injectionLog.Printf("Injected campaign orchestrator instructions into workflow markdown")
165+
166+
// Configure safe-outputs for campaign orchestration
167+
if workflowData.SafeOutputs == nil {
168+
workflowData.SafeOutputs = &workflow.SafeOutputsConfig{}
169+
}
170+
171+
// Configure dispatch-workflow for worker coordination (optional - only if workflows are specified)
172+
if len(project.Workflows) > 0 && workflowData.SafeOutputs.DispatchWorkflow == nil {
173+
workflowData.SafeOutputs.DispatchWorkflow = &workflow.DispatchWorkflowConfig{
174+
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3},
175+
Workflows: project.Workflows,
176+
}
177+
injectionLog.Printf("Configured dispatch-workflow safe-output for %d workflows", len(project.Workflows))
178+
} else if len(project.Workflows) == 0 {
179+
injectionLog.Print("No workflows specified - campaign will use custom discovery and dispatch logic")
180+
}
181+
182+
// Configure update-project (already handled by applyProjectSafeOutputs, but ensure governance max is applied)
183+
if project.Governance != nil && project.Governance.MaxProjectUpdatesPerRun > 0 {
184+
if workflowData.SafeOutputs.UpdateProjects != nil {
185+
workflowData.SafeOutputs.UpdateProjects.Max = project.Governance.MaxProjectUpdatesPerRun
186+
injectionLog.Printf("Applied governance max-project-updates-per-run: %d", project.Governance.MaxProjectUpdatesPerRun)
187+
}
188+
}
189+
190+
// Add concurrency control for campaigns if not already set
191+
if workflowData.Concurrency == "" {
192+
workflowData.Concurrency = fmt.Sprintf("concurrency:\n group: \"campaign-%s-orchestrator-${{ github.ref }}\"\n cancel-in-progress: false", campaignID)
193+
injectionLog.Printf("Added campaign concurrency control")
194+
}
195+
196+
injectionLog.Printf("Successfully injected campaign orchestrator features for: %s", campaignID)
197+
return nil
198+
}

pkg/campaign/orchestrator.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
339339
if bootstrapInstructions == "" {
340340
orchestratorLog.Print("Warning: Failed to render bootstrap instructions, template may be missing")
341341
} else {
342-
appendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions)
342+
AppendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions)
343343
orchestratorLog.Printf("Campaign '%s' orchestrator includes bootstrap mode: %s", spec.ID, spec.Bootstrap.Mode)
344344
}
345345
}
@@ -350,29 +350,29 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
350350
if workflowExecution == "" {
351351
orchestratorLog.Print("Warning: Failed to render workflow execution instructions, template may be missing")
352352
} else {
353-
appendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution)
353+
AppendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution)
354354
orchestratorLog.Printf("Campaign '%s' orchestrator includes workflow execution", spec.ID)
355355
}
356356

357357
orchestratorInstructions := RenderOrchestratorInstructions(promptData)
358358
if orchestratorInstructions == "" {
359359
orchestratorLog.Print("Warning: Failed to render orchestrator instructions, template may be missing")
360360
} else {
361-
appendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions)
361+
AppendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions)
362362
}
363363

364364
projectInstructions := RenderProjectUpdateInstructions(promptData)
365365
if projectInstructions == "" {
366366
orchestratorLog.Print("Warning: Failed to render project update instructions, template may be missing")
367367
} else {
368-
appendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions)
368+
AppendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions)
369369
}
370370

371371
closingInstructions := RenderClosingInstructions()
372372
if closingInstructions == "" {
373373
orchestratorLog.Print("Warning: Failed to render closing instructions, template may be missing")
374374
} else {
375-
appendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions)
375+
AppendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions)
376376
}
377377

378378
// Campaign orchestrators can dispatch workflows and perform limited Project operations.

pkg/campaign/prompt_sections.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"strings"
66
)
77

8-
func appendPromptSection(b *strings.Builder, title, body string) {
8+
// AppendPromptSection appends a section with a header and body to the builder.
9+
// This is used to structure campaign orchestrator prompts with clear section boundaries.
10+
func AppendPromptSection(b *strings.Builder, title, body string) {
911
body = strings.TrimSpace(body)
1012
if body == "" {
1113
return

pkg/cli/compile_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func compileSingleFile(compiler *workflow.Compiler, file string, stats *Compilat
122122
}
123123

124124
// Regular workflow file - compile normally
125-
compileHelpersLog.Printf("Compiling: %s", file)
125+
compileHelpersLog.Printf("Compiling as regular workflow: %s", file)
126126
if verbose {
127127
fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Compiling: %s", file)))
128128
}

pkg/cli/compile_workflow_processor.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,21 @@ func compileWorkflowFile(
137137
}
138138
result.workflowData = workflowData
139139

140+
// Inject campaign orchestrator features if project field has campaign configuration
141+
// This transforms the workflow into a campaign orchestrator in-place
142+
if err := campaign.InjectOrchestratorFeatures(workflowData); err != nil {
143+
errMsg := fmt.Sprintf("failed to inject campaign orchestrator features: %v", err)
144+
if !jsonOutput {
145+
fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg))
146+
}
147+
result.validationResult.Valid = false
148+
result.validationResult.Errors = append(result.validationResult.Errors, CompileValidationError{
149+
Type: "campaign_injection_error",
150+
Message: err.Error(),
151+
})
152+
return result
153+
}
154+
140155
compileWorkflowProcessorLog.Printf("Starting compilation of %s", resolvedFile)
141156

142157
// Compile the workflow

0 commit comments

Comments
 (0)