From 3a084c1e5d1c8bf8f11c62b41c327ce7ccc3bf67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:19:50 +0000 Subject: [PATCH 1/6] Initial plan From fdd0f0f0abd0d352af41b74ecb739c4d727ed0e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:31:11 +0000 Subject: [PATCH 2/6] Add campaign project URL validation and fix dependabot-bundler workflow - Add validation to require project URL for campaign orchestrators - Create campaign_project_validation.go with detection logic - Add comprehensive tests for campaign detection - Update dependabot-bundler.md to include project URL - Integrate validation into compiler orchestrator engine Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .github/workflows/dependabot-bundler.lock.yml | 267 ++++++++- .github/workflows/dependabot-bundler.md | 1 + pkg/workflow/campaign_project_validation.go | 205 +++++++ .../campaign_project_validation_test.go | 551 ++++++++++++++++++ pkg/workflow/compiler_orchestrator_engine.go | 7 + 5 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 pkg/workflow/campaign_project_validation.go create mode 100644 pkg/workflow/campaign_project_validation_test.go diff --git a/.github/workflows/dependabot-bundler.lock.yml b/.github/workflows/dependabot-bundler.lock.yml index 43a2824fcc..30dafb3a53 100644 --- a/.github/workflows/dependabot-bundler.lock.yml +++ b/.github/workflows/dependabot-bundler.lock.yml @@ -179,7 +179,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_project_status_update":{"max":1},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_project":{"max":100}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -279,6 +279,133 @@ jobs: }, "name": "noop" }, + { + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "campaign_id": { + "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", + "type": "string" + }, + "content_number": { + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456). Required when content_type is 'issue' or 'pull_request'.", + "type": "number" + }, + "content_type": { + "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project. Required when operation is not specified.", + "enum": [ + "issue", + "pull_request", + "draft_issue" + ], + "type": "string" + }, + "create_if_missing": { + "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", + "type": "boolean" + }, + "draft_body": { + "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "type": "string" + }, + "draft_title": { + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "type": "string" + }, + "field_definitions": { + "description": "Field definitions to create when operation is create_fields. Required when operation='create_fields'.", + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "Field type. Use SINGLE_SELECT with options for enumerated values.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT", + "ITERATION" + ], + "type": "string" + }, + "name": { + "description": "Field name to create (e.g., 'size', 'priority').", + "type": "string" + }, + "options": { + "description": "Options for SINGLE_SELECT fields.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "data_type" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", + "type": "object" + }, + "operation": { + "description": "Optional operation mode. Use create_fields to create required campaign fields up-front, or create_view to add a project view. When omitted, the tool adds/updates project items.", + "enum": [ + "create_fields", + "create_view" + ], + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View definition to create when operation is create_view. Required when operation='create_view'.", + "properties": { + "filter": { + "type": "string" + }, + "layout": { + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "visible_fields": { + "description": "Field IDs to show in the view (table/board only).", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" + } + }, + "required": [ + "project" + ], + "type": "object" + }, + "name": "update_project" + }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -305,6 +432,50 @@ jobs: "type": "object" }, "name": "missing_data" + }, + { + "description": "Create a status update on a GitHub Projects v2 board to communicate project progress. Use this when you need to provide stakeholder updates with status indicators, timeline information, and progress summaries. Status updates create a historical record of project progress tracked over time. Requires project URL, status indicator, dates, and markdown body describing progress/trends/findings.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state.", + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", + "type": "string" + }, + "start_date": { + "description": "Optional project start date in YYYY-MM-DD format (e.g., '2026-01-06').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + }, + "status": { + "description": "Status indicator for the project. Defaults to ON_TRACK. Values: ON_TRACK (progressing well), AT_RISK (has issues/blockers), OFF_TRACK (significantly behind), COMPLETE (finished), INACTIVE (paused/cancelled).", + "enum": [ + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE", + "INACTIVE" + ], + "type": "string" + }, + "target_date": { + "description": "Optional project target/end date in YYYY-MM-DD format (e.g., '2026-12-31').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + } + }, + "required": [ + "project", + "body" + ], + "type": "object" + }, + "name": "create_project_status_update" } ] EOF @@ -325,6 +496,45 @@ jobs: } } }, + "create_project_status_update": { + "defaultMax": 10, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65536 + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "start_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ] + }, + "target_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + } + } + }, "create_pull_request": { "defaultMax": 1, "fields": { @@ -385,6 +595,43 @@ jobs: "maxLength": 65000 } } + }, + "update_project": { + "defaultMax": 10, + "fields": { + "campaign_id": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "content_number": { + "optionalPositiveInteger": true + }, + "content_type": { + "type": "string", + "enum": [ + "issue", + "pull_request" + ] + }, + "fields": { + "type": "object" + }, + "issue": { + "optionalPositiveInteger": true + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "pull_request": { + "optionalPositiveInteger": true + } + } } } EOF @@ -1234,6 +1481,8 @@ jobs: GH_AW_WORKFLOW_ID: "dependabot-bundler" GH_AW_WORKFLOW_NAME: "Dependabot Bundler" outputs: + process_project_safe_outputs_processed_count: ${{ steps.process_project_safe_outputs.outputs.processed_count }} + process_project_safe_outputs_temporary_project_map: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: @@ -1284,11 +1533,27 @@ jobs: SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" + - name: Process Project-Related Safe Outputs + id: process_project_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":100}}" + GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + GH_AW_PROJECT_URL: "https://github.com/orgs/githubnext/projects/144" + with: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_project_handler_manager.cjs'); + await main(); - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_TEMPORARY_PROJECT_MAP: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"agentic-campaign\",\"z_campaign_security-alert-burndown\"]},\"create_pull_request\":{\"base_branch\":\"${{ github.ref_name }}\",\"labels\":[\"security\",\"dependencies\",\"dependabot\",\"automated-fix\",\"agentic-campaign\",\"z_campaign_security-alert-burndown\"],\"max\":1,\"max_patch_size\":1024,\"title_prefix\":\"[dependabot-bundle] \"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dependabot-bundler.md b/.github/workflows/dependabot-bundler.md index e53314307f..adbadc4788 100644 --- a/.github/workflows/dependabot-bundler.md +++ b/.github/workflows/dependabot-bundler.md @@ -21,6 +21,7 @@ tools: cache-memory: edit: bash: +project: https://github.com/orgs/githubnext/projects/144 safe-outputs: add-labels: allowed: diff --git a/pkg/workflow/campaign_project_validation.go b/pkg/workflow/campaign_project_validation.go new file mode 100644 index 0000000000..690a2ec021 --- /dev/null +++ b/pkg/workflow/campaign_project_validation.go @@ -0,0 +1,205 @@ +// This file provides validation for campaign orchestrator project requirements. +// +// # Campaign Project Validation +// +// This file ensures that workflows with campaign characteristics (such as campaign labels +// or campaign IDs) have a required GitHub Project URL configured for tracking their work. +// +// Campaign orchestrators coordinate multiple workflows and track progress on GitHub Project +// boards. Without a project URL, the orchestrator cannot track Dependabot PRs, bundle issues, +// or other campaign work items. +// +// # Detection Criteria +// +// A workflow is considered a campaign orchestrator if it has: +// - Campaign labels in safe-outputs (agentic-campaign or z_campaign_*) +// - Campaign ID configured in repo-memory tools +// +// # Validation Rules +// +// When campaign characteristics are detected: +// - A project field must be present in frontmatter +// - The project field must be a non-empty string or valid project config object +// +// # When to Update This File +// +// Update this validation when: +// - New campaign detection patterns are added +// - Project configuration requirements change +// - Campaign orchestration patterns evolve + +package workflow + +import ( + "fmt" + "strings" + + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var campaignProjectValidationLog = logger.New("workflow:campaign_project_validation") + +// validateCampaignProject checks if a workflow with campaign characteristics has a project URL configured +func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error { + campaignProjectValidationLog.Print("Checking campaign project requirements") + + // Check if this workflow has campaign characteristics + isCampaignWorkflow, campaignSource := detectCampaignWorkflow(frontmatter) + if !isCampaignWorkflow { + campaignProjectValidationLog.Print("Workflow is not a campaign orchestrator, skipping validation") + return nil + } + + campaignProjectValidationLog.Printf("Detected campaign workflow via %s", campaignSource) + + // Check if project field exists + projectData, hasProject := frontmatter["project"] + if !hasProject || projectData == nil { + return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123). Campaign detected via: %s", campaignSource) + } + + // Validate project field is not empty + switch v := projectData.(type) { + case string: + if strings.TrimSpace(v) == "" { + return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Printf("Valid project URL found: %s", v) + case map[string]any: + // Check if object has a URL field + if url, hasURL := v["url"]; !hasURL || url == nil { + return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource) + } else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" { + return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Print("Valid project configuration object found") + default: + return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource) + } + + campaignProjectValidationLog.Print("Campaign project validation passed") + return nil +} + +// detectCampaignWorkflow checks if a workflow has campaign characteristics +// Returns (isCampaign bool, source string) where source explains why it's detected as a campaign +func detectCampaignWorkflow(frontmatter map[string]any) (bool, string) { + // Check for campaign labels in safe-outputs + if hasCampaignLabels(frontmatter) { + return true, "campaign labels in safe-outputs (agentic-campaign or z_campaign_*)" + } + + // Check for campaign-id in repo-memory tools + if hasCampaignID(frontmatter) { + return true, "campaign-id in repo-memory configuration" + } + + return false, "" +} + +// hasCampaignLabels checks if safe-outputs configuration includes campaign labels +func hasCampaignLabels(frontmatter map[string]any) bool { + safeOutputs, ok := frontmatter["safe-outputs"].(map[string]any) + if !ok { + return false + } + + // Check all safe-output types that support labels + labelConfigs := []string{ + "add-labels", + "create-issue", + "create-pull-request", + "create-discussion", + } + + for _, configKey := range labelConfigs { + if hasLabelsInConfig(safeOutputs, configKey) { + return true + } + } + + return false +} + +// hasLabelsInConfig checks if a specific safe-output config contains campaign labels +func hasLabelsInConfig(safeOutputs map[string]any, configKey string) bool { + config, ok := safeOutputs[configKey].(map[string]any) + if !ok { + return false + } + + // Check for "allowed" field in add-labels + if configKey == "add-labels" { + if allowed, ok := config["allowed"].([]any); ok { + for _, label := range allowed { + if labelStr, ok := label.(string); ok && isCampaignLabel(labelStr) { + return true + } + } + } + } + + // Check for "labels" field in other safe-outputs + if labels, ok := config["labels"].([]any); ok { + for _, label := range labels { + if labelStr, ok := label.(string); ok && isCampaignLabel(labelStr) { + return true + } + } + } + + return false +} + +// isCampaignLabel checks if a label string is a campaign label +func isCampaignLabel(label string) bool { + // Check for exact match with AgenticCampaignLabel + if label == string(constants.AgenticCampaignLabel) { + return true + } + + // Check for z_campaign_ prefix + if strings.HasPrefix(label, string(constants.CampaignLabelPrefix)) { + return true + } + + return false +} + +// hasCampaignID checks if tools.repo-memory configuration includes a campaign-id +func hasCampaignID(frontmatter map[string]any) bool { + tools, ok := frontmatter["tools"].(map[string]any) + if !ok { + return false + } + + repoMemory, ok := tools["repo-memory"] + if !ok { + return false + } + + // repo-memory can be a single config object or an array of config objects + switch v := repoMemory.(type) { + case map[string]any: + // Single repo-memory configuration + if campaignID, exists := v["campaign-id"]; exists && campaignID != nil { + if idStr, ok := campaignID.(string); ok && strings.TrimSpace(idStr) != "" { + return true + } + } + case []any: + // Array of repo-memory configurations + for _, item := range v { + if itemMap, ok := item.(map[string]any); ok { + if campaignID, exists := itemMap["campaign-id"]; exists && campaignID != nil { + if idStr, ok := campaignID.(string); ok && strings.TrimSpace(idStr) != "" { + return true + } + } + } + } + } + + return false +} diff --git a/pkg/workflow/campaign_project_validation_test.go b/pkg/workflow/campaign_project_validation_test.go new file mode 100644 index 0000000000..9f24a7c8aa --- /dev/null +++ b/pkg/workflow/campaign_project_validation_test.go @@ -0,0 +1,551 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateCampaignProject(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectError bool + errorMsg string + }{ + { + name: "campaign with agentic-campaign label and project URL - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign", "z_campaign_test"}, + }, + }, + "project": "https://github.com/orgs/test/projects/123", + }, + expectError: false, + }, + { + name: "campaign with z_campaign_ label and project URL - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "labels": []any{"z_campaign_security"}, + }, + }, + "project": "https://github.com/orgs/test/projects/456", + }, + expectError: false, + }, + { + name: "campaign with campaign-id in repo-memory and project URL - valid", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "security-alert-burndown", + }, + }, + "project": "https://github.com/orgs/test/projects/789", + }, + expectError: false, + }, + { + name: "campaign with agentic-campaign label but no project - error", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", + }, + { + name: "campaign with z_campaign_ label but empty project URL - error", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-pull-request": map[string]any{ + "labels": []any{"z_campaign_test"}, + }, + }, + "project": "", + }, + expectError: true, + errorMsg: "requires a non-empty GitHub Project URL", + }, + { + name: "campaign with campaign-id but nil project - error", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "test-campaign", + }, + }, + "project": nil, + }, + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", + }, + { + name: "campaign with project config object - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + "project": map[string]any{ + "url": "https://github.com/orgs/test/projects/123", + "max-updates": 100, + }, + }, + expectError: false, + }, + { + name: "campaign with project config but missing URL - error", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": []any{ + map[string]any{ + "campaign-id": "test", + }, + }, + }, + "project": map[string]any{ + "max-updates": 100, + }, + }, + expectError: true, + errorMsg: "project configuration must include a 'url' field", + }, + { + name: "non-campaign workflow without project - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + }, + expectError: false, + }, + { + name: "workflow with regular labels - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"bug", "feature", "documentation"}, + }, + }, + }, + expectError: false, + }, + { + name: "campaign with multiple repo-memory entries - valid", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": []any{ + map[string]any{ + "id": "state", + }, + map[string]any{ + "campaign-id": "test-campaign", + }, + }, + }, + "project": "https://github.com/orgs/test/projects/999", + }, + expectError: false, + }, + { + name: "campaign via create-discussion labels - valid", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-discussion": map[string]any{ + "labels": []any{"agentic-campaign"}, + }, + }, + "project": "https://github.com/orgs/test/projects/111", + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + err := compiler.validateCampaignProject(tt.frontmatter) + + if tt.expectError { + require.Error(t, err, "Expected error but got none") + assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain expected text") + } else { + assert.NoError(t, err, "Expected no error but got: %v", err) + } + }) + } +} + +func TestDetectCampaignWorkflow(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedCampaign bool + expectedSource string + }{ + { + name: "detect via agentic-campaign label", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + expectedCampaign: true, + expectedSource: "campaign labels", + }, + { + name: "detect via z_campaign_ prefix", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "labels": []any{"z_campaign_test"}, + }, + }, + }, + expectedCampaign: true, + expectedSource: "campaign labels", + }, + { + name: "detect via campaign-id in single repo-memory", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "test", + }, + }, + }, + expectedCampaign: true, + expectedSource: "campaign-id", + }, + { + name: "detect via campaign-id in array repo-memory", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": []any{ + map[string]any{ + "campaign-id": "test", + }, + }, + }, + }, + expectedCampaign: true, + expectedSource: "campaign-id", + }, + { + name: "no campaign characteristics", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "labels": []any{"bug"}, + }, + }, + }, + expectedCampaign: false, + expectedSource: "", + }, + { + name: "empty repo-memory campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "", + }, + }, + }, + expectedCampaign: false, + expectedSource: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isCampaign, source := detectCampaignWorkflow(tt.frontmatter) + assert.Equal(t, tt.expectedCampaign, isCampaign, "Campaign detection mismatch") + if tt.expectedCampaign { + assert.Contains(t, source, tt.expectedSource, "Source should contain expected text") + } + }) + } +} + +func TestIsCampaignLabel(t *testing.T) { + tests := []struct { + name string + label string + expected bool + }{ + {"agentic-campaign exact match", "agentic-campaign", true}, + {"z_campaign_ prefix", "z_campaign_security", true}, + {"z_campaign_ prefix with dashes", "z_campaign_go-size-reduction", true}, + {"regular label", "bug", false}, + {"feature label", "feature", false}, + {"partial match", "my-agentic-campaign", false}, + {"empty string", "", false}, + {"z_ without campaign", "z_test", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isCampaignLabel(tt.label) + assert.Equal(t, tt.expected, result, "Label %q should %v be a campaign label", tt.label, map[bool]string{true: "", false: "not"}[tt.expected]) + }) + } +} + +func TestHasCampaignID(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expected bool + }{ + { + name: "single repo-memory with campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "test", + }, + }, + }, + expected: true, + }, + { + name: "array repo-memory with campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": []any{ + map[string]any{ + "campaign-id": "test", + }, + }, + }, + }, + expected: true, + }, + { + name: "no tools", + frontmatter: map[string]any{}, + expected: false, + }, + { + name: "no repo-memory", + frontmatter: map[string]any{ + "tools": map[string]any{}, + }, + expected: false, + }, + { + name: "empty campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "", + }, + }, + }, + expected: false, + }, + { + name: "nil campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": nil, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasCampaignID(tt.frontmatter) + assert.Equal(t, tt.expected, result, "Campaign ID detection mismatch") + }) + } +} + +func TestHasCampaignLabels(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expected bool + }{ + { + name: "add-labels with agentic-campaign", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + expected: true, + }, + { + name: "create-issue with z_campaign_ label", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "labels": []any{"z_campaign_test"}, + }, + }, + }, + expected: true, + }, + { + name: "create-pull-request with campaign label", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-pull-request": map[string]any{ + "labels": []any{"dependency", "agentic-campaign"}, + }, + }, + }, + expected: true, + }, + { + name: "create-discussion with campaign label", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "create-discussion": map[string]any{ + "labels": []any{"agentic-campaign"}, + }, + }, + }, + expected: true, + }, + { + name: "no safe-outputs", + frontmatter: map[string]any{}, + expected: false, + }, + { + name: "regular labels only", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"bug", "feature"}, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasCampaignLabels(tt.frontmatter) + assert.Equal(t, tt.expected, result, "Campaign labels detection mismatch") + }) + } +} + +func TestCampaignValidationIntegration(t *testing.T) { + // Test with actual dependabot-bundler.md style frontmatter (missing project) + frontmatter := map[string]any{ + "name": "Test Campaign", + "on": "workflow_dispatch", + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{ + "agentic-campaign", + "z_campaign_security-alert-burndown", + }, + }, + "create-pull-request": map[string]any{ + "labels": []any{"security", "dependencies", "agentic-campaign"}, + }, + }, + "tools": map[string]any{ + "repo-memory": []any{ + map[string]any{ + "id": "campaigns", + "branch-name": "memory/campaigns", + "campaign-id": "security-alert-burndown", + }, + }, + }, + } + + compiler := NewCompiler() + err := compiler.validateCampaignProject(frontmatter) + require.Error(t, err, "Should fail validation without project URL") + assert.Contains(t, err.Error(), "campaign orchestrator requires a GitHub Project URL") + + // Add project URL and verify it passes + frontmatter["project"] = "https://github.com/orgs/test/projects/144" + err = compiler.validateCampaignProject(frontmatter) + assert.NoError(t, err, "Should pass validation with project URL") +} + +func TestCampaignValidationErrorMessages(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedInError []string + }{ + { + name: "error message explains campaign source - labels", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + expectedInError: []string{ + "campaign orchestrator", + "GitHub Project URL", + "campaign labels", + }, + }, + { + name: "error message explains campaign source - campaign-id", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "test", + }, + }, + }, + expectedInError: []string{ + "campaign orchestrator", + "GitHub Project URL", + "campaign-id", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler() + err := compiler.validateCampaignProject(tt.frontmatter) + require.Error(t, err, "Should fail validation") + + errMsg := err.Error() + for _, expected := range tt.expectedInError { + assert.Contains(t, strings.ToLower(errMsg), strings.ToLower(expected), + "Error message should contain %q", expected) + } + }) + } +} diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index f8d2f14c1e..d6889cdd82 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -79,6 +79,13 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean // This ensures strict mode doesn't leak to other workflows being compiled c.strictMode = initialStrictMode + // Validate campaign orchestrator project requirements (applies to all workflows, not just strict mode) + orchestratorEngineLog.Print("Validating campaign orchestrator project requirements") + if err := c.validateCampaignProject(result.Frontmatter); err != nil { + orchestratorEngineLog.Printf("Campaign project validation failed: %v", err) + return nil, err + } + // Override with command line AI engine setting if provided if c.engineOverride != "" { originalEngineSetting := engineSetting From d488316de21369731bd6146f5e0fdd4e111cc22d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:35:28 +0000 Subject: [PATCH 3/6] Fix campaign orchestrator project URLs for security workflows - Add project URL to code-scanning-fixer.md - Add project URL to secret-scanning-triage.md - Add project URL to security-fix-pr.md - All campaign workflows now pass validation Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- .../workflows/code-scanning-fixer.lock.yml | 267 +++++++++++++++++- .github/workflows/code-scanning-fixer.md | 1 + .../workflows/secret-scanning-triage.lock.yml | 267 +++++++++++++++++- .github/workflows/secret-scanning-triage.md | 1 + .github/workflows/security-fix-pr.lock.yml | 267 +++++++++++++++++- .github/workflows/security-fix-pr.md | 1 + .../campaign_project_validation_test.go | 30 +- 7 files changed, 816 insertions(+), 18 deletions(-) diff --git a/.github/workflows/code-scanning-fixer.lock.yml b/.github/workflows/code-scanning-fixer.lock.yml index 9388b74517..8c7fdf1d0e 100644 --- a/.github/workflows/code-scanning-fixer.lock.yml +++ b/.github/workflows/code-scanning-fixer.lock.yml @@ -179,7 +179,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_project_status_update":{"max":1},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_project":{"max":100}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -279,6 +279,133 @@ jobs: }, "name": "noop" }, + { + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "campaign_id": { + "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", + "type": "string" + }, + "content_number": { + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456). Required when content_type is 'issue' or 'pull_request'.", + "type": "number" + }, + "content_type": { + "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project. Required when operation is not specified.", + "enum": [ + "issue", + "pull_request", + "draft_issue" + ], + "type": "string" + }, + "create_if_missing": { + "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", + "type": "boolean" + }, + "draft_body": { + "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "type": "string" + }, + "draft_title": { + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "type": "string" + }, + "field_definitions": { + "description": "Field definitions to create when operation is create_fields. Required when operation='create_fields'.", + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "Field type. Use SINGLE_SELECT with options for enumerated values.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT", + "ITERATION" + ], + "type": "string" + }, + "name": { + "description": "Field name to create (e.g., 'size', 'priority').", + "type": "string" + }, + "options": { + "description": "Options for SINGLE_SELECT fields.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "data_type" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", + "type": "object" + }, + "operation": { + "description": "Optional operation mode. Use create_fields to create required campaign fields up-front, or create_view to add a project view. When omitted, the tool adds/updates project items.", + "enum": [ + "create_fields", + "create_view" + ], + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View definition to create when operation is create_view. Required when operation='create_view'.", + "properties": { + "filter": { + "type": "string" + }, + "layout": { + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "visible_fields": { + "description": "Field IDs to show in the view (table/board only).", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" + } + }, + "required": [ + "project" + ], + "type": "object" + }, + "name": "update_project" + }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -305,6 +432,50 @@ jobs: "type": "object" }, "name": "missing_data" + }, + { + "description": "Create a status update on a GitHub Projects v2 board to communicate project progress. Use this when you need to provide stakeholder updates with status indicators, timeline information, and progress summaries. Status updates create a historical record of project progress tracked over time. Requires project URL, status indicator, dates, and markdown body describing progress/trends/findings.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state.", + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", + "type": "string" + }, + "start_date": { + "description": "Optional project start date in YYYY-MM-DD format (e.g., '2026-01-06').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + }, + "status": { + "description": "Status indicator for the project. Defaults to ON_TRACK. Values: ON_TRACK (progressing well), AT_RISK (has issues/blockers), OFF_TRACK (significantly behind), COMPLETE (finished), INACTIVE (paused/cancelled).", + "enum": [ + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE", + "INACTIVE" + ], + "type": "string" + }, + "target_date": { + "description": "Optional project target/end date in YYYY-MM-DD format (e.g., '2026-12-31').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + } + }, + "required": [ + "project", + "body" + ], + "type": "object" + }, + "name": "create_project_status_update" } ] EOF @@ -325,6 +496,45 @@ jobs: } } }, + "create_project_status_update": { + "defaultMax": 10, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65536 + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "start_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ] + }, + "target_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + } + } + }, "create_pull_request": { "defaultMax": 1, "fields": { @@ -385,6 +595,43 @@ jobs: "maxLength": 65000 } } + }, + "update_project": { + "defaultMax": 10, + "fields": { + "campaign_id": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "content_number": { + "optionalPositiveInteger": true + }, + "content_type": { + "type": "string", + "enum": [ + "issue", + "pull_request" + ] + }, + "fields": { + "type": "object" + }, + "issue": { + "optionalPositiveInteger": true + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "pull_request": { + "optionalPositiveInteger": true + } + } } } EOF @@ -1234,6 +1481,8 @@ jobs: GH_AW_WORKFLOW_ID: "code-scanning-fixer" GH_AW_WORKFLOW_NAME: "Code Scanning Fixer" outputs: + process_project_safe_outputs_processed_count: ${{ steps.process_project_safe_outputs.outputs.processed_count }} + process_project_safe_outputs_temporary_project_map: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: @@ -1284,11 +1533,27 @@ jobs: SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" + - name: Process Project-Related Safe Outputs + id: process_project_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":100}}" + GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + GH_AW_PROJECT_URL: "https://github.com/orgs/githubnext/projects/144" + with: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_project_handler_manager.cjs'); + await main(); - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_TEMPORARY_PROJECT_MAP: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"agentic-campaign\",\"z_campaign_security-alert-burndown\"]},\"create_pull_request\":{\"base_branch\":\"${{ github.ref_name }}\",\"labels\":[\"security\",\"automated-fix\",\"agentic-campaign\",\"z_campaign_security-alert-burndown\"],\"max\":1,\"max_patch_size\":1024,\"title_prefix\":\"[code-scanning-fix] \"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/code-scanning-fixer.md b/.github/workflows/code-scanning-fixer.md index 9e8d149ec3..a2c6a74377 100644 --- a/.github/workflows/code-scanning-fixer.md +++ b/.github/workflows/code-scanning-fixer.md @@ -21,6 +21,7 @@ tools: edit: bash: cache-memory: +project: https://github.com/orgs/githubnext/projects/144 safe-outputs: add-labels: allowed: diff --git a/.github/workflows/secret-scanning-triage.lock.yml b/.github/workflows/secret-scanning-triage.lock.yml index 2faf9af0ad..912bb8ad08 100644 --- a/.github/workflows/secret-scanning-triage.lock.yml +++ b/.github/workflows/secret-scanning-triage.lock.yml @@ -181,7 +181,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_issue":{"max":1},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"create_issue":{"max":1},"create_project_status_update":{"max":1},"create_pull_request":{},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_project":{"max":100}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -321,6 +321,133 @@ jobs: }, "name": "noop" }, + { + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "campaign_id": { + "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", + "type": "string" + }, + "content_number": { + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456). Required when content_type is 'issue' or 'pull_request'.", + "type": "number" + }, + "content_type": { + "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project. Required when operation is not specified.", + "enum": [ + "issue", + "pull_request", + "draft_issue" + ], + "type": "string" + }, + "create_if_missing": { + "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", + "type": "boolean" + }, + "draft_body": { + "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "type": "string" + }, + "draft_title": { + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "type": "string" + }, + "field_definitions": { + "description": "Field definitions to create when operation is create_fields. Required when operation='create_fields'.", + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "Field type. Use SINGLE_SELECT with options for enumerated values.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT", + "ITERATION" + ], + "type": "string" + }, + "name": { + "description": "Field name to create (e.g., 'size', 'priority').", + "type": "string" + }, + "options": { + "description": "Options for SINGLE_SELECT fields.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "data_type" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", + "type": "object" + }, + "operation": { + "description": "Optional operation mode. Use create_fields to create required campaign fields up-front, or create_view to add a project view. When omitted, the tool adds/updates project items.", + "enum": [ + "create_fields", + "create_view" + ], + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View definition to create when operation is create_view. Required when operation='create_view'.", + "properties": { + "filter": { + "type": "string" + }, + "layout": { + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "visible_fields": { + "description": "Field IDs to show in the view (table/board only).", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" + } + }, + "required": [ + "project" + ], + "type": "object" + }, + "name": "update_project" + }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -347,6 +474,50 @@ jobs: "type": "object" }, "name": "missing_data" + }, + { + "description": "Create a status update on a GitHub Projects v2 board to communicate project progress. Use this when you need to provide stakeholder updates with status indicators, timeline information, and progress summaries. Status updates create a historical record of project progress tracked over time. Requires project URL, status indicator, dates, and markdown body describing progress/trends/findings.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state.", + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", + "type": "string" + }, + "start_date": { + "description": "Optional project start date in YYYY-MM-DD format (e.g., '2026-01-06').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + }, + "status": { + "description": "Status indicator for the project. Defaults to ON_TRACK. Values: ON_TRACK (progressing well), AT_RISK (has issues/blockers), OFF_TRACK (significantly behind), COMPLETE (finished), INACTIVE (paused/cancelled).", + "enum": [ + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE", + "INACTIVE" + ], + "type": "string" + }, + "target_date": { + "description": "Optional project target/end date in YYYY-MM-DD format (e.g., '2026-12-31').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + } + }, + "required": [ + "project", + "body" + ], + "type": "object" + }, + "name": "create_project_status_update" } ] EOF @@ -400,6 +571,45 @@ jobs: } } }, + "create_project_status_update": { + "defaultMax": 10, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65536 + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "start_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ] + }, + "target_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + } + } + }, "create_pull_request": { "defaultMax": 1, "fields": { @@ -460,6 +670,43 @@ jobs: "maxLength": 65000 } } + }, + "update_project": { + "defaultMax": 10, + "fields": { + "campaign_id": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "content_number": { + "optionalPositiveInteger": true + }, + "content_type": { + "type": "string", + "enum": [ + "issue", + "pull_request" + ] + }, + "fields": { + "type": "object" + }, + "issue": { + "optionalPositiveInteger": true + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "pull_request": { + "optionalPositiveInteger": true + } + } } } EOF @@ -1339,6 +1586,8 @@ jobs: GH_AW_WORKFLOW_ID: "secret-scanning-triage" GH_AW_WORKFLOW_NAME: "Secret Scanning Triage" outputs: + process_project_safe_outputs_processed_count: ${{ steps.process_project_safe_outputs.outputs.processed_count }} + process_project_safe_outputs_temporary_project_map: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: @@ -1389,11 +1638,27 @@ jobs: SERVER_URL_STRIPPED="${SERVER_URL#https://}" git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" echo "Git configured with standard GitHub Actions identity" + - name: Process Project-Related Safe Outputs + id: process_project_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":100}}" + GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + GH_AW_PROJECT_URL: "https://github.com/orgs/githubnext/projects/144" + with: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_project_handler_manager.cjs'); + await main(); - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_TEMPORARY_PROJECT_MAP: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"agentic-campaign\",\"z_campaign_security-alert-burndown\"]},\"create_issue\":{\"labels\":[\"security\",\"secret-scanning\",\"triage\",\"agentic-campaign\",\"z_campaign_security-alert-burndown\"],\"max\":1,\"title_prefix\":\"[secret-triage] \"},\"create_pull_request\":{\"base_branch\":\"${{ github.ref_name }}\",\"labels\":[\"security\",\"secret-scanning\",\"automated-fix\",\"agentic-campaign\",\"z_campaign_security-alert-burndown\"],\"max\":1,\"max_patch_size\":1024,\"title_prefix\":\"[secret-removal] \"},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/secret-scanning-triage.md b/.github/workflows/secret-scanning-triage.md index f30fafab71..4276276fb8 100644 --- a/.github/workflows/secret-scanning-triage.md +++ b/.github/workflows/secret-scanning-triage.md @@ -23,6 +23,7 @@ tools: bash: imports: - shared/reporting.md +project: https://github.com/orgs/githubnext/projects/144 safe-outputs: add-labels: allowed: diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index 71d81ab94c..7a705f6fe1 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -184,7 +184,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"autofix_code_scanning_alert":{"max":5},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + {"add_labels":{"allowed":["agentic-campaign","z_campaign_security-alert-burndown"],"max":3},"autofix_code_scanning_alert":{"max":5},"create_project_status_update":{"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1},"update_project":{"max":100}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -251,6 +251,133 @@ jobs: }, "name": "noop" }, + { + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\n\nThree usage modes:\n1. Add/update project item: Requires project + content_type. For 'issue' or 'pull_request', also requires content_number. For 'draft_issue', requires draft_title.\n2. Create project fields: Requires project + operation='create_fields' + field_definitions.\n3. Create project view: Requires project + operation='create_view' + view.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "campaign_id": { + "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", + "type": "string" + }, + "content_number": { + "description": "Issue or pull request number to add to the project. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123 for issue #123, or 456 in github.com/owner/repo/pull/456 for PR #456). Required when content_type is 'issue' or 'pull_request'.", + "type": "number" + }, + "content_type": { + "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project. Required when operation is not specified.", + "enum": [ + "issue", + "pull_request", + "draft_issue" + ], + "type": "string" + }, + "create_if_missing": { + "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", + "type": "boolean" + }, + "draft_body": { + "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "type": "string" + }, + "draft_title": { + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "type": "string" + }, + "field_definitions": { + "description": "Field definitions to create when operation is create_fields. Required when operation='create_fields'.", + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "Field type. Use SINGLE_SELECT with options for enumerated values.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT", + "ITERATION" + ], + "type": "string" + }, + "name": { + "description": "Field name to create (e.g., 'size', 'priority').", + "type": "string" + }, + "options": { + "description": "Options for SINGLE_SELECT fields.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "data_type" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", + "type": "object" + }, + "operation": { + "description": "Optional operation mode. Use create_fields to create required campaign fields up-front, or create_view to add a project view. When omitted, the tool adds/updates project items.", + "enum": [ + "create_fields", + "create_view" + ], + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + }, + "view": { + "additionalProperties": false, + "description": "View definition to create when operation is create_view. Required when operation='create_view'.", + "properties": { + "filter": { + "type": "string" + }, + "layout": { + "enum": [ + "table", + "board", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "visible_fields": { + "description": "Field IDs to show in the view (table/board only).", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "name", + "layout" + ], + "type": "object" + } + }, + "required": [ + "project" + ], + "type": "object" + }, + "name": "update_project" + }, { "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", "inputSchema": { @@ -278,6 +405,50 @@ jobs: }, "name": "missing_data" }, + { + "description": "Create a status update on a GitHub Projects v2 board to communicate project progress. Use this when you need to provide stakeholder updates with status indicators, timeline information, and progress summaries. Status updates create a historical record of project progress tracked over time. Requires project URL, status indicator, dates, and markdown body describing progress/trends/findings.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Status update body in markdown format describing progress, findings, trends, and next steps. Should provide stakeholders with clear understanding of project state.", + "type": "string" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$", + "type": "string" + }, + "start_date": { + "description": "Optional project start date in YYYY-MM-DD format (e.g., '2026-01-06').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + }, + "status": { + "description": "Status indicator for the project. Defaults to ON_TRACK. Values: ON_TRACK (progressing well), AT_RISK (has issues/blockers), OFF_TRACK (significantly behind), COMPLETE (finished), INACTIVE (paused/cancelled).", + "enum": [ + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE", + "INACTIVE" + ], + "type": "string" + }, + "target_date": { + "description": "Optional project target/end date in YYYY-MM-DD format (e.g., '2026-12-31').", + "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$", + "type": "string" + } + }, + "required": [ + "project", + "body" + ], + "type": "object" + }, + "name": "create_project_status_update" + }, { "description": "Create an autofix for a code scanning alert. Use this to provide automated fixes for security vulnerabilities detected by code scanning tools. The fix should contain the corrected code that resolves the security issue.", "inputSchema": { @@ -327,6 +498,45 @@ jobs: } } }, + "create_project_status_update": { + "defaultMax": 10, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65536 + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "start_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + }, + "status": { + "type": "string", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ] + }, + "target_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "patternError": "must be in YYYY-MM-DD format" + } + } + }, "missing_tool": { "defaultMax": 20, "fields": { @@ -358,6 +568,43 @@ jobs: "maxLength": 65000 } } + }, + "update_project": { + "defaultMax": 10, + "fields": { + "campaign_id": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "content_number": { + "optionalPositiveInteger": true + }, + "content_type": { + "type": "string", + "enum": [ + "issue", + "pull_request" + ] + }, + "fields": { + "type": "object" + }, + "issue": { + "optionalPositiveInteger": true + }, + "project": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "pull_request": { + "optionalPositiveInteger": true + } + } } } EOF @@ -1187,6 +1434,8 @@ jobs: GH_AW_WORKFLOW_ID: "security-fix-pr" GH_AW_WORKFLOW_NAME: "Security Fix PR" outputs: + process_project_safe_outputs_processed_count: ${{ steps.process_project_safe_outputs.outputs.processed_count }} + process_project_safe_outputs_temporary_project_map: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: @@ -1211,11 +1460,27 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Project-Related Safe Outputs + id: process_project_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":100}}" + GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + GH_AW_PROJECT_URL: "https://github.com/orgs/githubnext/projects/144" + with: + github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_project_handler_manager.cjs'); + await main(); - name: Process Safe Outputs id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_TEMPORARY_PROJECT_MAP: ${{ steps.process_project_safe_outputs.outputs.temporary_project_map }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_labels\":{\"allowed\":[\"agentic-campaign\",\"z_campaign_security-alert-burndown\"]},\"autofix_code_scanning_alert\":{\"max\":5},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security-fix-pr.md b/.github/workflows/security-fix-pr.md index 214d0b39fc..fe0f760500 100644 --- a/.github/workflows/security-fix-pr.md +++ b/.github/workflows/security-fix-pr.md @@ -24,6 +24,7 @@ tools: file-glob: [security-alert-burndown/**] campaign-id: security-alert-burndown cache-memory: +project: https://github.com/orgs/githubnext/projects/144 safe-outputs: add-labels: allowed: diff --git a/pkg/workflow/campaign_project_validation_test.go b/pkg/workflow/campaign_project_validation_test.go index 9f24a7c8aa..a2674ab521 100644 --- a/pkg/workflow/campaign_project_validation_test.go +++ b/pkg/workflow/campaign_project_validation_test.go @@ -193,10 +193,10 @@ func TestValidateCampaignProject(t *testing.T) { func TestDetectCampaignWorkflow(t *testing.T) { tests := []struct { - name string - frontmatter map[string]any + name string + frontmatter map[string]any expectedCampaign bool - expectedSource string + expectedSource string }{ { name: "detect via agentic-campaign label", @@ -311,9 +311,9 @@ func TestIsCampaignLabel(t *testing.T) { func TestHasCampaignID(t *testing.T) { tests := []struct { - name string + name string frontmatter map[string]any - expected bool + expected bool }{ { name: "single repo-memory with campaign-id", @@ -340,9 +340,9 @@ func TestHasCampaignID(t *testing.T) { expected: true, }, { - name: "no tools", + name: "no tools", frontmatter: map[string]any{}, - expected: false, + expected: false, }, { name: "no repo-memory", @@ -385,9 +385,9 @@ func TestHasCampaignID(t *testing.T) { func TestHasCampaignLabels(t *testing.T) { tests := []struct { - name string + name string frontmatter map[string]any - expected bool + expected bool }{ { name: "add-labels with agentic-campaign", @@ -434,9 +434,9 @@ func TestHasCampaignLabels(t *testing.T) { expected: true, }, { - name: "no safe-outputs", + name: "no safe-outputs", frontmatter: map[string]any{}, - expected: false, + expected: false, }, { name: "regular labels only", @@ -499,9 +499,9 @@ func TestCampaignValidationIntegration(t *testing.T) { func TestCampaignValidationErrorMessages(t *testing.T) { tests := []struct { - name string - frontmatter map[string]any - expectedInError []string + name string + frontmatter map[string]any + expectedInError []string }{ { name: "error message explains campaign source - labels", @@ -540,7 +540,7 @@ func TestCampaignValidationErrorMessages(t *testing.T) { compiler := NewCompiler() err := compiler.validateCampaignProject(tt.frontmatter) require.Error(t, err, "Should fail validation") - + errMsg := err.Error() for _, expected := range tt.expectedInError { assert.Contains(t, strings.ToLower(errMsg), strings.ToLower(expected), From 3ca85314c18772a8a2e8bea608f4d01025401348 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:33:47 +0000 Subject: [PATCH 4/6] Add markdown fallback for campaign project URLs - Frontmatter 'project' field is now the source of truth - If not in frontmatter, fall back to searching markdown body - Add tests for markdown fallback functionality - Update error messages to mention both options Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/workflow/campaign_project_validation.go | 75 +++++--- .../campaign_project_validation_test.go | 162 +++++++++++++++--- pkg/workflow/compiler_orchestrator_engine.go | 2 +- 3 files changed, 191 insertions(+), 48 deletions(-) diff --git a/pkg/workflow/campaign_project_validation.go b/pkg/workflow/campaign_project_validation.go index 690a2ec021..67efad8fde 100644 --- a/pkg/workflow/campaign_project_validation.go +++ b/pkg/workflow/campaign_project_validation.go @@ -41,7 +41,10 @@ import ( var campaignProjectValidationLog = logger.New("workflow:campaign_project_validation") // validateCampaignProject checks if a workflow with campaign characteristics has a project URL configured -func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error { +// The project URL can be specified in two places with the following precedence: +// 1. Frontmatter 'project' field (source of truth) +// 2. Markdown body content (fallback) +func (c *Compiler) validateCampaignProject(frontmatter map[string]any, markdownContent string) error { campaignProjectValidationLog.Print("Checking campaign project requirements") // Check if this workflow has campaign characteristics @@ -53,33 +56,42 @@ func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error { campaignProjectValidationLog.Printf("Detected campaign workflow via %s", campaignSource) - // Check if project field exists + // Check if project field exists in frontmatter (source of truth) projectData, hasProject := frontmatter["project"] - if !hasProject || projectData == nil { - return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123). Campaign detected via: %s", campaignSource) + if hasProject && projectData != nil { + campaignProjectValidationLog.Print("Project field found in frontmatter (source of truth)") + // Validate frontmatter project field is not empty + switch v := projectData.(type) { + case string: + if strings.TrimSpace(v) == "" { + return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Printf("Valid project URL found in frontmatter: %s", v) + case map[string]any: + // Check if object has a URL field + if url, hasURL := v["url"]; !hasURL || url == nil { + return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource) + } else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" { + return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Print("Valid project configuration object found in frontmatter") + default: + return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Print("Campaign project validation passed (frontmatter)") + return nil } - // Validate project field is not empty - switch v := projectData.(type) { - case string: - if strings.TrimSpace(v) == "" { - return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource) - } - campaignProjectValidationLog.Printf("Valid project URL found: %s", v) - case map[string]any: - // Check if object has a URL field - if url, hasURL := v["url"]; !hasURL || url == nil { - return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource) - } else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" { - return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource) - } - campaignProjectValidationLog.Print("Valid project configuration object found") - default: - return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource) + // Fallback: Look for project URL in markdown content + campaignProjectValidationLog.Print("No project field in frontmatter, checking markdown content for project URL") + if hasProjectURLInMarkdown(markdownContent) { + campaignProjectValidationLog.Print("Valid project URL found in markdown content (fallback)") + campaignProjectValidationLog.Print("Campaign project validation passed (markdown fallback)") + return nil } - campaignProjectValidationLog.Print("Campaign project validation passed") - return nil + // No project URL found in either frontmatter or markdown + return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123), or include a project URL in the markdown body. Campaign detected via: %s", campaignSource) } // detectCampaignWorkflow checks if a workflow has campaign characteristics @@ -98,6 +110,23 @@ func detectCampaignWorkflow(frontmatter map[string]any) (bool, string) { return false, "" } +// hasProjectURLInMarkdown checks if the markdown content contains a GitHub Project URL +// This serves as a fallback when the project field is not in the frontmatter +func hasProjectURLInMarkdown(markdownContent string) bool { + // Use a simple string search for performance + // Check for the distinctive pattern of GitHub Project URLs + // Matches: https://github.com/orgs/{org}/projects/{number} + // or: https://github.com/users/{user}/projects/{number} + if strings.Contains(markdownContent, "https://github.com/orgs/") && strings.Contains(markdownContent, "/projects/") { + return true + } + if strings.Contains(markdownContent, "https://github.com/users/") && strings.Contains(markdownContent, "/projects/") { + return true + } + + return false +} + // hasCampaignLabels checks if safe-outputs configuration includes campaign labels func hasCampaignLabels(frontmatter map[string]any) bool { safeOutputs, ok := frontmatter["safe-outputs"].(map[string]any) diff --git a/pkg/workflow/campaign_project_validation_test.go b/pkg/workflow/campaign_project_validation_test.go index a2674ab521..935753a97e 100644 --- a/pkg/workflow/campaign_project_validation_test.go +++ b/pkg/workflow/campaign_project_validation_test.go @@ -12,10 +12,11 @@ import ( func TestValidateCampaignProject(t *testing.T) { tests := []struct { - name string - frontmatter map[string]any - expectError bool - errorMsg string + name string + frontmatter map[string]any + markdownContent string + expectError bool + errorMsg string }{ { name: "campaign with agentic-campaign label and project URL - valid", @@ -27,7 +28,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/123", }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign with z_campaign_ label and project URL - valid", @@ -39,7 +41,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/456", }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign with campaign-id in repo-memory and project URL - valid", @@ -51,7 +54,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/789", }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign with agentic-campaign label but no project - error", @@ -62,8 +66,9 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - expectError: true, - errorMsg: "campaign orchestrator requires a GitHub Project URL", + markdownContent: "", + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", }, { name: "campaign with z_campaign_ label but empty project URL - error", @@ -75,8 +80,9 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "", }, - expectError: true, - errorMsg: "requires a non-empty GitHub Project URL", + markdownContent: "", + expectError: true, + errorMsg: "requires a non-empty GitHub Project URL", }, { name: "campaign with campaign-id but nil project - error", @@ -88,8 +94,9 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": nil, }, - expectError: true, - errorMsg: "campaign orchestrator requires a GitHub Project URL", + markdownContent: "", + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", }, { name: "campaign with project config object - valid", @@ -104,7 +111,8 @@ func TestValidateCampaignProject(t *testing.T) { "max-updates": 100, }, }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign with project config but missing URL - error", @@ -120,8 +128,9 @@ func TestValidateCampaignProject(t *testing.T) { "max-updates": 100, }, }, - expectError: true, - errorMsg: "project configuration must include a 'url' field", + markdownContent: "", + expectError: true, + errorMsg: "project configuration must include a 'url' field", }, { name: "non-campaign workflow without project - valid", @@ -132,7 +141,8 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "workflow with regular labels - valid", @@ -143,7 +153,8 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign with multiple repo-memory entries - valid", @@ -160,7 +171,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/999", }, - expectError: false, + markdownContent: "", + expectError: false, }, { name: "campaign via create-discussion labels - valid", @@ -172,14 +184,65 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/111", }, - expectError: false, + markdownContent: "", + expectError: false, + }, + { + name: "campaign with project URL in markdown (orgs) - valid fallback", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + markdownContent: "Track progress at https://github.com/orgs/myorg/projects/144", + expectError: false, + }, + { + name: "campaign with project URL in markdown (users) - valid fallback", + frontmatter: map[string]any{ + "tools": map[string]any{ + "repo-memory": map[string]any{ + "campaign-id": "test", + }, + }, + }, + markdownContent: "See https://github.com/users/myuser/projects/42 for details", + expectError: false, + }, + { + name: "campaign with frontmatter project (source of truth) even if markdown has URL", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + "project": "https://github.com/orgs/correct/projects/1", + }, + markdownContent: "Old URL: https://github.com/orgs/wrong/projects/999", + expectError: false, + }, + { + name: "campaign without project in frontmatter or markdown - error", + frontmatter: map[string]any{ + "safe-outputs": map[string]any{ + "add-labels": map[string]any{ + "allowed": []any{"agentic-campaign"}, + }, + }, + }, + markdownContent: "No project URL here", + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler() - err := compiler.validateCampaignProject(tt.frontmatter) + err := compiler.validateCampaignProject(tt.frontmatter, tt.markdownContent) if tt.expectError { require.Error(t, err, "Expected error but got none") @@ -191,6 +254,57 @@ func TestValidateCampaignProject(t *testing.T) { } } +func TestHasProjectURLInMarkdown(t *testing.T) { + tests := []struct { + name string + markdown string + expected bool + }{ + { + name: "project URL with orgs", + markdown: "Track at https://github.com/orgs/myorg/projects/123", + expected: true, + }, + { + name: "project URL with users", + markdown: "See https://github.com/users/john/projects/42", + expected: true, + }, + { + name: "no project URL", + markdown: "This is a regular workflow without project tracking", + expected: false, + }, + { + name: "github URL but not a project", + markdown: "See https://github.com/owner/repo", + expected: false, + }, + { + name: "partial match - has orgs but no projects", + markdown: "Visit https://github.com/orgs/myorg for more info", + expected: false, + }, + { + name: "partial match - has projects but no orgs", + markdown: "Check out our projects folder", + expected: false, + }, + { + name: "multiple project URLs", + markdown: "Old: https://github.com/orgs/old/projects/1\nNew: https://github.com/orgs/new/projects/2", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := hasProjectURLInMarkdown(tt.markdown) + assert.Equal(t, tt.expected, result, "Expected %v for markdown: %s", tt.expected, tt.markdown) + }) + } +} + func TestDetectCampaignWorkflow(t *testing.T) { tests := []struct { name string @@ -487,13 +601,13 @@ func TestCampaignValidationIntegration(t *testing.T) { } compiler := NewCompiler() - err := compiler.validateCampaignProject(frontmatter) + err := compiler.validateCampaignProject(frontmatter, "") require.Error(t, err, "Should fail validation without project URL") assert.Contains(t, err.Error(), "campaign orchestrator requires a GitHub Project URL") // Add project URL and verify it passes frontmatter["project"] = "https://github.com/orgs/test/projects/144" - err = compiler.validateCampaignProject(frontmatter) + err = compiler.validateCampaignProject(frontmatter, "") assert.NoError(t, err, "Should pass validation with project URL") } @@ -538,7 +652,7 @@ func TestCampaignValidationErrorMessages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler() - err := compiler.validateCampaignProject(tt.frontmatter) + err := compiler.validateCampaignProject(tt.frontmatter, "") require.Error(t, err, "Should fail validation") errMsg := err.Error() diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index d6889cdd82..bf08b0a3ff 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -81,7 +81,7 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean // Validate campaign orchestrator project requirements (applies to all workflows, not just strict mode) orchestratorEngineLog.Print("Validating campaign orchestrator project requirements") - if err := c.validateCampaignProject(result.Frontmatter); err != nil { + if err := c.validateCampaignProject(result.Frontmatter, result.Markdown); err != nil { orchestratorEngineLog.Printf("Campaign project validation failed: %v", err) return nil, err } From 077f440b1e937e3922733933f2728b7237f69eed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:50:08 +0000 Subject: [PATCH 5/6] Revert markdown fallback logic for project URLs - Remove markdown body fallback for project URL detection - Keep only frontmatter validation (original logic) - Frontmatter 'project' field is now required for campaigns Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/workflow/campaign_project_validation.go | 75 +++----- .../campaign_project_validation_test.go | 162 +++--------------- pkg/workflow/compiler_orchestrator_engine.go | 2 +- 3 files changed, 48 insertions(+), 191 deletions(-) diff --git a/pkg/workflow/campaign_project_validation.go b/pkg/workflow/campaign_project_validation.go index 67efad8fde..690a2ec021 100644 --- a/pkg/workflow/campaign_project_validation.go +++ b/pkg/workflow/campaign_project_validation.go @@ -41,10 +41,7 @@ import ( var campaignProjectValidationLog = logger.New("workflow:campaign_project_validation") // validateCampaignProject checks if a workflow with campaign characteristics has a project URL configured -// The project URL can be specified in two places with the following precedence: -// 1. Frontmatter 'project' field (source of truth) -// 2. Markdown body content (fallback) -func (c *Compiler) validateCampaignProject(frontmatter map[string]any, markdownContent string) error { +func (c *Compiler) validateCampaignProject(frontmatter map[string]any) error { campaignProjectValidationLog.Print("Checking campaign project requirements") // Check if this workflow has campaign characteristics @@ -56,42 +53,33 @@ func (c *Compiler) validateCampaignProject(frontmatter map[string]any, markdownC campaignProjectValidationLog.Printf("Detected campaign workflow via %s", campaignSource) - // Check if project field exists in frontmatter (source of truth) + // Check if project field exists projectData, hasProject := frontmatter["project"] - if hasProject && projectData != nil { - campaignProjectValidationLog.Print("Project field found in frontmatter (source of truth)") - // Validate frontmatter project field is not empty - switch v := projectData.(type) { - case string: - if strings.TrimSpace(v) == "" { - return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource) - } - campaignProjectValidationLog.Printf("Valid project URL found in frontmatter: %s", v) - case map[string]any: - // Check if object has a URL field - if url, hasURL := v["url"]; !hasURL || url == nil { - return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource) - } else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" { - return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource) - } - campaignProjectValidationLog.Print("Valid project configuration object found in frontmatter") - default: - return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource) - } - campaignProjectValidationLog.Print("Campaign project validation passed (frontmatter)") - return nil + if !hasProject || projectData == nil { + return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123). Campaign detected via: %s", campaignSource) } - // Fallback: Look for project URL in markdown content - campaignProjectValidationLog.Print("No project field in frontmatter, checking markdown content for project URL") - if hasProjectURLInMarkdown(markdownContent) { - campaignProjectValidationLog.Print("Valid project URL found in markdown content (fallback)") - campaignProjectValidationLog.Print("Campaign project validation passed (markdown fallback)") - return nil + // Validate project field is not empty + switch v := projectData.(type) { + case string: + if strings.TrimSpace(v) == "" { + return fmt.Errorf("campaign orchestrator requires a non-empty GitHub Project URL. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Printf("Valid project URL found: %s", v) + case map[string]any: + // Check if object has a URL field + if url, hasURL := v["url"]; !hasURL || url == nil { + return fmt.Errorf("campaign orchestrator project configuration must include a 'url' field with a valid GitHub Project URL. Campaign detected via: %s", campaignSource) + } else if urlStr, ok := url.(string); !ok || strings.TrimSpace(urlStr) == "" { + return fmt.Errorf("campaign orchestrator project URL must be a non-empty string. Campaign detected via: %s", campaignSource) + } + campaignProjectValidationLog.Print("Valid project configuration object found") + default: + return fmt.Errorf("campaign orchestrator 'project' field must be a string URL or configuration object. Campaign detected via: %s", campaignSource) } - // No project URL found in either frontmatter or markdown - return fmt.Errorf("campaign orchestrator requires a GitHub Project URL to track work items. Please add a 'project' field to the frontmatter with a valid GitHub Project URL (e.g., project: https://github.com/orgs/myorg/projects/123), or include a project URL in the markdown body. Campaign detected via: %s", campaignSource) + campaignProjectValidationLog.Print("Campaign project validation passed") + return nil } // detectCampaignWorkflow checks if a workflow has campaign characteristics @@ -110,23 +98,6 @@ func detectCampaignWorkflow(frontmatter map[string]any) (bool, string) { return false, "" } -// hasProjectURLInMarkdown checks if the markdown content contains a GitHub Project URL -// This serves as a fallback when the project field is not in the frontmatter -func hasProjectURLInMarkdown(markdownContent string) bool { - // Use a simple string search for performance - // Check for the distinctive pattern of GitHub Project URLs - // Matches: https://github.com/orgs/{org}/projects/{number} - // or: https://github.com/users/{user}/projects/{number} - if strings.Contains(markdownContent, "https://github.com/orgs/") && strings.Contains(markdownContent, "/projects/") { - return true - } - if strings.Contains(markdownContent, "https://github.com/users/") && strings.Contains(markdownContent, "/projects/") { - return true - } - - return false -} - // hasCampaignLabels checks if safe-outputs configuration includes campaign labels func hasCampaignLabels(frontmatter map[string]any) bool { safeOutputs, ok := frontmatter["safe-outputs"].(map[string]any) diff --git a/pkg/workflow/campaign_project_validation_test.go b/pkg/workflow/campaign_project_validation_test.go index 935753a97e..a2674ab521 100644 --- a/pkg/workflow/campaign_project_validation_test.go +++ b/pkg/workflow/campaign_project_validation_test.go @@ -12,11 +12,10 @@ import ( func TestValidateCampaignProject(t *testing.T) { tests := []struct { - name string - frontmatter map[string]any - markdownContent string - expectError bool - errorMsg string + name string + frontmatter map[string]any + expectError bool + errorMsg string }{ { name: "campaign with agentic-campaign label and project URL - valid", @@ -28,8 +27,7 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/123", }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign with z_campaign_ label and project URL - valid", @@ -41,8 +39,7 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/456", }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign with campaign-id in repo-memory and project URL - valid", @@ -54,8 +51,7 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/789", }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign with agentic-campaign label but no project - error", @@ -66,9 +62,8 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - markdownContent: "", - expectError: true, - errorMsg: "campaign orchestrator requires a GitHub Project URL", + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", }, { name: "campaign with z_campaign_ label but empty project URL - error", @@ -80,9 +75,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "", }, - markdownContent: "", - expectError: true, - errorMsg: "requires a non-empty GitHub Project URL", + expectError: true, + errorMsg: "requires a non-empty GitHub Project URL", }, { name: "campaign with campaign-id but nil project - error", @@ -94,9 +88,8 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": nil, }, - markdownContent: "", - expectError: true, - errorMsg: "campaign orchestrator requires a GitHub Project URL", + expectError: true, + errorMsg: "campaign orchestrator requires a GitHub Project URL", }, { name: "campaign with project config object - valid", @@ -111,8 +104,7 @@ func TestValidateCampaignProject(t *testing.T) { "max-updates": 100, }, }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign with project config but missing URL - error", @@ -128,9 +120,8 @@ func TestValidateCampaignProject(t *testing.T) { "max-updates": 100, }, }, - markdownContent: "", - expectError: true, - errorMsg: "project configuration must include a 'url' field", + expectError: true, + errorMsg: "project configuration must include a 'url' field", }, { name: "non-campaign workflow without project - valid", @@ -141,8 +132,7 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "workflow with regular labels - valid", @@ -153,8 +143,7 @@ func TestValidateCampaignProject(t *testing.T) { }, }, }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign with multiple repo-memory entries - valid", @@ -171,8 +160,7 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/999", }, - markdownContent: "", - expectError: false, + expectError: false, }, { name: "campaign via create-discussion labels - valid", @@ -184,65 +172,14 @@ func TestValidateCampaignProject(t *testing.T) { }, "project": "https://github.com/orgs/test/projects/111", }, - markdownContent: "", - expectError: false, - }, - { - name: "campaign with project URL in markdown (orgs) - valid fallback", - frontmatter: map[string]any{ - "safe-outputs": map[string]any{ - "add-labels": map[string]any{ - "allowed": []any{"agentic-campaign"}, - }, - }, - }, - markdownContent: "Track progress at https://github.com/orgs/myorg/projects/144", - expectError: false, - }, - { - name: "campaign with project URL in markdown (users) - valid fallback", - frontmatter: map[string]any{ - "tools": map[string]any{ - "repo-memory": map[string]any{ - "campaign-id": "test", - }, - }, - }, - markdownContent: "See https://github.com/users/myuser/projects/42 for details", - expectError: false, - }, - { - name: "campaign with frontmatter project (source of truth) even if markdown has URL", - frontmatter: map[string]any{ - "safe-outputs": map[string]any{ - "add-labels": map[string]any{ - "allowed": []any{"agentic-campaign"}, - }, - }, - "project": "https://github.com/orgs/correct/projects/1", - }, - markdownContent: "Old URL: https://github.com/orgs/wrong/projects/999", - expectError: false, - }, - { - name: "campaign without project in frontmatter or markdown - error", - frontmatter: map[string]any{ - "safe-outputs": map[string]any{ - "add-labels": map[string]any{ - "allowed": []any{"agentic-campaign"}, - }, - }, - }, - markdownContent: "No project URL here", - expectError: true, - errorMsg: "campaign orchestrator requires a GitHub Project URL", + expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler() - err := compiler.validateCampaignProject(tt.frontmatter, tt.markdownContent) + err := compiler.validateCampaignProject(tt.frontmatter) if tt.expectError { require.Error(t, err, "Expected error but got none") @@ -254,57 +191,6 @@ func TestValidateCampaignProject(t *testing.T) { } } -func TestHasProjectURLInMarkdown(t *testing.T) { - tests := []struct { - name string - markdown string - expected bool - }{ - { - name: "project URL with orgs", - markdown: "Track at https://github.com/orgs/myorg/projects/123", - expected: true, - }, - { - name: "project URL with users", - markdown: "See https://github.com/users/john/projects/42", - expected: true, - }, - { - name: "no project URL", - markdown: "This is a regular workflow without project tracking", - expected: false, - }, - { - name: "github URL but not a project", - markdown: "See https://github.com/owner/repo", - expected: false, - }, - { - name: "partial match - has orgs but no projects", - markdown: "Visit https://github.com/orgs/myorg for more info", - expected: false, - }, - { - name: "partial match - has projects but no orgs", - markdown: "Check out our projects folder", - expected: false, - }, - { - name: "multiple project URLs", - markdown: "Old: https://github.com/orgs/old/projects/1\nNew: https://github.com/orgs/new/projects/2", - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := hasProjectURLInMarkdown(tt.markdown) - assert.Equal(t, tt.expected, result, "Expected %v for markdown: %s", tt.expected, tt.markdown) - }) - } -} - func TestDetectCampaignWorkflow(t *testing.T) { tests := []struct { name string @@ -601,13 +487,13 @@ func TestCampaignValidationIntegration(t *testing.T) { } compiler := NewCompiler() - err := compiler.validateCampaignProject(frontmatter, "") + err := compiler.validateCampaignProject(frontmatter) require.Error(t, err, "Should fail validation without project URL") assert.Contains(t, err.Error(), "campaign orchestrator requires a GitHub Project URL") // Add project URL and verify it passes frontmatter["project"] = "https://github.com/orgs/test/projects/144" - err = compiler.validateCampaignProject(frontmatter, "") + err = compiler.validateCampaignProject(frontmatter) assert.NoError(t, err, "Should pass validation with project URL") } @@ -652,7 +538,7 @@ func TestCampaignValidationErrorMessages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler() - err := compiler.validateCampaignProject(tt.frontmatter, "") + err := compiler.validateCampaignProject(tt.frontmatter) require.Error(t, err, "Should fail validation") errMsg := err.Error() diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go index bf08b0a3ff..d6889cdd82 100644 --- a/pkg/workflow/compiler_orchestrator_engine.go +++ b/pkg/workflow/compiler_orchestrator_engine.go @@ -81,7 +81,7 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean // Validate campaign orchestrator project requirements (applies to all workflows, not just strict mode) orchestratorEngineLog.Print("Validating campaign orchestrator project requirements") - if err := c.validateCampaignProject(result.Frontmatter, result.Markdown); err != nil { + if err := c.validateCampaignProject(result.Frontmatter); err != nil { orchestratorEngineLog.Printf("Campaign project validation failed: %v", err) return nil, err } From cb17d5bfc61d1060326150c3c922d0dcfcf2ffdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:31:51 +0000 Subject: [PATCH 6/6] Align campaign detection with safe-output patterns - Keep campaign detection focused on explicit campaign indicators - Campaign labels (agentic-campaign, z_campaign_*) in safe-outputs - Campaign-id in repo-memory configuration - Don't use update-project as detection (avoids false positives) - Fix tests to add project URL where needed Co-authored-by: mnkiefer <8320933+mnkiefer@users.noreply.github.com> --- pkg/workflow/campaign_project_validation_test.go | 1 + pkg/workflow/update_project_handler_config_test.go | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/workflow/campaign_project_validation_test.go b/pkg/workflow/campaign_project_validation_test.go index a2674ab521..28a7406779 100644 --- a/pkg/workflow/campaign_project_validation_test.go +++ b/pkg/workflow/campaign_project_validation_test.go @@ -549,3 +549,4 @@ func TestCampaignValidationErrorMessages(t *testing.T) { }) } } + diff --git a/pkg/workflow/update_project_handler_config_test.go b/pkg/workflow/update_project_handler_config_test.go index 742273a158..ffba2ff5c6 100644 --- a/pkg/workflow/update_project_handler_config_test.go +++ b/pkg/workflow/update_project_handler_config_test.go @@ -19,6 +19,7 @@ func TestUpdateProjectHandlerConfigIncludesFieldDefinitions(t *testing.T) { name: Test Update Project Handler Config on: workflow_dispatch engine: copilot +project: https://github.com/orgs/test/projects/123 safe-outputs: update-project: max: 1