From eaa68a1b66a2c6a675858c4a80eef8f8c9e01a3f Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Thu, 22 Jan 2026 20:22:16 +0100 Subject: [PATCH] chore: add campaign label handling and tests --- .../setup/js/safe_output_handler_manager.cjs | 101 +++++++++++++++++- .../js/safe_output_handler_manager.test.cjs | 31 ++++++ pkg/campaign/orchestrator.go | 2 +- pkg/workflow/safe_outputs_env.go | 21 ++++ pkg/workflow/safe_outputs_test.go | 17 +++ 5 files changed, 170 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 6c4e6b1211b..a58b1c87a1a 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -15,6 +15,102 @@ const { hasUnresolvedTemporaryIds, replaceTemporaryIdReferences, normalizeTempor const { generateMissingInfoSections } = require("./missing_info_formatter.cjs"); const { setCollectedMissings } = require("./missing_messages_helper.cjs"); +const DEFAULT_AGENTIC_CAMPAIGN_LABEL = "agentic-campaign"; + +/** + * Normalize campaign IDs to the same label format used by campaign discovery. + * Mirrors actions/setup/js/campaign_discovery.cjs. + * @param {string} campaignId + * @returns {string} + */ +function formatCampaignLabel(campaignId) { + return `z_campaign_${String(campaignId) + .toLowerCase() + .replace(/[_\s]+/g, "-")}`; +} + +/** + * Get campaign labels implied by environment variables. + * @returns {{enabled: boolean, labels: string[]}} + */ +function getCampaignLabelsFromEnv() { + const campaignId = String(process.env.GH_AW_CAMPAIGN_ID || "").trim(); + const trackerLabel = String(process.env.GH_AW_TRACKER_LABEL || "").trim(); + + if (!campaignId) { + return { enabled: false, labels: [] }; + } + + const labels = [DEFAULT_AGENTIC_CAMPAIGN_LABEL, formatCampaignLabel(campaignId)]; + if (trackerLabel) { + labels.push(trackerLabel); + } + + return { enabled: true, labels }; +} + +/** + * Merge labels with trimming + case-insensitive de-duplication. + * @param {string[]|undefined} existing + * @param {string[]} extra + * @returns {string[]} + */ +function mergeLabels(existing, extra) { + const out = []; + const seen = new Set(); + + for (const raw of [...(existing || []), ...(extra || [])]) { + const label = String(raw || "").trim(); + if (!label) { + continue; + } + + const key = label.toLowerCase(); + if (seen.has(key)) { + continue; + } + + seen.add(key); + out.push(label); + } + + return out; +} + +/** + * Apply campaign labels to supported output messages. + * This keeps worker output labeling centralized and avoids coupling campaign logic + * into individual safe output handlers. + * + * @param {any} message + * @param {{enabled: boolean, labels: string[]}} campaignLabels + * @returns {any} + */ +function applyCampaignLabelsToMessage(message, campaignLabels) { + if (!campaignLabels.enabled) { + return message; + } + + if (!message || typeof message !== "object") { + return message; + } + + const type = message.type; + if (type !== "create_issue" && type !== "create_pull_request") { + return message; + } + + const existing = Array.isArray(message.labels) ? message.labels : []; + const merged = mergeLabels(existing, campaignLabels.labels); + + // Avoid cloning unless we actually need to mutate + if (merged.length === existing.length && merged.every((v, i) => v === existing[i])) { + return message; + } + + return { ...message, labels: merged }; +} + /** * Handler map configuration * Maps safe output types to their handler module file paths @@ -189,6 +285,9 @@ function collectMissingMessages(messages) { async function processMessages(messageHandlers, messages) { const results = []; + // Campaign context: when present, always label created issues/PRs for discovery. + const campaignLabels = getCampaignLabelsFromEnv(); + // Collect missing_tool and missing_data messages first const missings = collectMissingMessages(messages); @@ -211,7 +310,7 @@ async function processMessages(messageHandlers, messages) { // Process messages in order of appearance for (let i = 0; i < messages.length; i++) { - const message = messages[i]; + const message = applyCampaignLabelsToMessage(messages[i], campaignLabels); const messageType = message.type; if (!messageType) { diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index 6e51ffcbd1f..21cbd8bdef3 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -19,6 +19,8 @@ describe("Safe Output Handler Manager", () => { afterEach(() => { // Clean up environment variables delete process.env.GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG; + delete process.env.GH_AW_CAMPAIGN_ID; + delete process.env.GH_AW_TRACKER_LABEL; }); describe("loadConfig", () => { @@ -112,6 +114,35 @@ describe("Safe Output Handler Manager", () => { }); describe("processMessages", () => { + it("should inject campaign labels into create_issue and create_pull_request messages", async () => { + process.env.GH_AW_CAMPAIGN_ID = "Security Alert Burndown"; + process.env.GH_AW_TRACKER_LABEL = "campaign:security-alert-burndown"; + + const messages = [ + { type: "create_issue", title: "Issue", labels: ["Bug"] }, + { type: "create_pull_request", title: "PR", labels: ["Bug", "agentic-campaign"] }, + ]; + + const handler = vi.fn().mockImplementation(async message => { + expect(Array.isArray(message.labels)).toBe(true); + expect(message.labels).toContain("agentic-campaign"); + expect(message.labels).toContain("z_campaign_security-alert-burndown"); + expect(message.labels).toContain("campaign:security-alert-burndown"); + return { success: true }; + }); + + const handlers = new Map([ + ["create_issue", handler], + ["create_pull_request", handler], + ]); + + const result = await processMessages(handlers, messages); + + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + expect(handler).toHaveBeenCalledTimes(2); + }); + it("should process messages in order of appearance", async () => { const messages = [ { type: "add_comment", body: "Comment" }, diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 7d9898e292d..ac9d27cfe90 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -116,7 +116,7 @@ func buildDiscoverySteps(spec *CampaignSpec) []map[string]any { "uses": "actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd", // v8.0.0 "env": envVars, "with": map[string]any{ - "github-token": "${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}", + "github-token": "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN || secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}", "script": ` const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 3a1518c261f..6d362031a7d 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -10,6 +10,22 @@ import ( var safeOutputsEnvLog = logger.New("workflow:safe_outputs_env") +// getCampaignIDFromRepoMemory returns the first configured campaign-id from repo-memory (if any). +// Campaign worker workflows typically carry campaign context via tools.repo-memory.*.campaign-id. +func getCampaignIDFromRepoMemory(data *WorkflowData) string { + if data == nil || data.RepoMemoryConfig == nil { + return "" + } + + for _, memory := range data.RepoMemoryConfig.Memories { + if strings.TrimSpace(memory.CampaignID) != "" { + return strings.TrimSpace(memory.CampaignID) + } + } + + return "" +} + // ======================================== // Safe Output Environment Variables // ======================================== @@ -136,6 +152,11 @@ func (c *Compiler) buildStandardSafeOutputEnvVars(data *WorkflowData, targetRepo targetRepoSlug, )...) + // Campaign context (optional): used by safe output pipeline to label outputs for discovery. + if campaignID := getCampaignIDFromRepoMemory(data); campaignID != "" { + customEnvVars = append(customEnvVars, fmt.Sprintf(" GH_AW_CAMPAIGN_ID: %q\n", campaignID)) + } + // Add messages config if present if data.SafeOutputs.Messages != nil { messagesJSON, err := serializeMessagesConfig(data.SafeOutputs.Messages) diff --git a/pkg/workflow/safe_outputs_test.go b/pkg/workflow/safe_outputs_test.go index 9a297d94396..5ee8b9530b0 100644 --- a/pkg/workflow/safe_outputs_test.go +++ b/pkg/workflow/safe_outputs_test.go @@ -951,6 +951,23 @@ func TestBuildStandardSafeOutputEnvVars(t *testing.T) { "GH_AW_ENGINE_MODEL: \"gpt-4\"", }, }, + { + name: "with repo-memory campaign-id", + workflowData: &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{}, + RepoMemoryConfig: &RepoMemoryConfig{ + Memories: []RepoMemoryEntry{{ + ID: "campaigns", + CampaignID: "security-alert-burndown", + }}, + }, + }, + targetRepoSlug: "", + expectedInVars: []string{ + "GH_AW_CAMPAIGN_ID: \"security-alert-burndown\"", + }, + }, } for _, tt := range tests {