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
101 changes: 100 additions & 1 deletion actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand All @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions actions/setup/js/safe_output_handler_manager.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion pkg/campaign/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions pkg/workflow/safe_outputs_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ========================================
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions pkg/workflow/safe_outputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading