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
37 changes: 36 additions & 1 deletion .github/aw/orchestrate-agentic-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,44 @@ and synchronizing campaign state into a GitHub Project board.

1. Workers are immutable and campaign-agnostic
2. The GitHub Project board is the authoritative campaign state
3. Correlation is explicit (tracker-id)
3. Correlation is explicit (tracker-id AND labels)
4. Reads and writes are separate steps (never interleave)
5. Idempotent operation is mandatory (safe to re-run)
6. Only predefined project fields may be updated
7. **Project Update Instructions take precedence for all project writes**
8. **Campaign items MUST be labeled** for discovery and isolation

---

## Campaign Label Requirements

**All campaign-related issues, PRs, and discussions MUST have two labels:**

1. **`agentic-campaign`** - Generic label marking content as part of ANY campaign
- Prevents other workflows from processing campaign items
- Enables campaign-wide queries and filters

2. **`z_campaign_{{.CampaignID}}`** - Campaign-specific label
- Enables precise discovery of items belonging to THIS campaign
- Format: `z_campaign_<campaign-id>` (lowercase, hyphen-separated)
- Example: `z_campaign_security-q1-2025`

**Worker Responsibilities:**
- Workers creating issues/PRs as campaign output MUST add both labels
- Workers SHOULD use `create-issue` or `create-pr` safe outputs with labels configuration
- If workers cannot add labels automatically, campaign orchestrator will attempt to add them during discovery

**Non-Campaign Workflow Responsibilities:**
- Workflows triggered by issues/PRs SHOULD skip items with `agentic-campaign` label
- Use `skip-if-match` configuration to filter out campaign items:
```yaml
on:
issues:
types: [opened, labeled]
skip-if-match:
query: "label:agentic-campaign"
max: 0 # Skip if ANY campaign items match
```

---

Expand Down Expand Up @@ -107,6 +140,8 @@ and synchronizing campaign state into a GitHub Project board.
---
`campaign_id: {{.CampaignID}}`
labels:
- agentic-campaign
- z_campaign_{{.CampaignID}}
- epic
- type:epic
```
Expand Down
41 changes: 38 additions & 3 deletions .github/workflows/security-alert-burndown.campaign.lock.yml

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

74 changes: 64 additions & 10 deletions actions/setup/js/campaign_discovery.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ async function searchByLabel(octokit, label, repos, orgs, maxItems, maxPages, cu
/**
* Discover items from worker cache-memory (reads existing worker cache files)
* Workers use cache-memory to track their outputs; campaign reads these to discover items
*
* DEPRECATED: This method is being phased out in favor of label-based discovery.
* Use label-based discovery via searchByLabel() with "agentic-campaign" and campaign-specific labels instead.
*
* @param {string} campaignId - Campaign identifier (for logging)
* @param {string[]} workflows - List of worker workflow names
* @param {number} maxItems - Maximum items to discover
Expand Down Expand Up @@ -440,23 +444,73 @@ async function discover(config) {
let totalItemsScanned = 0;
let totalPagesScanned = 0;

// Primary discovery: Read from campaign memory (workers' output records)
if (workflows && workflows.length > 0) {
core.info(`Attempting memory-based discovery first...`);
// Generate campaign-specific label
const campaignLabel = `z_campaign_${campaignId.toLowerCase().replace(/[_\s]+/g, "-")}`;

// Primary discovery: Search by campaign-specific label (most reliable)
core.info(`Primary discovery: Searching by campaign-specific label: ${campaignLabel}`);
try {
const labelResult = await searchByLabel(octokit, campaignLabel, repos, orgs, maxDiscoveryItems, maxDiscoveryPages, cursor);
allItems.push(...labelResult.items);
totalItemsScanned += labelResult.itemsScanned;
totalPagesScanned += labelResult.pagesScanned;
cursor = labelResult.cursor;
core.info(`Campaign-specific label discovery found ${labelResult.items.length} item(s)`);
} catch (labelError) {
core.warning(`Campaign-specific label discovery failed: ${labelError instanceof Error ? labelError.message : String(labelError)}`);
}

// Secondary discovery: Search by generic "agentic-campaign" label
if (allItems.length === 0 || totalItemsScanned < maxDiscoveryItems) {
core.info(`Secondary discovery: Searching by generic agentic-campaign label...`);
try {
const remainingItems = maxDiscoveryItems - totalItemsScanned;
const remainingPages = maxDiscoveryPages - totalPagesScanned;

const genericResult = await searchByLabel(octokit, "agentic-campaign", repos, orgs, remainingItems, remainingPages, cursor);

// Filter to only items that match this campaign ID (check body for campaign_id: <id>)
const campaignItems = genericResult.items.filter(item => {
// Check if item body contains campaign_id: <campaignId>
// This requires fetching the full issue/PR data
return true; // For now, include all items with generic label
// TODO: Add filtering by campaign_id in body text
});

// Merge items (deduplicate by URL)
const existingUrls = new Set(allItems.map(i => i.url));
for (const item of campaignItems) {
if (!existingUrls.has(item.url)) {
allItems.push(item);
}
}

totalItemsScanned += genericResult.itemsScanned;
totalPagesScanned += genericResult.pagesScanned;
cursor = genericResult.cursor;
core.info(`Generic label discovery found ${campaignItems.length} item(s)`);
} catch (genericError) {
core.warning(`Generic label discovery failed: ${genericError instanceof Error ? genericError.message : String(genericError)}`);
}
}

// Fallback discovery: Read from worker cache-memory (DEPRECATED)
if (allItems.length === 0 && workflows && workflows.length > 0) {
core.warning(`Label-based discovery found no items. Falling back to DEPRECATED cache-memory discovery...`);
core.warning(`This fallback will be removed in a future version. Please ensure workers are adding campaign labels.`);
try {
const memoryResult = await discoverFromMemory(campaignId, workflows, maxDiscoveryItems);
allItems.push(...memoryResult.items);
totalItemsScanned += memoryResult.itemsScanned;
core.info(`Memory-based discovery found ${memoryResult.items.length} item(s)`);
core.info(`Cache-memory discovery found ${memoryResult.items.length} item(s)`);
} catch (memoryError) {
core.warning(`Memory-based discovery failed: ${memoryError instanceof Error ? memoryError.message : String(memoryError)}`);
core.info(`Falling back to GitHub API search...`);
core.warning(`Cache-memory discovery failed: ${memoryError instanceof Error ? memoryError.message : String(memoryError)}`);
}
}

// Fallback discovery: Search GitHub API by tracker-id (if memory discovery yielded nothing or failed)
// Tertiary fallback: Search GitHub API by tracker-id (if still no items)
if (allItems.length === 0 && workflows && workflows.length > 0) {
core.info(`No items found in memory, searching GitHub API by tracker-id...`);
core.info(`No items found via labels or cache. Searching GitHub API by tracker-id...`);
for (const workflow of workflows) {
if (totalItemsScanned >= maxDiscoveryItems || totalPagesScanned >= maxDiscoveryPages) {
core.warning(`Reached discovery budget limits. Stopping discovery.`);
Expand All @@ -475,8 +529,8 @@ async function discover(config) {
}
}

// Discover by tracker label (if provided)
if (trackerLabel) {
// Legacy discovery by tracker label (if provided and still needed)
if (trackerLabel && (allItems.length === 0 || totalItemsScanned < maxDiscoveryItems)) {
if (totalItemsScanned < maxDiscoveryItems && totalPagesScanned < maxDiscoveryPages) {
const remainingItems = maxDiscoveryItems - totalItemsScanned;
const remainingPages = maxDiscoveryPages - totalPagesScanned;
Expand Down
4 changes: 2 additions & 2 deletions pkg/campaign/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) {
}

// Verify that the orchestrator uses manifest-based discovery (not agent-side search)
if !strings.Contains(data.MarkdownContent, "Correlation is explicit (tracker-id)") {
t.Errorf("expected markdown to mention tracker-id correlation rule, got: %q", data.MarkdownContent)
if !strings.Contains(data.MarkdownContent, "Correlation is explicit (tracker-id AND labels)") {
t.Errorf("expected markdown to mention tracker-id and labels correlation rule, got: %q", data.MarkdownContent)
}
if !strings.Contains(data.MarkdownContent, "Read the precomputed discovery manifest") {
t.Errorf("expected markdown to include manifest-based discovery instructions, got: %q", data.MarkdownContent)
Expand Down
37 changes: 36 additions & 1 deletion pkg/cli/templates/orchestrate-agentic-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,44 @@ and synchronizing campaign state into a GitHub Project board.

1. Workers are immutable and campaign-agnostic
2. The GitHub Project board is the authoritative campaign state
3. Correlation is explicit (tracker-id)
3. Correlation is explicit (tracker-id AND labels)
4. Reads and writes are separate steps (never interleave)
5. Idempotent operation is mandatory (safe to re-run)
6. Only predefined project fields may be updated
7. **Project Update Instructions take precedence for all project writes**
8. **Campaign items MUST be labeled** for discovery and isolation

---

## Campaign Label Requirements

**All campaign-related issues, PRs, and discussions MUST have two labels:**

1. **`agentic-campaign`** - Generic label marking content as part of ANY campaign
- Prevents other workflows from processing campaign items
- Enables campaign-wide queries and filters

2. **`z_campaign_{{.CampaignID}}`** - Campaign-specific label
- Enables precise discovery of items belonging to THIS campaign
- Format: `z_campaign_<campaign-id>` (lowercase, hyphen-separated)
- Example: `z_campaign_security-q1-2025`

**Worker Responsibilities:**
- Workers creating issues/PRs as campaign output MUST add both labels
- Workers SHOULD use `create-issue` or `create-pr` safe outputs with labels configuration
- If workers cannot add labels automatically, campaign orchestrator will attempt to add them during discovery

**Non-Campaign Workflow Responsibilities:**
- Workflows triggered by issues/PRs SHOULD skip items with `agentic-campaign` label
- Use `skip-if-match` configuration to filter out campaign items:
```yaml
on:
issues:
types: [opened, labeled]
skip-if-match:
query: "label:agentic-campaign"
max: 0 # Skip if ANY campaign items match
```

---

Expand Down Expand Up @@ -107,6 +140,8 @@ and synchronizing campaign state into a GitHub Project board.
---
`campaign_id: {{.CampaignID}}`
labels:
- agentic-campaign
- z_campaign_{{.CampaignID}}
- epic
- type:epic
```
Expand Down
10 changes: 10 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ const GitHubCopilotMCPDomain = "api.githubcopilot.com"
// This points to the githubnext "[TEMPLATE: Agentic Campaign]" project (Project 74).
const DefaultCampaignTemplateProjectURL URL = "https://github.com/orgs/githubnext/projects/74"

// AgenticCampaignLabel is the label applied to all campaign-related issues, PRs, and discussions.
// This label marks content as part of an agentic campaign, preventing other workflows from
// processing these items to avoid interference with campaign orchestration.
const AgenticCampaignLabel = "agentic-campaign"

// CampaignLabelPrefix is the prefix used for campaign-specific labels.
// Campaign-specific labels follow the format "z_campaign_<id>" where <id> is the campaign identifier.
// The "z_" prefix ensures these labels sort last in label lists.
const CampaignLabelPrefix = "z_campaign_"

// DefaultClaudeCodeVersion is the default version of the Claude Code CLI.
const DefaultClaudeCodeVersion Version = "2.1.14"

Expand Down
24 changes: 24 additions & 0 deletions pkg/stringutil/identifiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package stringutil
import (
"path/filepath"
"strings"

"github.com/githubnext/gh-aw/pkg/constants"
)

// NormalizeWorkflowName removes .md and .lock.yml extensions from workflow names.
Expand Down Expand Up @@ -216,3 +218,25 @@ func IsLockFile(path string) bool {
func IsCampaignLockFile(path string) bool {
return strings.HasSuffix(path, ".campaign.lock.yml")
}

// FormatCampaignLabel generates a campaign-specific label from a campaign ID.
// Campaign-specific labels follow the format "z_campaign_<id>" where <id> is the campaign identifier.
// The "z_" prefix ensures these labels sort last in label lists for better visibility.
//
// This function sanitizes the campaign ID by replacing invalid label characters (spaces, special chars)
// with hyphens to ensure GitHub label compatibility.
//
// Examples:
//
// FormatCampaignLabel("security-q1-2025") // returns "z_campaign_security-q1-2025"
// FormatCampaignLabel("Security Q1 2025") // returns "z_campaign_security-q1-2025"
// FormatCampaignLabel("dependency_updates") // returns "z_campaign_dependency-updates"
func FormatCampaignLabel(campaignID string) string {
// Sanitize campaign ID for label compatibility
// Replace spaces and underscores with hyphens, convert to lowercase
sanitized := strings.ToLower(campaignID)
sanitized = strings.ReplaceAll(sanitized, " ", "-")
sanitized = strings.ReplaceAll(sanitized, "_", "-")

return constants.CampaignLabelPrefix + sanitized
}
Loading
Loading