diff --git a/.github/aw/bootstrap-agentic-campaign.md b/.github/aw/bootstrap-agentic-campaign.md deleted file mode 100644 index 4ebec8d8e19..00000000000 --- a/.github/aw/bootstrap-agentic-campaign.md +++ /dev/null @@ -1,154 +0,0 @@ -# Bootstrap Instructions (Phase 0) - -This phase runs when discovery returns zero work items, providing initial work for the campaign to begin. - -{{ if .BootstrapMode }} -## Bootstrap Strategy: {{ .BootstrapMode }} - -{{ if eq .BootstrapMode "seeder-worker" }} -### Seeder Worker Dispatch - -When no work items are discovered, dispatch a seeder/scanner worker to discover initial work: - -**Worker ID**: `{{ .SeederWorkerID }}` - -**Payload**: -```json -{{ .SeederPayload }} -``` - -{{ if gt .SeederMaxItems 0 }} -**Max Items**: {{ .SeederMaxItems }} (limit how many items the seeder returns) -{{ end }} - -**Implementation Steps**: - -1. Check if discovery returned zero items (no worker outputs found) -2. If zero items: - - Use the `dispatch_workflow` safe output to trigger the seeder worker - - Pass the campaign_id and configured payload - - Wait for the seeder worker to complete and create initial work items -3. On next orchestrator run, the discovery step will find the seeder's outputs - -**Seeder Worker Contract**: -- MUST accept `campaign_id` and `payload` inputs (standard worker contract) -- MUST create discoverable outputs (issues, PRs, or discussions) -- MUST apply the tracker label: `z_campaign_{{ .CampaignID }}` -- SHOULD limit output count to configured max-items if provided -- SHOULD use deterministic work item keys for idempotency - -{{ else if eq .BootstrapMode "project-todos" }} -### Project Board Todo Items - -When no work items are discovered, read from the Project board's "{{ .TodoValue }}" column: - -{{ if .StatusField }} -**Status Field**: `{{ .StatusField }}` -{{ else }} -**Status Field**: `Status` (default) -{{ end }} - -{{ if .TodoValue }} -**Todo Value**: `{{ .TodoValue }}` -{{ else }} -**Todo Value**: `Todo` (default) -{{ end }} - -{{ if gt .TodoMaxItems 0 }} -**Max Items**: {{ .TodoMaxItems }} (limit how many Todo items to process) -{{ end }} - -{{ if .RequireFields }} -**Required Fields**: {{ range $index, $field := .RequireFields }}{{ if $index }}, {{ end }}`{{ $field }}`{{ end }} - - Skip Todo items where any of these fields are empty -{{ end }} - -**Implementation Steps**: - -1. Check if discovery returned zero items (no worker outputs found) -2. If zero items: - - Query the Project board at `{{ .ProjectURL }}` - - Filter items where Status = "{{ .TodoValue }}" - {{ if .RequireFields }}- Skip items missing required fields{{ end }} - {{ if gt .TodoMaxItems 0 }}- Limit to {{ .TodoMaxItems }} items{{ end }} - - Select workers based on item metadata (see Worker Selection below) - - Dispatch appropriate worker workflows for each Todo item -3. Update Project status to "In Progress" for dispatched items -4. On next orchestrator run, the discovery step will find the worker outputs - -**Project Item to Payload Mapping**: -- Read Project field values from the Todo item -- Map to worker payload schema based on worker metadata -- Include campaign_id in every worker dispatch -- Use the item's URL or number as the work_item_id - -{{ else if eq .BootstrapMode "manual" }} -### Manual Bootstrap - -No automatic bootstrap configured. Wait for manual work item creation: - -- Work items should be created manually (issues, PRs, or discussions) -- All items MUST have the tracker label: `z_campaign_{{ .CampaignID }}` -- Items MUST follow the worker output labeling contract -- Once items exist, the orchestrator will discover them normally - -{{ end }} -{{ end }} - ---- - -## Worker Selection - -{{ if .WorkerMetadata }} -When dispatching workers during bootstrap, use deterministic selection: - -{{ range $index, $worker := .WorkerMetadata }} -### Worker {{ add1 $index }}: {{ .ID }} - -**Capabilities**: {{ range $capIndex, $cap := .Capabilities }}{{ if $capIndex }}, {{ end }}`{{ $cap }}`{{ end }} - -**Payload Schema**: -{{ range $fieldName, $fieldDef := .PayloadSchema }}- `{{ $fieldName }}` ({{ .Type }}{{ if .Required }}, required{{ end }}): {{ .Description }} -{{ end }} - -**Output Labeling**: -{{ if .OutputLabeling.Labels }}- Labels: {{ range $labelIndex, $label := .OutputLabeling.Labels }}{{ if $labelIndex }}, {{ end }}`{{ $label }}`{{ end }} -{{ end }}- Key in Title: {{ .OutputLabeling.KeyInTitle }} -{{ if .OutputLabeling.KeyFormat }}- Key Format: `{{ .OutputLabeling.KeyFormat }}` -{{ end }} -- Campaign tracker label applied automatically: `z_campaign_{{ $.CampaignID }}` - -**Idempotency Strategy**: {{ .IdempotencyStrategy }} - -{{ if .Priority }}**Priority**: {{ .Priority }} (higher = preferred when multiple workers match) -{{ end }} - -{{ end }} - -**Selection Algorithm**: - -1. For each work item, check which workers can handle it: - - Match work item type/metadata to worker capabilities - - Check if worker's payload schema requirements can be satisfied -2. If multiple workers match: - - Select the worker with highest priority - - If priorities are equal, select first alphabetically by ID -3. Build payload from work item metadata according to worker's payload schema -4. Dispatch worker with campaign_id and constructed payload - -{{ else }} -**Note**: No worker metadata configured. Use workflow IDs from campaign spec for dispatch. -{{ end }} - ---- - -## Bootstrap Success Criteria - -After bootstrap completes: - -1. ✅ At least one worker workflow dispatched (or manual items created) -2. ✅ All dispatched items will have proper tracker labels -3. ✅ Next orchestrator run will discover >= 1 work item -4. ✅ Campaign transitions from bootstrap phase to normal operation - -**Idempotency**: Bootstrap only runs when discovery = 0. Once work items exist, normal orchestration takes over. diff --git a/.github/aw/close-agentic-campaign.md b/.github/aw/close-agentic-campaign.md deleted file mode 100644 index f08b2179660..00000000000 --- a/.github/aw/close-agentic-campaign.md +++ /dev/null @@ -1,20 +0,0 @@ -# Closing Instructions (Highest Priority) - -Execute all four steps in strict order: - -1. Read State (no writes) -2. Make Decisions (no writes) -3. Apply Updates (writes) -4. Report - -The following rules are mandatory and override inferred behavior: - -- The GitHub Project board is the single source of truth. -- All project writes MUST comply with the Project Update Instructions. -- State reads and state writes MUST NOT be interleaved. -- Do NOT infer missing data or invent values. -- Do NOT reorganize hierarchy. -- Do NOT overwrite fields except as explicitly allowed. -- Workers are immutable and campaign-agnostic. - -If any instruction conflicts, the Project Update Instructions take precedence for all writes. diff --git a/.github/aw/execute-agentic-campaign-workflow.md b/.github/aw/execute-agentic-campaign-workflow.md deleted file mode 100644 index 921a6120647..00000000000 --- a/.github/aw/execute-agentic-campaign-workflow.md +++ /dev/null @@ -1,284 +0,0 @@ -# Workflow Execution - -This campaign references the following campaign workers. These workers follow the first-class worker pattern: they are dispatch-only workflows with standardized input contracts. - -**IMPORTANT: Workers are orchestrated, not autonomous. They accept `campaign_id` and `payload` inputs via workflow_dispatch.** - ---- - -## Campaign Workers - -{{ if .Workflows }} -The following campaign workers are referenced by this campaign: -{{ range $idx, $workflow := .Workflows }} -{{ add1 $idx }}. `{{ $workflow }}` -{{ end }} -{{ end }} - -**Worker Pattern**: All workers MUST: -- Use `workflow_dispatch` as the ONLY trigger (no schedule/push/pull_request) -- Accept `campaign_id` (string) and `payload` (string; JSON) inputs -- Implement idempotency via deterministic work item keys -- Label all created items with `z_campaign_{{ .CampaignID }}` - ---- - -## Workflow Creation Guardrails - -### Before Creating Any Worker Workflow, Ask: - -1. **Does this workflow already exist?** - Check `.github/workflows/` thoroughly -2. **Can an existing workflow be adapted?** - Even if not perfect, existing is safer -3. **Is the requirement clear?** - Can you articulate exactly what it should do? -4. **Is it testable?** - Can you verify it works with test inputs? -5. **Is it reusable?** - Could other campaigns benefit from this worker? - -### Only Create New Workers When: - -✅ **All these conditions are met:** -- No existing workflow does the required task -- The campaign objective explicitly requires this capability -- You have a clear, specific design for the worker -- The worker has a focused, single-purpose scope -- You can test it independently before campaign use - -❌ **Never create workers when:** -- You're unsure about requirements -- An existing workflow "mostly" works -- The worker would be complex or multi-purpose -- You haven't verified it doesn't already exist -- You can't clearly explain what it does in one sentence - ---- - -## Worker Creation Template - -If you must create a new worker (only after checking ALL guardrails above), use this template: - -**Create the workflow file at `.github/workflows/.md`:** - -```yaml ---- -name: -description: - -on: - workflow_dispatch: - inputs: - campaign_id: - description: 'Campaign identifier' - required: true - type: string - payload: - description: 'JSON payload with work item details' - required: true - type: string - -tracker-id: - -tools: - github: - toolsets: [default] - # Add minimal additional tools as needed - -safe-outputs: - create-pull-request: - max: 1 # Start conservative - add-comment: - max: 2 ---- - -# - -You are a campaign worker that processes work items. - -## Input Contract - -Parse inputs: -```javascript -const campaignId = context.payload.inputs.campaign_id; -const payload = JSON.parse(context.payload.inputs.payload); -``` - -Expected payload structure: -```json -{ - "repository": "owner/repo", - "work_item_id": "unique-id", - "target_ref": "main", - // Additional context... -} -``` - -## Idempotency Requirements - -1. **Generate deterministic key**: - ``` - const workKey = `campaign-${campaignId}-${payload.repository}-${payload.work_item_id}`; - ``` - -2. **Check for existing work**: - - Search for PRs/issues with `workKey` in title - - Filter by label: `z_campaign_${campaignId}` - - If found: Skip or update - - If not: Create new - -3. **Label all created items**: - - Apply `z_campaign_${campaignId}` label - - This enables discovery by orchestrator - -## Task - - - -## Output - -Report: -- Link to created/updated PR or issue -- Whether work was skipped (exists) or completed -- Any errors or blockers -``` - -**After creating:** -- Compile: `gh aw compile .md` -- **CRITICAL: Test with sample inputs** (see testing requirements below) - ---- - -## Worker Testing (MANDATORY) - -**Why test?** - Untested workers may fail during campaign execution. Test with sample inputs first to catch issues early. - -**Testing steps:** - -1. **Prepare test payload**: - ```json - { - "repository": "test-org/test-repo", - "work_item_id": "test-1", - "target_ref": "main" - } - ``` - -2. **Trigger test run**: - ```bash - gh workflow run .yml \ - -f campaign_id={{ .CampaignID }} \ - -f payload='{"repository":"test-org/test-repo","work_item_id":"test-1"}' - ``` - - Or via GitHub MCP: - ```javascript - mcp__github__run_workflow( - workflow_id: "", - ref: "main", - inputs: { - campaign_id: "{{ .CampaignID }}", - payload: JSON.stringify({repository: "test-org/test-repo", work_item_id: "test-1"}) - } - ) - ``` - -3. **Wait for completion**: Poll until status is "completed" - -4. **Verify success**: - - Check that workflow succeeded - - Verify idempotency: Run again with same inputs, should skip/update - - Review created items have correct labels - - Confirm deterministic keys are used - -5. **Test failure actions**: - - DO NOT use the worker if testing fails - - Analyze failure logs - - Make corrections - - Recompile and retest - - If unfixable after 2 attempts, report in status and skip - -**Note**: Workflows that accept `workflow_dispatch` inputs can receive parameters from the orchestrator. This enables the orchestrator to provide context, priorities, or targets based on its decisions. See [DispatchOps documentation](https://githubnext.github.io/gh-aw/guides/dispatchops/#with-input-parameters) for input parameter examples. - ---- - -## Orchestration Guidelines - -**Execution pattern:** -- Workers are **orchestrated, not autonomous** -- Orchestrator discovers work items via discovery manifest -- Orchestrator decides which workers to run and with what inputs -- Workers receive `campaign_id` and `payload` via workflow_dispatch -- Sequential vs parallel execution is orchestrator's decision - -**Worker dispatch:** -- Parse discovery manifest (`./.gh-aw/campaign.discovery.json`) -- For each work item needing processing: - 1. Determine appropriate worker for this item type - 2. Construct payload with work item details - 3. Dispatch worker via workflow_dispatch with campaign_id and payload - 4. Track dispatch status - -**Input construction:** -```javascript -// Example: Dispatching security-fix worker -const workItem = discoveryManifest.items[0]; -const payload = { - repository: workItem.repo, - work_item_id: `alert-${workItem.number}`, - target_ref: "main", - alert_type: "sql-injection", - file_path: "src/db.go", - line_number: 42 -}; - -await github.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: "security-fix-worker.yml", - ref: "main", - inputs: { - campaign_id: "{{ .CampaignID }}", - payload: JSON.stringify(payload) - } -}); -``` - -**Idempotency by design:** -- Workers implement their own idempotency checks -- Orchestrator doesn't need to track what's been processed -- Can safely re-dispatch work items across runs -- Workers will skip or update existing items - -**Failure handling:** -- If a worker dispatch fails, note it but continue -- Worker failures don't block entire campaign -- Report all failures in status update with context -- Humans can intervene if needed - ---- - -## After Worker Orchestration - -Once workers have been dispatched (or new workers created and tested), proceed with normal orchestrator steps: - -1. **Discovery** - Read state from discovery manifest and project board -2. **Planning** - Determine what needs updating on project board -3. **Project Updates** - Write state changes to project board -4. **Status Reporting** - Report progress, worker dispatches, failures, next steps - ---- - -## Key Differences from Fusion Approach - -**Old fusion approach (REMOVED)**: -- Workers had mixed triggers (schedule + workflow_dispatch) -- Fusion dynamically added workflow_dispatch to existing workflows -- Workers stored in campaign-specific folders -- Ambiguous ownership and trigger precedence - -**New first-class worker approach**: -- Workers are dispatch-only (on: workflow_dispatch) -- Standardized input contract (campaign_id, payload) -- Explicit idempotency via deterministic keys -- Clear ownership: workers are orchestrated, not autonomous -- Workers stored with regular workflows (not campaign-specific folders) -- Orchestration policy kept explicit in orchestrator - -This eliminates duplicate execution problems and makes orchestration concerns explicit. diff --git a/.github/aw/generate-agentic-campaign.md b/.github/aw/generate-agentic-campaign.md deleted file mode 100644 index 56b7cd9e2e8..00000000000 --- a/.github/aw/generate-agentic-campaign.md +++ /dev/null @@ -1,210 +0,0 @@ -# Campaign Generator - -You are a campaign workflow coordinator for GitHub Agentic Workflows. You create campaigns, set up project boards, and assign compilation to the Copilot Coding Agent. - -**Issue Context:** Read the campaign requirements from the issue that triggered this workflow (via the `create-agentic-campaign` label). - -## Using Safe Output Tools - -When creating or modifying GitHub resources, **use MCP tool calls directly** (not markdown or JSON): - -- `create_project` - Create project board -- `update_project` - Create/update project fields, views, and items -- `update_issue` - Update issue details -- `create_agent_session` - Create a Copilot coding agent session (preferred handoff) -- `assign_to_agent` - Assign to agent (optional; use for existing issues/PRs) - -## Workflow - -**Your Responsibilities:** - -1. Create GitHub Project -2. Create views: Roadmap (roadmap), Task Tracker (table), Progress Board (board) -3. Create required campaign project fields (see “Project Fields (Required)”) using `update_project` with `operation: "create_fields"` -4. Parse campaign requirements from the triggering issue (available via GitHub event context) -5. Discover workflows: scan `.github/workflows/*.md` and check [agentics collection](https://github.com/githubnext/agentics) -6. Generate `.campaign.md` spec in `.github/workflows/` -7. Update the triggering issue with a human-readable status + Copilot Coding Agent instructions -8. Create a Copilot coding agent session (preferred) or assign to agent (fallback) - -**Agent Responsibilities:** Compile with `gh aw compile`, commit files, create PR - -## Campaign Spec Format - -```yaml ---- -id: -name: -description: -project-url: -workflows: [, ] -scope: [owner/repo1, owner/repo2, org:org-name] # Optional: defaults to current repository -owners: [@] -risk-level: -state: planned -allowed-safe-outputs: [create-issue, add-comment] ---- - -# - - - -## Workflows - -### - - -## Timeline -- **Start**: -- **Target**: -``` - -## Key Guidelines - -## Project Fields (Required) - -Campaign orchestrators and project-updaters assume these fields exist. Create them up-front with `update_project` using `operation: "create_fields"` and `field_definitions` so single-select options are created correctly (GitHub does not support adding options later). - -Required fields: - -- `status` (single-select): `Todo`, `In Progress`, `Review required`, `Blocked`, `Done` -- `campaign_id` (text) -- `worker_workflow` (text) -- `target_repo` (text, `owner/repo`) -- `priority` (single-select): `High`, `Medium`, `Low` -- `size` (single-select): `Small`, `Medium`, `Large` -- `start_date` (date, `YYYY-MM-DD`) -- `end_date` (date, `YYYY-MM-DD`) - -Create them before adding any items to the project. - -## Copilot Coding Agent Handoff (Required) - -Before creating an agent session, update the triggering issue (via `update_issue`) to include a clear, human-friendly status update. - -The issue update MUST be easy to follow for someone unfamiliar with campaigns. Include: - -- What you did (Project created, fields/views created, spec generated) -- What you are about to do next (handoff to agent) -- What the human should do next (review PR, merge, run orchestrator) -- Links to documentation - -Use `update_issue` with `operation: "append"` so you **do not overwrite** the original issue text. - -Docs to link: -- Getting started: https://githubnext.github.io/gh-aw/guides/campaigns/getting-started/ -- Flow & lifecycle: https://githubnext.github.io/gh-aw/guides/campaigns/flow/ -- Campaign specs: https://githubnext.github.io/gh-aw/guides/campaigns/scratchpad/ - -### Required structure for the issue update - -Add a section like this (fill in real values): - -```markdown -## Campaign setup status - -**Status:** Ready for PR review - -### What just happened -- Created Project: -- Created standard fields + views (Roadmap, Task Tracker, Progress Board) -- Generated campaign spec: `.github/workflows/.campaign.md` -- Selected workflows: ``, `` - -### What happens next -1. Copilot Coding Agent will open a pull request with the generated files (via agent session). -2. You review the PR and merge it. -3. After merge, run the orchestrator workflow from the Actions tab. - -### Copilot Coding Agent handoff -- **Campaign ID:** `` -- **Project URL:** -- **Workflows:** ``, `` -- **Agent session:** - -Run: -```bash -gh aw compile -``` - -Commit + include in the PR: -- `.github/workflows/.campaign.md` -- `.github/workflows/.campaign.g.md` -- `.github/workflows/.campaign.lock.yml` - -Acceptance checklist: -- `gh aw compile` succeeds -- Orchestrator lock file updated -- PR opened and linked back to this issue - -Docs: -- https://githubnext.github.io/gh-aw/guides/campaigns/getting-started/ -- https://githubnext.github.io/gh-aw/guides/campaigns/flow/ -``` - -### Minimum handoff requirements - -In addition to the structure above, include these exact items: - -- The generated `campaign-id` and `project-url` -- The list of selected workflow IDs -- Exact commands for the agent to run (at minimum): `gh aw compile` -- What files must be committed (the new `.github/workflows/.campaign.md`, generated `.campaign.g.md`, and compiled `.campaign.lock.yml`) -- A short acceptance checklist (e.g., “`gh aw compile` succeeds; lock file updated; PR opened”) - -**Campaign ID:** Convert names to kebab-case (e.g., "Security Q1 2025" → "security-q1-2025"). Check for conflicts in `.github/workflows/`. - -**Allowed Repos/Orgs (Required):** - -- `scope`: **Optional** - Scope selectors for repos and orgs this campaign can discover and operate on (defaults to current repo) -- Defines campaign scope as a reviewable contract for security and governance - -**Workflow Discovery:** - -- Scan existing: `.github/workflows/*.md` (agentic), `*.yml` (regular) -- Match by keywords: security, dependency, documentation, quality, CI/CD -- Select 2-4 workflows (prioritize existing, identify AI enhancement candidates) - -**Safe Outputs (Least Privilege):** - -- For this campaign generator workflow, use `update-issue` for status updates (this workflow does not enable `add-comment`). -- Project-based: `create-project`, `update-project`, `update-issue`, `create-agent-session` (preferred) - -**Operation Order for Project Setup:** - -1. `create-project` (creates project + views) -2. `update-project` (adds items/fields) -3. `update-issue` (updates metadata, optional) -4. `create-agent-session` (preferred) or `assign-to-agent` (fallback) - -**Example Safe Outputs Configuration for Project-Based Campaigns:** - -```yaml -safe-outputs: - create-project: - max: 1 - github-token: "" # Provide via workflow secret/env; avoid secrets expressions in runtime-import files - target-owner: "${{ github.repository_owner }}" - views: # Views are created automatically when project is created - - name: "Campaign Roadmap" - layout: "roadmap" - filter: "is:issue is:pr" - - name: "Task Tracker" - layout: "table" - filter: "is:issue is:pr" - - name: "Progress Board" - layout: "board" - filter: "is:issue is:pr" - update-project: - max: 10 - github-token: "" # Provide via workflow secret/env; avoid secrets expressions in runtime-import files - update-issue: - create-agent-session: - base: "${{ github.ref_name }}" # Prefer main/default branch when appropriate -``` - -**Risk Levels:** - -- High: Sensitive/multi-repo/breaking → 2 approvals + sponsor -- Medium: Cross-repo/automated → 1 approval -- Low: Read-only/single repo → No approval diff --git a/.github/aw/orchestrate-agentic-campaign.md b/.github/aw/orchestrate-agentic-campaign.md deleted file mode 100644 index 9f758036159..00000000000 --- a/.github/aw/orchestrate-agentic-campaign.md +++ /dev/null @@ -1,165 +0,0 @@ -# Orchestrator Instructions - -This orchestrator coordinates a single campaign by discovering worker outputs and making deterministic decisions. - -**Scope:** orchestration + project sync + reporting (discovery, planning, pacing, writing, reporting). -**Actuation model:** **hybrid** — the orchestrator may update campaign state directly (Projects and status updates) and may also dispatch allowlisted worker workflows. -**Write authority:** the orchestrator may write GitHub state when explicitly allowlisted via safe outputs; delegate repo/code changes (e.g., PRs) to workers unless this campaign explicitly defines otherwise. - ---- - -## Traffic and Rate Limits (Required) - -- Minimize API calls; avoid full rescans when possible. -- Prefer incremental discovery with deterministic ordering (e.g., by `updatedAt`, tie-break by ID). -- Enforce strict pagination budgets; if a query requires many pages, stop early and continue next run. -- Use a durable cursor/checkpoint so the next run continues without rescanning. -- On throttling (HTTP 429 / rate-limit 403), do not retry aggressively; back off and end the run after reporting what remains. - -{{ if .CursorGlob }} -**Cursor file (repo-memory)**: `{{ .CursorGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/cursor.json` -- If it exists: read first and continue from its boundary. -- If it does not exist: create it by end of run. -- Always write the updated cursor back to the same path. -{{ end }} - -{{ if .MetricsGlob }} -**Metrics snapshots (repo-memory)**: `{{ .MetricsGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/metrics/*.json` -- Persist one append-only JSON metrics snapshot per run (new file per run; do not rewrite history). -- Use UTC date (`YYYY-MM-DD`) in the filename (example: `metrics/2025-12-22.json`). -{{ end }} - -{{ if gt .MaxDiscoveryItemsPerRun 0 }} -**Read budget**: max discovery items per run: {{ .MaxDiscoveryItemsPerRun }} -{{ end }} -{{ if gt .MaxDiscoveryPagesPerRun 0 }} -**Read budget**: max discovery pages per run: {{ .MaxDiscoveryPagesPerRun }} -{{ end }} - ---- - -## Core Principles - -1. Workers are immutable and campaign-agnostic -2. The GitHub Project board is the authoritative campaign state -3. Correlation is explicit (tracker-id AND labels) -4. Reads and writes are separate steps (never interleave) -5. Idempotent operation is mandatory (safe to re-run) -6. Orchestrator writes must be deterministic and minimal - ---- - -## Execution Steps (Required Order) - -### Step 1 — Read State (Discovery) [NO WRITES] - -**IMPORTANT**: Discovery has been precomputed. Read the discovery manifest instead of performing GitHub-wide searches. - -1) Read the precomputed discovery manifest: `./.gh-aw/campaign.discovery.json` - -2) Parse discovered items from the manifest: - - Each item has: url, content_type (issue/pull_request/discussion), number, repo, created_at, updated_at, state - - Closed items have: closed_at (for issues) or merged_at (for PRs) - - Items are pre-sorted by updated_at for deterministic processing - -3) Check the manifest summary for work counts. - -4) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. - -### Step 2 — Make Decisions (Planning) [NO WRITES] - -5) Determine desired `status` strictly from explicit GitHub state: -- Open → `Todo` (or `In Progress` only if explicitly indicated elsewhere) -- Closed (issue/discussion) → `Done` -- Merged (PR) → `Done` - -6) Calculate required date fields (for workers that sync Projects): -- `start_date`: format `created_at` as `YYYY-MM-DD` -- `end_date`: - - if closed/merged → format `closed_at`/`merged_at` as `YYYY-MM-DD` - - if open → **today's date** formatted `YYYY-MM-DD` - -7) Reads and writes are separate steps (never interleave). - -### Step 3 — Apply Updates (Execution) [WRITES] - -8) Apply required GitHub state updates in a single write phase. - -Allowed writes (when allowlisted via safe outputs): -- Update the campaign Project board (add/update items and fields) -- Post status updates (e.g., update an issue or add a comment) -- Create Copilot agent sessions for repo-side work (use when you need code changes) - -Constraints: -- Use only allowlisted safe outputs. -- Keep within configured max counts and API budgets. -- Do not interleave reads and writes. - -### Step 4 — Dispatch Workers (Optional) [DISPATCH] - -9) For repo-side actions (e.g., code changes), dispatch allowlisted worker workflows using `dispatch-workflow`. - -Constraints: -- Only dispatch allowlisted workflows. -- Keep within the dispatch-workflow max for this run. - -### Step 5 — Report - -10) Summarize what you updated and/or dispatched, what remains, and what should run next. - - **Discovered:** 25 items (15 issues, 10 PRs) - **Processed:** 10 items added to project, 5 updated - **Completion:** 60% (30/50 total tasks) - - ## Most Important Findings - - 1. **Critical accessibility gaps identified**: 3 high-severity accessibility issues discovered in mobile navigation, requiring immediate attention - 2. **Documentation coverage acceleration**: Achieved 5% improvement in one week (best velocity so far) - 3. **Worker efficiency improving**: daily-doc-updater now processing 40% more items per run - - ## What Was Learned - - - Multi-device testing reveals issues that desktop-only testing misses - should be prioritized - - Documentation updates tied to code changes have higher accuracy and completeness - - Users report fewer issues when examples include error handling patterns - - ## Campaign Progress - - **Documentation Coverage** (Primary Metric): - - Baseline: 85% → Current: 88% → Target: 95% - - Direction: ↑ Increasing (+3% this week, +1% velocity/week) - - Status: ON TRACK - At current velocity, will reach 95% in 7 weeks - - **Accessibility Score** (Supporting Metric): - - Baseline: 90% → Current: 91% → Target: 98% - - Direction: ↑ Increasing (+1% this month) - - Status: AT RISK - Slower progress than expected, may need dedicated focus - - **User-Reported Issues** (Supporting Metric): - - Baseline: 15/month → Current: 12/month → Target: 5/month - - Direction: ↓ Decreasing (-3 this month, -20% velocity) - - Status: ON TRACK - Trending toward target - - ## Next Steps - - 1. Address 3 critical accessibility issues identified this run (high priority) - 2. Continue processing remaining 15 discovered items - 3. Focus on accessibility improvements to accelerate supporting KPI - 4. Maintain current documentation coverage velocity -``` - -12) Report: -- counts discovered (by type) -- counts processed this run (by action: add/status_update/backfill/noop/failed) -- counts deferred due to budgets -- failures (with reasons) -- completion state (work items only) -- cursor advanced / remaining backlog estimate - ---- - -## Authority - -If any instruction in this file conflicts with **Project Update Instructions**, the Project Update Instructions win for all project writes. diff --git a/.github/aw/update-agentic-campaign-project.md b/.github/aw/update-agentic-campaign-project.md deleted file mode 100644 index 0b35d0272a6..00000000000 --- a/.github/aw/update-agentic-campaign-project.md +++ /dev/null @@ -1,247 +0,0 @@ -{{if .ProjectURL}} -# Project Update Instructions (Authoritative Write Contract) - -## Project Board Integration - -This file defines the ONLY allowed rules for writing to the GitHub Project board. -If any other instructions conflict with this file, THIS FILE TAKES PRECEDENCE for all project writes. - ---- - -## 0) Hard Requirements (Do Not Deviate) - -- Any workflow performing project writes (orchestrators or workers) MUST use only the `update-project` safe-output. -- All writes MUST target exactly: - - **Project URL**: `{{.ProjectURL}}` -- Every item MUST include: - - `campaign_id: "{{.CampaignID}}"` - -## Campaign ID - -All campaign tracking MUST key off `campaign_id: "{{.CampaignID}}"`. - ---- - -## 1) Required Project Fields (Must Already Exist) - -| Field | Type | Allowed / Notes | -|---|---|---| -| `status` | single-select | `Todo` / `In Progress` / `Review required` / `Blocked` / `Done` | -| `campaign_id` | text | Must equal `{{.CampaignID}}` | -| `worker_workflow` | text | workflow ID or `"unknown"` | -| `target_repo` | text | `owner/repo` | -| `priority` | single-select | `High` / `Medium` / `Low` | -| `size` | single-select | `Small` / `Medium` / `Large` | -| `start_date` | date | `YYYY-MM-DD` | -| `end_date` | date | `YYYY-MM-DD` | - -Field names are case-sensitive. - ---- - -## 2) Content Identification (Mandatory) - -Use **content number** (integer), never the URL as an identifier. - -- Issue URL: `.../issues/123` → `content_type: "issue"`, `content_number: 123` -- PR URL: `.../pull/456` → `content_type: "pull_request"`, `content_number: 456` - ---- - -## 3) Deterministic Field Rules (No Inference) - -These rules apply to any time you write fields: - -- `campaign_id`: always `{{.CampaignID}}` -- `worker_workflow`: workflow ID if known, else `"unknown"` -- `target_repo`: extract `owner/repo` from the issue/PR URL -- `priority`: default `Medium` unless explicitly known -- `size`: default `Medium` unless explicitly known -- `start_date`: issue/PR `created_at` formatted `YYYY-MM-DD` -- `end_date`: - - if closed/merged → `closed_at` / `merged_at` formatted `YYYY-MM-DD` - - if open → **today’s date** formatted `YYYY-MM-DD` (**required for roadmap view; do not leave blank**) - -For open items, `end_date` is a UI-required placeholder and does NOT represent actual completion. - ---- - -## 4) Read-Write Separation (Prevents Read/Write Mixing) - -1. **READ STEP (no writes)** — validate existence and gather metadata -2. **WRITE STEP (writes only)** — execute `update-project` - -Never interleave reads and writes. - ---- - -## 5) Adding an Issue or PR (First Write) - -### Adding New Issues - -When first adding an item to the project, you MUST write ALL required fields. - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Todo" # "Done" if already closed/merged - campaign_id: "{{.CampaignID}}" - worker_workflow: "unknown" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-03" -``` - ---- - -## 6) Updating an Existing Item (Minimal Writes) - -### Updating Existing Items - -Preferred behavior is minimal, idempotent writes: - -- If item exists and `status` is unchanged → **No-op** -- If item exists and `status` differs → **Update `status` only** -- If any required field is missing/empty/invalid → **One-time full backfill** (repair only) - -### Status-only Update (Default) - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" -``` - -### Full Backfill (Repair Only) - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" - campaign_id: "{{.CampaignID}}" - worker_workflow: "WORKFLOW_ID" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-02" -``` - ---- - -## 7) Idempotency Rules - -- Matching status already set → **No-op** -- Different status → **Status-only update** -- Invalid/deleted/inaccessible URL → **Record failure and continue** - -## Write Operation Rules - -All writes MUST conform to this file and use `update-project` only. - ---- - -## 8) Logging + Failure Handling (Mandatory) - -For every attempted item, record: - -- `content_type`, `content_number`, `target_repo` -- action taken: `noop | add | status_update | backfill | failed` -- error details if failed - -Failures must not stop processing remaining items. - ---- - -## 9) Worker Workflow Policy - -- Workers are campaign-agnostic. -- Orchestrator populates `worker_workflow`. -- If `worker_workflow` cannot be determined, it MUST remain `"unknown"` unless explicitly reclassified by the orchestrator. - ---- - -## 10) Parent / Sub-Issue Rules (Campaign Hierarchy) - -- Each project board MUST have exactly **one Epic issue** representing the campaign. -- The Epic issue MUST: - - Be added to the project board - - Use the same `campaign_id` - - Use `worker_workflow: "unknown"` - -- All campaign work issues (non-epic) MUST be created as **sub-issues of the Epic**. -- Issues MUST NOT be re-parented based on worker assignment. - -- Pull requests cannot be sub-issues: - - PRs MUST reference their related issue via standard GitHub linking (e.g. “Closes #123”). - -- Worker grouping MUST be done via the `worker_workflow` project field, not via parent issues. - -- The Epic issue is narrative only. -- The project board is the sole authoritative source of campaign state. - ---- - -## Appendix — Machine Check Checklist (Optional) - -This checklist is designed to validate outputs before executing project writes. - -### A) Output Structure Checks - -- [ ] All writes use `update-project:` blocks (no other write mechanism). -- [ ] Each `update-project` block includes: - - [ ] `project: "{{.ProjectURL}}"` - - [ ] `campaign_id: "{{.CampaignID}}"` (top-level) - - [ ] `content_type` ∈ {`issue`, `pull_request`} - - [ ] `content_number` is an integer - - [ ] `fields` object is present - -### B) Field Validity Checks - -- [ ] `fields.status` ∈ {`Todo`, `In Progress`, `Review required`, `Blocked`, `Done`} -- [ ] `fields.campaign_id` is present on first-add/backfill and equals `{{.CampaignID}}` -- [ ] `fields.worker_workflow` is present on first-add/backfill and is either a known workflow ID or `"unknown"` -- [ ] `fields.target_repo` matches `owner/repo` -- [ ] `fields.priority` ∈ {`High`, `Medium`, `Low`} -- [ ] `fields.size` ∈ {`Small`, `Medium`, `Large`} -- [ ] `fields.start_date` matches `YYYY-MM-DD` -- [ ] `fields.end_date` matches `YYYY-MM-DD` - -### C) Update Semantics Checks - -- [ ] For existing items, payload is **status-only** unless explicitly doing a backfill repair. -- [ ] Backfill is used only when required fields are missing/empty/invalid. -- [ ] No payload overwrites `priority`/`size`/`worker_workflow` with defaults during a normal status update. - -### D) Read-Write Separation Checks - -- [ ] All reads occur before any writes (no read/write interleaving). -- [ ] Writes are batched separately from discovery. - -### E) Epic/Hierarchy Checks (Policy-Level) - -- [ ] Exactly one Epic exists for the campaign board. -- [ ] Epic is on the board and uses `worker_workflow: "unknown"`. -- [ ] All campaign work issues are sub-issues of the Epic (if supported by environment/tooling). -- [ ] PRs are linked to issues via GitHub linking (e.g. “Closes #123”). - -### F) Failure Handling Checks - -- [ ] Invalid/deleted/inaccessible items are logged as failures and processing continues. -- [ ] Idempotency is delegated to the `update-project` tool; no pre-filtering by board presence. - -{{end}} diff --git a/.github/workflows/security-alert-burndown.lock.yml b/.github/workflows/security-alert-burndown.lock.yml index 36a706788b5..b3c8afad78d 100644 --- a/.github/workflows/security-alert-burndown.lock.yml +++ b/.github/workflows/security-alert-burndown.lock.yml @@ -966,726 +966,6 @@ jobs: - PRs: open only - Limit updates to 100 items per run to respect rate limits (prioritize highest severity/most recent first) - - - --- - # WORKFLOW EXECUTION (PHASE 0) - --- - # Workflow Execution - - This campaign references the following campaign workers. These workers follow the first-class worker pattern: they are dispatch-only workflows with standardized input contracts. - - **IMPORTANT: Workers are orchestrated, not autonomous. They accept `campaign_id` and `payload` inputs via workflow_dispatch.** - - --- - - ## Campaign Workers - - - - **Worker Pattern**: All workers MUST: - - Use `workflow_dispatch` as the ONLY trigger (no schedule/push/pull_request) - - Accept `campaign_id` (string) and `payload` (string; JSON) inputs - - Implement idempotency via deterministic work item keys - - Label all created items with `z_campaign_security-alert-burndown` - - --- - - ## Workflow Creation Guardrails - - ### Before Creating Any Worker Workflow, Ask: - - 1. **Does this workflow already exist?** - Check `.github/workflows/` thoroughly - 2. **Can an existing workflow be adapted?** - Even if not perfect, existing is safer - 3. **Is the requirement clear?** - Can you articulate exactly what it should do? - 4. **Is it testable?** - Can you verify it works with test inputs? - 5. **Is it reusable?** - Could other campaigns benefit from this worker? - - ### Only Create New Workers When: - - ✅ **All these conditions are met:** - - No existing workflow does the required task - - The campaign objective explicitly requires this capability - - You have a clear, specific design for the worker - - The worker has a focused, single-purpose scope - - You can test it independently before campaign use - - ❌ **Never create workers when:** - - You're unsure about requirements - - An existing workflow "mostly" works - - The worker would be complex or multi-purpose - - You haven't verified it doesn't already exist - - You can't clearly explain what it does in one sentence - - --- - - ## Worker Creation Template - - If you must create a new worker (only after checking ALL guardrails above), use this template: - - **Create the workflow file at `.github/workflows/.md`:** - - ```yaml - --- - name: - description: - - on: - workflow_dispatch: - inputs: - campaign_id: - description: 'Campaign identifier' - required: true - type: string - payload: - description: 'JSON payload with work item details' - required: true - type: string - - tracker-id: - - tools: - github: - toolsets: [default] - # Add minimal additional tools as needed - - safe-outputs: - create-pull-request: - max: 1 # Start conservative - add-comment: - max: 2 - --- - - # - - You are a campaign worker that processes work items. - - ## Input Contract - - Parse inputs: - ```javascript - const campaignId = context.payload.inputs.campaign_id; - const payload = JSON.parse(context.payload.inputs.payload); - ``` - - Expected payload structure: - ```json - { - "repository": "owner/repo", - "work_item_id": "unique-id", - "target_ref": "main", - // Additional context... - } - ``` - - ## Idempotency Requirements - - 1. **Generate deterministic key**: - ``` - const workKey = `campaign-${campaignId}-${payload.repository}-${payload.work_item_id}`; - ``` - - 2. **Check for existing work**: - - Search for PRs/issues with `workKey` in title - - Filter by label: `z_campaign_${campaignId}` - - If found: Skip or update - - If not: Create new - - 3. **Label all created items**: - - Apply `z_campaign_${campaignId}` label - - This enables discovery by orchestrator - - ## Task - - - - ## Output - - Report: - - Link to created/updated PR or issue - - Whether work was skipped (exists) or completed - - Any errors or blockers - ``` - - **After creating:** - - Compile: `gh aw compile .md` - - **CRITICAL: Test with sample inputs** (see testing requirements below) - - --- - - ## Worker Testing (MANDATORY) - - **Why test?** - Untested workers may fail during campaign execution. Test with sample inputs first to catch issues early. - - **Testing steps:** - - 1. **Prepare test payload**: - ```json - { - "repository": "test-org/test-repo", - "work_item_id": "test-1", - "target_ref": "main" - } - ``` - - 2. **Trigger test run**: - ```bash - gh workflow run .yml \ - -f campaign_id=security-alert-burndown \ - -f payload='{"repository":"test-org/test-repo","work_item_id":"test-1"}' - ``` - - Or via GitHub MCP: - ```javascript - mcp__github__run_workflow( - workflow_id: "", - ref: "main", - inputs: { - campaign_id: "security-alert-burndown", - payload: JSON.stringify({repository: "test-org/test-repo", work_item_id: "test-1"}) - } - ) - ``` - - 3. **Wait for completion**: Poll until status is "completed" - - 4. **Verify success**: - - Check that workflow succeeded - - Verify idempotency: Run again with same inputs, should skip/update - - Review created items have correct labels - - Confirm deterministic keys are used - - 5. **Test failure actions**: - - DO NOT use the worker if testing fails - - Analyze failure logs - - Make corrections - - Recompile and retest - - If unfixable after 2 attempts, report in status and skip - - **Note**: Workflows that accept `workflow_dispatch` inputs can receive parameters from the orchestrator. This enables the orchestrator to provide context, priorities, or targets based on its decisions. See [DispatchOps documentation](https://githubnext.github.io/gh-aw/guides/dispatchops/#with-input-parameters) for input parameter examples. - - --- - - ## Orchestration Guidelines - - **Execution pattern:** - - Workers are **orchestrated, not autonomous** - - Orchestrator discovers work items via discovery manifest - - Orchestrator decides which workers to run and with what inputs - - Workers receive `campaign_id` and `payload` via workflow_dispatch - - Sequential vs parallel execution is orchestrator's decision - - **Worker dispatch:** - - Parse discovery manifest (`./.gh-aw/campaign.discovery.json`) - - For each work item needing processing: - 1. Determine appropriate worker for this item type - 2. Construct payload with work item details - 3. Dispatch worker via workflow_dispatch with campaign_id and payload - 4. Track dispatch status - - **Input construction:** - ```javascript - // Example: Dispatching security-fix worker - const workItem = discoveryManifest.items[0]; - const payload = { - repository: workItem.repo, - work_item_id: `alert-${workItem.number}`, - target_ref: "main", - alert_type: "sql-injection", - file_path: "src/db.go", - line_number: 42 - }; - - await github.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: "security-fix-worker.yml", - ref: "main", - inputs: { - campaign_id: "security-alert-burndown", - payload: JSON.stringify(payload) - } - }); - ``` - - **Idempotency by design:** - - Workers implement their own idempotency checks - - Orchestrator doesn't need to track what's been processed - - Can safely re-dispatch work items across runs - - Workers will skip or update existing items - - **Failure handling:** - - If a worker dispatch fails, note it but continue - - Worker failures don't block entire campaign - - Report all failures in status update with context - - Humans can intervene if needed - - --- - - ## After Worker Orchestration - - Once workers have been dispatched (or new workers created and tested), proceed with normal orchestrator steps: - - 1. **Discovery** - Read state from discovery manifest and project board - 2. **Planning** - Determine what needs updating on project board - 3. **Project Updates** - Write state changes to project board - 4. **Status Reporting** - Report progress, worker dispatches, failures, next steps - - --- - - ## Key Differences from Fusion Approach - - **Old fusion approach (REMOVED)**: - - Workers had mixed triggers (schedule + workflow_dispatch) - - Fusion dynamically added workflow_dispatch to existing workflows - - Workers stored in campaign-specific folders - - Ambiguous ownership and trigger precedence - - **New first-class worker approach**: - - Workers are dispatch-only (on: workflow_dispatch) - - Standardized input contract (campaign_id, payload) - - Explicit idempotency via deterministic keys - - Clear ownership: workers are orchestrated, not autonomous - - Workers stored with regular workflows (not campaign-specific folders) - - Orchestration policy kept explicit in orchestrator - - This eliminates duplicate execution problems and makes orchestration concerns explicit. - --- - # ORCHESTRATOR INSTRUCTIONS - --- - # Orchestrator Instructions - - This orchestrator coordinates a single campaign by discovering worker outputs and making deterministic decisions. - - **Scope:** orchestration + project sync + reporting (discovery, planning, pacing, writing, reporting). - **Actuation model:** **hybrid** — the orchestrator may update campaign state directly (Projects and status updates) and may also dispatch allowlisted worker workflows. - **Write authority:** the orchestrator may write GitHub state when explicitly allowlisted via safe outputs; delegate repo/code changes (e.g., PRs) to workers unless this campaign explicitly defines otherwise. - - --- - - ## Traffic and Rate Limits (Required) - - - Minimize API calls; avoid full rescans when possible. - - Prefer incremental discovery with deterministic ordering (e.g., by `updatedAt`, tie-break by ID). - - Enforce strict pagination budgets; if a query requires many pages, stop early and continue next run. - - Use a durable cursor/checkpoint so the next run continues without rescanning. - - On throttling (HTTP 429 / rate-limit 403), do not retry aggressively; back off and end the run after reporting what remains. - - - **Cursor file (repo-memory)**: `memory/campaigns/security-alert-burndown/cursor.json` - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - **File system path**: `/tmp/gh-aw/repo-memory/campaigns/security-alert-burndown/cursor.json` - - If it exists: read first and continue from its boundary. - - If it does not exist: create it by end of run. - - Always write the updated cursor back to the same path. - - - - **Metrics snapshots (repo-memory)**: `memory/campaigns/security-alert-burndown/metrics/*.json` - **File system path**: `/tmp/gh-aw/repo-memory/campaigns/security-alert-burndown/metrics/*.json` - - Persist one append-only JSON metrics snapshot per run (new file per run; do not rewrite history). - - Use UTC date (`YYYY-MM-DD`) in the filename (example: `metrics/2025-12-22.json`). - - - - - - --- - - ## Core Principles - - 1. Workers are immutable and campaign-agnostic - 2. The GitHub Project board is the authoritative campaign state - 3. Correlation is explicit (tracker-id AND labels) - 4. Reads and writes are separate steps (never interleave) - 5. Idempotent operation is mandatory (safe to re-run) - 6. Orchestrator writes must be deterministic and minimal - - --- - - ## Execution Steps (Required Order) - - ### Step 1 — Read State (Discovery) [NO WRITES] - - **IMPORTANT**: Discovery has been precomputed. Read the discovery manifest instead of performing GitHub-wide searches. - - 1) Read the precomputed discovery manifest: `./.gh-aw/campaign.discovery.json` - - 2) Parse discovered items from the manifest: - - Each item has: url, content_type (issue/pull_request/discussion), number, repo, created_at, updated_at, state - - Closed items have: closed_at (for issues) or merged_at (for PRs) - - Items are pre-sorted by updated_at for deterministic processing - - 3) Check the manifest summary for work counts. - - 4) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. - - ### Step 2 — Make Decisions (Planning) [NO WRITES] - - 5) Determine desired `status` strictly from explicit GitHub state: - - Open → `Todo` (or `In Progress` only if explicitly indicated elsewhere) - - Closed (issue/discussion) → `Done` - - Merged (PR) → `Done` - - 6) Calculate required date fields (for workers that sync Projects): - - `start_date`: format `created_at` as `YYYY-MM-DD` - - `end_date`: - - if closed/merged → format `closed_at`/`merged_at` as `YYYY-MM-DD` - - if open → **today's date** formatted `YYYY-MM-DD` - - 7) Reads and writes are separate steps (never interleave). - - ### Step 3 — Apply Updates (Execution) [WRITES] - - 8) Apply required GitHub state updates in a single write phase. - - Allowed writes (when allowlisted via safe outputs): - - Update the campaign Project board (add/update items and fields) - - Post status updates (e.g., update an issue or add a comment) - - Create Copilot agent sessions for repo-side work (use when you need code changes) - - Constraints: - - Use only allowlisted safe outputs. - - Keep within configured max counts and API budgets. - - Do not interleave reads and writes. - - ### Step 4 — Dispatch Workers (Optional) [DISPATCH] - - 9) For repo-side actions (e.g., code changes), dispatch allowlisted worker workflows using `dispatch-workflow`. - - Constraints: - - Only dispatch allowlisted workflows. - - Keep within the dispatch-workflow max for this run. - - ### Step 5 — Report - - 10) Summarize what you updated and/or dispatched, what remains, and what should run next. - - **Discovered:** 25 items (15 issues, 10 PRs) - **Processed:** 10 items added to project, 5 updated - **Completion:** 60% (30/50 total tasks) - - ## Most Important Findings - - 1. **Critical accessibility gaps identified**: 3 high-severity accessibility issues discovered in mobile navigation, requiring immediate attention - 2. **Documentation coverage acceleration**: Achieved 5% improvement in one week (best velocity so far) - 3. **Worker efficiency improving**: daily-doc-updater now processing 40% more items per run - - ## What Was Learned - - - Multi-device testing reveals issues that desktop-only testing misses - should be prioritized - - Documentation updates tied to code changes have higher accuracy and completeness - - Users report fewer issues when examples include error handling patterns - - ## Campaign Progress - - **Documentation Coverage** (Primary Metric): - - Baseline: 85% → Current: 88% → Target: 95% - - Direction: ↑ Increasing (+3% this week, +1% velocity/week) - - Status: ON TRACK - At current velocity, will reach 95% in 7 weeks - - **Accessibility Score** (Supporting Metric): - - Baseline: 90% → Current: 91% → Target: 98% - - Direction: ↑ Increasing (+1% this month) - - Status: AT RISK - Slower progress than expected, may need dedicated focus - - **User-Reported Issues** (Supporting Metric): - - Baseline: 15/month → Current: 12/month → Target: 5/month - - Direction: ↓ Decreasing (-3 this month, -20% velocity) - - Status: ON TRACK - Trending toward target - - ## Next Steps - - 1. Address 3 critical accessibility issues identified this run (high priority) - 2. Continue processing remaining 15 discovered items - 3. Focus on accessibility improvements to accelerate supporting KPI - 4. Maintain current documentation coverage velocity - ``` - - 12) Report: - - counts discovered (by type) - - counts processed this run (by action: add/status_update/backfill/noop/failed) - - counts deferred due to budgets - - failures (with reasons) - - completion state (work items only) - - cursor advanced / remaining backlog estimate - - --- - - ## Authority - - If any instruction in this file conflicts with **Project Update Instructions**, the Project Update Instructions win for all project writes. - --- - # PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES) - --- - # Project Update Instructions (Authoritative Write Contract) - - ## Project Board Integration - - This file defines the ONLY allowed rules for writing to the GitHub Project board. - If any other instructions conflict with this file, THIS FILE TAKES PRECEDENCE for all project writes. - - --- - - ## 0) Hard Requirements (Do Not Deviate) - - - Any workflow performing project writes (orchestrators or workers) MUST use only the `update-project` safe-output. - - All writes MUST target exactly: - - **Project URL**: `https://github.com/orgs/githubnext/projects/144` - - Every item MUST include: - - `campaign_id: "security-alert-burndown"` - - ## Campaign ID - - All campaign tracking MUST key off `campaign_id: "security-alert-burndown"`. - - --- - - ## 1) Required Project Fields (Must Already Exist) - - | Field | Type | Allowed / Notes | - |---|---|---| - | `status` | single-select | `Todo` / `In Progress` / `Review required` / `Blocked` / `Done` | - | `campaign_id` | text | Must equal `security-alert-burndown` | - | `worker_workflow` | text | workflow ID or `"unknown"` | - | `target_repo` | text | `owner/repo` | - | `priority` | single-select | `High` / `Medium` / `Low` | - | `size` | single-select | `Small` / `Medium` / `Large` | - | `start_date` | date | `YYYY-MM-DD` | - | `end_date` | date | `YYYY-MM-DD` | - - Field names are case-sensitive. - - --- - - ## 2) Content Identification (Mandatory) - - Use **content number** (integer), never the URL as an identifier. - - - Issue URL: `.../issues/123` → `content_type: "issue"`, `content_number: 123` - - PR URL: `.../pull/456` → `content_type: "pull_request"`, `content_number: 456` - - --- - - ## 3) Deterministic Field Rules (No Inference) - - These rules apply to any time you write fields: - - - `campaign_id`: always `security-alert-burndown` - - `worker_workflow`: workflow ID if known, else `"unknown"` - - `target_repo`: extract `owner/repo` from the issue/PR URL - - `priority`: default `Medium` unless explicitly known - - `size`: default `Medium` unless explicitly known - - `start_date`: issue/PR `created_at` formatted `YYYY-MM-DD` - - `end_date`: - - if closed/merged → `closed_at` / `merged_at` formatted `YYYY-MM-DD` - - if open → **today’s date** formatted `YYYY-MM-DD` (**required for roadmap view; do not leave blank**) - - For open items, `end_date` is a UI-required placeholder and does NOT represent actual completion. - - --- - - ## 4) Read-Write Separation (Prevents Read/Write Mixing) - - 1. **READ STEP (no writes)** — validate existence and gather metadata - 2. **WRITE STEP (writes only)** — execute `update-project` - - Never interleave reads and writes. - - --- - - ## 5) Adding an Issue or PR (First Write) - - ### Adding New Issues - - When first adding an item to the project, you MUST write ALL required fields. - - ```yaml - update-project: - project: "https://github.com/orgs/githubnext/projects/144" - campaign_id: "security-alert-burndown" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Todo" # "Done" if already closed/merged - campaign_id: "security-alert-burndown" - worker_workflow: "unknown" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-03" - ``` - - --- - - ## 6) Updating an Existing Item (Minimal Writes) - - ### Updating Existing Items - - Preferred behavior is minimal, idempotent writes: - - - If item exists and `status` is unchanged → **No-op** - - If item exists and `status` differs → **Update `status` only** - - If any required field is missing/empty/invalid → **One-time full backfill** (repair only) - - ### Status-only Update (Default) - - ```yaml - update-project: - project: "https://github.com/orgs/githubnext/projects/144" - campaign_id: "security-alert-burndown" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" - ``` - - ### Full Backfill (Repair Only) - - ```yaml - update-project: - project: "https://github.com/orgs/githubnext/projects/144" - campaign_id: "security-alert-burndown" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" - campaign_id: "security-alert-burndown" - worker_workflow: "WORKFLOW_ID" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-02" - ``` - - --- - - ## 7) Idempotency Rules - - - Matching status already set → **No-op** - - Different status → **Status-only update** - - Invalid/deleted/inaccessible URL → **Record failure and continue** - - ## Write Operation Rules - - All writes MUST conform to this file and use `update-project` only. - - --- - - ## 8) Logging + Failure Handling (Mandatory) - - For every attempted item, record: - - - `content_type`, `content_number`, `target_repo` - - action taken: `noop | add | status_update | backfill | failed` - - error details if failed - - Failures must not stop processing remaining items. - - --- - - ## 9) Worker Workflow Policy - - - Workers are campaign-agnostic. - - Orchestrator populates `worker_workflow`. - - If `worker_workflow` cannot be determined, it MUST remain `"unknown"` unless explicitly reclassified by the orchestrator. - - --- - - ## 10) Parent / Sub-Issue Rules (Campaign Hierarchy) - - - Each project board MUST have exactly **one Epic issue** representing the campaign. - - The Epic issue MUST: - - Be added to the project board - - Use the same `campaign_id` - - Use `worker_workflow: "unknown"` - - - All campaign work issues (non-epic) MUST be created as **sub-issues of the Epic**. - - Issues MUST NOT be re-parented based on worker assignment. - - - Pull requests cannot be sub-issues: - - PRs MUST reference their related issue via standard GitHub linking (e.g. “Closes #123”). - - - Worker grouping MUST be done via the `worker_workflow` project field, not via parent issues. - - - The Epic issue is narrative only. - - The project board is the sole authoritative source of campaign state. - - --- - - ## Appendix — Machine Check Checklist (Optional) - - This checklist is designed to validate outputs before executing project writes. - - ### A) Output Structure Checks - - - [ ] All writes use `update-project:` blocks (no other write mechanism). - - [ ] Each `update-project` block includes: - - [ ] `project: "https://github.com/orgs/githubnext/projects/144"` - - [ ] `campaign_id: "security-alert-burndown"` (top-level) - - [ ] `content_type` ∈ {`issue`, `pull_request`} - - [ ] `content_number` is an integer - - [ ] `fields` object is present - - ### B) Field Validity Checks - - - [ ] `fields.status` ∈ {`Todo`, `In Progress`, `Review required`, `Blocked`, `Done`} - - [ ] `fields.campaign_id` is present on first-add/backfill and equals `security-alert-burndown` - - [ ] `fields.worker_workflow` is present on first-add/backfill and is either a known workflow ID or `"unknown"` - - [ ] `fields.target_repo` matches `owner/repo` - - [ ] `fields.priority` ∈ {`High`, `Medium`, `Low`} - - [ ] `fields.size` ∈ {`Small`, `Medium`, `Large`} - - [ ] `fields.start_date` matches `YYYY-MM-DD` - - [ ] `fields.end_date` matches `YYYY-MM-DD` - - ### C) Update Semantics Checks - - - [ ] For existing items, payload is **status-only** unless explicitly doing a backfill repair. - - [ ] Backfill is used only when required fields are missing/empty/invalid. - - [ ] No payload overwrites `priority`/`size`/`worker_workflow` with defaults during a normal status update. - - ### D) Read-Write Separation Checks - - - [ ] All reads occur before any writes (no read/write interleaving). - - [ ] Writes are batched separately from discovery. - - ### E) Epic/Hierarchy Checks (Policy-Level) - - - [ ] Exactly one Epic exists for the campaign board. - - [ ] Epic is on the board and uses `worker_workflow: "unknown"`. - - [ ] All campaign work issues are sub-issues of the Epic (if supported by environment/tooling). - - [ ] PRs are linked to issues via GitHub linking (e.g. “Closes #123”). - - ### F) Failure Handling Checks - - - [ ] Invalid/deleted/inaccessible items are logged as failures and processing continues. - - [ ] Idempotency is delegated to the `update-project` tool; no pre-filtering by board presence. - --- - # CLOSING INSTRUCTIONS (HIGHEST PRIORITY) - --- - # Closing Instructions (Highest Priority) - - Execute all four steps in strict order: - - 1. Read State (no writes) - 2. Make Decisions (no writes) - 3. Apply Updates (writes) - 4. Report - - The following rules are mandatory and override inferred behavior: - - - The GitHub Project board is the single source of truth. - - All project writes MUST comply with the Project Update Instructions. - - State reads and state writes MUST NOT be interleaved. - - Do NOT infer missing data or invent values. - - Do NOT reorganize hierarchy. - - Do NOT overwrite fields except as explicitly allowed. - - Workers are immutable and campaign-agnostic. - - If any instruction conflicts, the Project Update Instructions take precedence for all writes. PROMPT_EOF - name: Substitute placeholders uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 diff --git a/Makefile b/Makefile index 6908df7e951..2765bf10b9a 100644 --- a/Makefile +++ b/Makefile @@ -594,13 +594,6 @@ sync-templates: # Agent templates @cp .github/agents/agentic-workflows.agent.md pkg/cli/templates/ - - # Campaign management templates (lifecycle order) - @cp .github/aw/generate-agentic-campaign.md pkg/cli/templates/ - @cp .github/aw/orchestrate-agentic-campaign.md pkg/cli/templates/ - @cp .github/aw/execute-agentic-campaign-workflow.md pkg/cli/templates/ - @cp .github/aw/update-agentic-campaign-project.md pkg/cli/templates/ - @cp .github/aw/close-agentic-campaign.md pkg/cli/templates/ @echo "✓ Templates synced successfully" diff --git a/cmd/gh-aw/argument_syntax_test.go b/cmd/gh-aw/argument_syntax_test.go index d6fb147e043..1c6f28fe825 100644 --- a/cmd/gh-aw/argument_syntax_test.go +++ b/cmd/gh-aw/argument_syntax_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/cli" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -45,13 +44,6 @@ func TestArgumentSyntaxConsistency(t *testing.T) { argsValidator: "MinimumNArgs(1)", shouldValidate: func(cmd *cobra.Command) error { return cmd.Args(cmd, []string{"test"}) }, }, - { - name: "campaign new requires campaign-id", - command: findSubcommand(campaign.NewCommand(), "new"), - expectedUse: "new ", - argsValidator: "MaximumNArgs(1) with custom validation", - shouldValidate: nil, // Skip validation as it has custom error handling - }, // Commands with optional arguments (using square brackets []) { @@ -124,13 +116,6 @@ func TestArgumentSyntaxConsistency(t *testing.T) { argsValidator: "no validator (all optional)", shouldValidate: func(cmd *cobra.Command) error { return nil }, }, - { - name: "campaign command has optional filter", - command: campaign.NewCommand(), - expectedUse: "campaign [filter]", - argsValidator: "MaximumNArgs(1)", - shouldValidate: func(cmd *cobra.Command) error { return cmd.Args(cmd, []string{}) }, - }, } for _, tt := range tests { @@ -249,47 +234,6 @@ func TestPRSubcommandArgumentSyntax(t *testing.T) { } } -// TestCampaignSubcommandArgumentSyntax verifies campaign subcommands have consistent syntax -func TestCampaignSubcommandArgumentSyntax(t *testing.T) { - campaignCmd := campaign.NewCommand() - - tests := []struct { - name string - subcommand string - expectedUse string - }{ - { - name: "campaign status has optional filter", - subcommand: "status", - expectedUse: "status [filter]", - }, - { - name: "campaign new requires campaign-id", - subcommand: "new", - expectedUse: "new ", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Find the subcommand - setup step - foundCmd := findSubcommand(campaignCmd, tt.subcommand) - require.NotNil(t, foundCmd, - "Test requires campaign subcommand %q to exist in command list", - tt.subcommand) - - use := foundCmd.Use - assert.Equal(t, tt.expectedUse, use, - "Campaign subcommand %q should have expected Use syntax", tt.subcommand) - - // Validate the Use pattern format - assert.True(t, isValidUseSyntax(use), - "Campaign subcommand %q Use=%q should follow valid syntax pattern", - tt.subcommand, use) - }) - } -} - // findSubcommand finds a subcommand by name in a command func findSubcommand(cmd *cobra.Command, name string) *cobra.Command { for _, subcmd := range cmd.Commands() { @@ -370,7 +314,6 @@ func TestArgumentNamingConventions(t *testing.T) { cli.NewStatusCommand(), cli.NewMCPCommand(), cli.NewPRCommand(), - campaign.NewCommand(), } // Also collect subcommands @@ -384,7 +327,6 @@ func TestArgumentNamingConventions(t *testing.T) { "pattern": "Filter/search commands should use 'pattern' or 'filter'", "run-id": "Audit command should use 'run-id' for clarity", "workflow-spec": "Trial command should use 'workflow-spec' to indicate special format", - "campaign-id": "Campaign new should use 'campaign-id' for clarity", "pr-url": "PR transfer should use 'pr-url' for clarity", "server": "MCP commands should use 'server' for MCP server names", } diff --git a/cmd/gh-aw/capitalization_test.go b/cmd/gh-aw/capitalization_test.go index bcc1fffd7ea..14061ad430a 100644 --- a/cmd/gh-aw/capitalization_test.go +++ b/cmd/gh-aw/capitalization_test.go @@ -6,7 +6,6 @@ import ( "strings" "testing" - "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/cli" "github.com/spf13/cobra" ) @@ -78,7 +77,6 @@ func TestTechnicalTermsCapitalization(t *testing.T) { commandsToCheck := []*cobra.Command{ compileCmd, newCmd, - campaign.NewCommand(), } // Check all commands and their subcommands diff --git a/cmd/gh-aw/command_groups_test.go b/cmd/gh-aw/command_groups_test.go index 0dc09a8cf49..5d840fb7f33 100644 --- a/cmd/gh-aw/command_groups_test.go +++ b/cmd/gh-aw/command_groups_test.go @@ -39,7 +39,6 @@ func TestCommandGroupAssignments(t *testing.T) { // Analysis Commands {name: "logs command in analysis group", commandName: "logs", expectedGroup: "analysis", shouldHaveGroup: true}, {name: "audit command in analysis group", commandName: "audit", expectedGroup: "analysis", shouldHaveGroup: true}, - {name: "campaign command in analysis group", commandName: "campaign", expectedGroup: "analysis", shouldHaveGroup: true}, // Utilities {name: "mcp-server command in utilities group", commandName: "mcp-server", expectedGroup: "utilities", shouldHaveGroup: true}, diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 98a26b8baee..198eb83461f 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -5,7 +5,6 @@ import ( "os" "strings" - "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/cli" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/constants" @@ -554,7 +553,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all healthCmd := cli.NewHealthCommand() mcpServerCmd := cli.NewMCPServerCommand() prCmd := cli.NewPRCommand() - campaignCmd := campaign.NewCommand() secretsCmd := cli.NewSecretsCommand() fixCmd := cli.NewFixCommand() upgradeCmd := cli.NewUpgradeCommand() @@ -587,7 +585,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all logsCmd.GroupID = "analysis" auditCmd.GroupID = "analysis" healthCmd.GroupID = "analysis" - campaignCmd.GroupID = "analysis" // Utilities mcpServerCmd.GroupID = "utilities" @@ -617,7 +614,6 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all rootCmd.AddCommand(mcpServerCmd) rootCmd.AddCommand(prCmd) rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(campaignCmd) rootCmd.AddCommand(secretsCmd) rootCmd.AddCommand(fixCmd) rootCmd.AddCommand(completionCmd) diff --git a/go.mod b/go.mod index e73d99feac9..8189ed8d50a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,6 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - go.yaml.in/yaml/v3 v3.0.4 golang.org/x/crypto v0.47.0 golang.org/x/mod v0.32.0 golang.org/x/term v0.39.0 @@ -265,6 +264,7 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/pkg/campaign/bootstrap_test.go b/pkg/campaign/bootstrap_test.go deleted file mode 100644 index 4eecbc6eee6..00000000000 --- a/pkg/campaign/bootstrap_test.go +++ /dev/null @@ -1,458 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "encoding/json" - "testing" - - "go.yaml.in/yaml/v3" -) - -// TestBootstrapConfig_SeederWorker tests parsing bootstrap config with seeder-worker mode -func TestBootstrapConfig_SeederWorker(t *testing.T) { - yamlContent := `--- -id: test-bootstrap-seeder -name: Test Bootstrap Seeder -project-url: https://github.com/orgs/test/projects/1 -bootstrap: - mode: seeder-worker - seeder-worker: - workflow-id: security-scanner - payload: - scan-type: full - max-findings: 100 - max-items: 50 ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse bootstrap seeder config: %v", err) - } - - if spec.Bootstrap == nil { - t.Fatal("Bootstrap config should not be nil") - } - if spec.Bootstrap.Mode != "seeder-worker" { - t.Errorf("Expected mode 'seeder-worker', got '%s'", spec.Bootstrap.Mode) - } - if spec.Bootstrap.SeederWorker == nil { - t.Fatal("SeederWorker config should not be nil") - } - if spec.Bootstrap.SeederWorker.WorkflowID != "security-scanner" { - t.Errorf("Expected workflow-id 'security-scanner', got '%s'", spec.Bootstrap.SeederWorker.WorkflowID) - } - if spec.Bootstrap.SeederWorker.MaxItems != 50 { - t.Errorf("Expected max-items 50, got %d", spec.Bootstrap.SeederWorker.MaxItems) - } - if len(spec.Bootstrap.SeederWorker.Payload) == 0 { - t.Error("Payload should not be empty") - } -} - -// TestBootstrapConfig_ProjectTodos tests parsing bootstrap config with project-todos mode -func TestBootstrapConfig_ProjectTodos(t *testing.T) { - yamlContent := `--- -id: test-bootstrap-todos -name: Test Bootstrap Todos -project-url: https://github.com/orgs/test/projects/1 -bootstrap: - mode: project-todos - project-todos: - status-field: Status - todo-value: Backlog - max-items: 10 - require-fields: - - Priority - - Assignee ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse bootstrap todos config: %v", err) - } - - if spec.Bootstrap == nil { - t.Fatal("Bootstrap config should not be nil") - } - if spec.Bootstrap.Mode != "project-todos" { - t.Errorf("Expected mode 'project-todos', got '%s'", spec.Bootstrap.Mode) - } - if spec.Bootstrap.ProjectTodos == nil { - t.Fatal("ProjectTodos config should not be nil") - } - if spec.Bootstrap.ProjectTodos.StatusField != "Status" { - t.Errorf("Expected status-field 'Status', got '%s'", spec.Bootstrap.ProjectTodos.StatusField) - } - if spec.Bootstrap.ProjectTodos.TodoValue != "Backlog" { - t.Errorf("Expected todo-value 'Backlog', got '%s'", spec.Bootstrap.ProjectTodos.TodoValue) - } - if spec.Bootstrap.ProjectTodos.MaxItems != 10 { - t.Errorf("Expected max-items 10, got %d", spec.Bootstrap.ProjectTodos.MaxItems) - } - if len(spec.Bootstrap.ProjectTodos.RequireFields) != 2 { - t.Errorf("Expected 2 require-fields, got %d", len(spec.Bootstrap.ProjectTodos.RequireFields)) - } -} - -// TestBootstrapConfig_Manual tests parsing bootstrap config with manual mode -func TestBootstrapConfig_Manual(t *testing.T) { - yamlContent := `--- -id: test-bootstrap-manual -name: Test Bootstrap Manual -project-url: https://github.com/orgs/test/projects/1 -bootstrap: - mode: manual ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse bootstrap manual config: %v", err) - } - - if spec.Bootstrap == nil { - t.Fatal("Bootstrap config should not be nil") - } - if spec.Bootstrap.Mode != "manual" { - t.Errorf("Expected mode 'manual', got '%s'", spec.Bootstrap.Mode) - } -} - -// TestWorkerMetadata_Basic tests parsing basic worker metadata -func TestWorkerMetadata_Basic(t *testing.T) { - yamlContent := `--- -id: test-workers -name: Test Workers -project-url: https://github.com/orgs/test/projects/1 -workers: - - id: security-fixer - name: Security Fix Worker - description: Fixes security vulnerabilities - capabilities: - - fix-security-alerts - - create-pull-requests - payload-schema: - repository: - type: string - description: Target repository in owner/repo format - required: true - example: owner/repo - work_item_id: - type: string - description: Unique work item identifier - required: true - example: alert-123 - severity: - type: string - description: Alert severity level - required: false - example: high - output-labeling: - labels: - - security - - automated - key-in-title: true - key-format: "campaign-{campaign_id}-{repository}-{work_item_id}" - metadata-fields: - - Campaign Id - - Worker Workflow - idempotency-strategy: pr-title-based - priority: 10 ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse worker metadata: %v", err) - } - - if len(spec.Workers) == 0 { - t.Fatal("Workers should not be empty") - } - - worker := spec.Workers[0] - if worker.ID != "security-fixer" { - t.Errorf("Expected worker ID 'security-fixer', got '%s'", worker.ID) - } - if worker.Name != "Security Fix Worker" { - t.Errorf("Expected worker name 'Security Fix Worker', got '%s'", worker.Name) - } - if len(worker.Capabilities) != 2 { - t.Errorf("Expected 2 capabilities, got %d", len(worker.Capabilities)) - } - if len(worker.PayloadSchema) != 3 { - t.Errorf("Expected 3 payload schema fields, got %d", len(worker.PayloadSchema)) - } - - // Check payload schema field - repoField, exists := worker.PayloadSchema["repository"] - if !exists { - t.Error("Expected 'repository' field in payload schema") - } - if repoField.Type != "string" { - t.Errorf("Expected repository type 'string', got '%s'", repoField.Type) - } - if !repoField.Required { - t.Error("Expected repository field to be required") - } - - // Check output labeling - if !worker.OutputLabeling.KeyInTitle { - t.Error("Expected key-in-title to be true") - } - if len(worker.OutputLabeling.Labels) != 2 { - t.Errorf("Expected 2 labels, got %d", len(worker.OutputLabeling.Labels)) - } - if len(worker.OutputLabeling.MetadataFields) != 2 { - t.Errorf("Expected 2 metadata fields, got %d", len(worker.OutputLabeling.MetadataFields)) - } - - // Check idempotency strategy - if worker.IdempotencyStrategy != "pr-title-based" { - t.Errorf("Expected idempotency strategy 'pr-title-based', got '%s'", worker.IdempotencyStrategy) - } - - // Check priority - if worker.Priority != 10 { - t.Errorf("Expected priority 10, got %d", worker.Priority) - } -} - -// TestWorkerMetadata_Multiple tests parsing multiple workers -func TestWorkerMetadata_Multiple(t *testing.T) { - yamlContent := `--- -id: test-multi-workers -name: Test Multiple Workers -project-url: https://github.com/orgs/test/projects/1 -workers: - - id: worker-one - capabilities: [scan] - payload-schema: - target: - type: string - description: Target to scan - required: true - output-labeling: - key-in-title: false - idempotency-strategy: branch-based - - id: worker-two - capabilities: [fix] - payload-schema: - issue: - type: number - description: Issue number - required: true - output-labeling: - key-in-title: true - key-format: "fix-{issue}" - idempotency-strategy: issue-title-based - priority: 5 ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse multiple workers: %v", err) - } - - if len(spec.Workers) != 2 { - t.Fatalf("Expected 2 workers, got %d", len(spec.Workers)) - } - - // Check first worker - if spec.Workers[0].ID != "worker-one" { - t.Errorf("Expected first worker ID 'worker-one', got '%s'", spec.Workers[0].ID) - } - if spec.Workers[0].IdempotencyStrategy != "branch-based" { - t.Errorf("Expected first worker idempotency 'branch-based', got '%s'", spec.Workers[0].IdempotencyStrategy) - } - - // Check second worker - if spec.Workers[1].ID != "worker-two" { - t.Errorf("Expected second worker ID 'worker-two', got '%s'", spec.Workers[1].ID) - } - if spec.Workers[1].Priority != 5 { - t.Errorf("Expected second worker priority 5, got %d", spec.Workers[1].Priority) - } -} - -// TestBootstrapAndWorkers_Combined tests campaign with both bootstrap and workers -func TestBootstrapAndWorkers_Combined(t *testing.T) { - yamlContent := `--- -id: test-combined -name: Test Combined -project-url: https://github.com/orgs/test/projects/1 -bootstrap: - mode: seeder-worker - seeder-worker: - workflow-id: scanner - payload: - mode: discovery -workers: - - id: scanner - capabilities: [scan] - payload-schema: - mode: - type: string - description: Scan mode - required: true - output-labeling: - key-in-title: true - idempotency-strategy: cursor-based - - id: fixer - capabilities: [fix] - payload-schema: - alert_id: - type: string - description: Alert ID - required: true - output-labeling: - key-in-title: true - idempotency-strategy: pr-title-based - priority: 10 ---- - -# Test Campaign` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse combined config: %v", err) - } - - // Verify bootstrap - if spec.Bootstrap == nil { - t.Fatal("Bootstrap should not be nil") - } - if spec.Bootstrap.Mode != "seeder-worker" { - t.Errorf("Expected bootstrap mode 'seeder-worker', got '%s'", spec.Bootstrap.Mode) - } - if spec.Bootstrap.SeederWorker.WorkflowID != "scanner" { - t.Errorf("Expected seeder workflow-id 'scanner', got '%s'", spec.Bootstrap.SeederWorker.WorkflowID) - } - - // Verify workers - if len(spec.Workers) != 2 { - t.Fatalf("Expected 2 workers, got %d", len(spec.Workers)) - } - - // Check that seeder worker exists - var scannerFound bool - for _, worker := range spec.Workers { - if worker.ID == "scanner" { - scannerFound = true - if len(worker.Capabilities) != 1 || worker.Capabilities[0] != "scan" { - t.Error("Scanner worker should have 'scan' capability") - } - } - } - if !scannerFound { - t.Error("Scanner worker not found in workers list") - } -} - -// TestBootstrapConfig_JSONSerialization tests JSON marshaling/unmarshaling -func TestBootstrapConfig_JSONSerialization(t *testing.T) { - original := CampaignSpec{ - ID: "test-json", - Name: "Test JSON", - ProjectURL: "https://github.com/orgs/test/projects/1", - Bootstrap: &CampaignBootstrapConfig{ - Mode: "seeder-worker", - SeederWorker: &SeederWorkerConfig{ - WorkflowID: "scanner", - Payload: map[string]any{ - "type": "full-scan", - "max": 100, - }, - MaxItems: 50, - }, - }, - Workers: []WorkerMetadata{ - { - ID: "test-worker", - Capabilities: []string{"scan"}, - PayloadSchema: map[string]WorkerPayloadField{ - "target": { - Type: "string", - Description: "Target to scan", - Required: true, - }, - }, - OutputLabeling: WorkerOutputLabeling{ - KeyInTitle: true, - }, - IdempotencyStrategy: "branch-based", - }, - }, - } - - // Marshal to JSON - jsonData, err := json.Marshal(original) - if err != nil { - t.Fatalf("Failed to marshal to JSON: %v", err) - } - - // Unmarshal from JSON - var restored CampaignSpec - err = json.Unmarshal(jsonData, &restored) - if err != nil { - t.Fatalf("Failed to unmarshal from JSON: %v", err) - } - - // Verify fields - if restored.Bootstrap == nil { - t.Fatal("Restored bootstrap should not be nil") - } - if restored.Bootstrap.Mode != original.Bootstrap.Mode { - t.Errorf("Bootstrap mode mismatch: got '%s', want '%s'", restored.Bootstrap.Mode, original.Bootstrap.Mode) - } - if len(restored.Workers) != len(original.Workers) { - t.Errorf("Worker count mismatch: got %d, want %d", len(restored.Workers), len(original.Workers)) - } -} - -// TestWorkerPayloadField_RequiredDefaultsFalse tests that Required defaults to false -func TestWorkerPayloadField_RequiredDefaultsFalse(t *testing.T) { - yamlContent := `--- -id: test-required -name: Test Required Default -project-url: https://github.com/orgs/test/projects/1 -workers: - - id: test - capabilities: [test] - payload-schema: - optional_field: - type: string - description: Optional field without explicit required - output-labeling: - key-in-title: false - idempotency-strategy: branch-based ---- - -# Test` - - var spec CampaignSpec - err := yaml.Unmarshal([]byte(yamlContent), &spec) - if err != nil { - t.Fatalf("Failed to parse: %v", err) - } - - field := spec.Workers[0].PayloadSchema["optional_field"] - if field.Required { - t.Error("Expected Required to default to false") - } -} diff --git a/pkg/campaign/campaign_test.go b/pkg/campaign/campaign_test.go deleted file mode 100644 index 9a392fe4e00..00000000000 --- a/pkg/campaign/campaign_test.go +++ /dev/null @@ -1,222 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// TestLoadCampaignSpecs_Basic verifies that campaign specs can be loaded -// from the default campaigns directory without errors. -func TestLoadCampaignSpecs_Basic(t *testing.T) { - // Save current directory - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer os.Chdir(originalDir) - - // Change to repository root - repoRoot := filepath.Join(originalDir, "..", "..") - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("Failed to change to repository root: %v", err) - } - - specs, err := LoadSpecs(repoRoot) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - // The repository may or may not have campaign specs, but LoadSpecs should succeed - t.Logf("Found %d campaign specs in repository", len(specs)) - - // If campaign specs exist, verify they have required fields - for _, spec := range specs { - if spec.ID == "" { - t.Errorf("Campaign spec has empty ID: %+v", spec) - } - if spec.Name == "" { - t.Errorf("Campaign spec %s has empty Name", spec.ID) - } - if spec.ConfigPath == "" { - t.Errorf("Campaign spec %s has empty ConfigPath", spec.ID) - } - } -} - -// TestComputeCompiledStateForCampaign_UsesLockFiles checks that compiled -// state reflects presence and freshness of .lock.yml files. -func TestComputeCompiledStateForCampaign_UsesLockFiles(t *testing.T) { - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer os.Chdir(originalDir) - - repoRoot := filepath.Join(originalDir, "..", "..") - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("Failed to change to repository root: %v", err) - } - - specs, err := LoadSpecs(repoRoot) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - var incident CampaignSpec - found := false - for _, spec := range specs { - if spec.ID == "go-file-size-reduction-project64" { - incident = spec - found = true - break - } - } - if !found { - t.Skip("go-file-size-reduction-project64 campaign spec not found; skipping compiled-state test") - } - - state := ComputeCompiledState(incident, ".github/workflows") - if state == "Missing workflow" { - t.Fatalf("Expected go-file-size-reduction-project64 workflows to exist, got compiled state: %s", state) - } -} - -// TestRunCampaignStatus_JSON ensures the campaign list view returns valid JSON -// and that at least one campaign is present. -func TestRunCampaignStatus_JSON(t *testing.T) { - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer os.Chdir(originalDir) - - repoRoot := filepath.Join(originalDir, "..", "..") - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("Failed to change to repository root: %v", err) - } - - // Capture stdout via a pipe; simpler is to call runCampaignStatus and - // re-marshal the result, so instead we directly call the loader and - // verify JSON marshaling there. - specs, err := LoadSpecs(repoRoot) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - data, err := json.Marshal(specs) - if err != nil { - t.Fatalf("Failed to marshal specs to JSON: %v", err) - } - - if len(data) == 0 { - t.Fatalf("Expected non-empty JSON for specs") - } -} - -// TestValidateCampaignSpec_Basic ensures that a minimal but well-formed -// spec passes validation without problems and that defaulting behavior -// (like version) is applied. -func TestValidateCampaignSpec_Basic(t *testing.T) { - spec := &CampaignSpec{ - ID: "go-file-size-reduction", - Name: "Go File Size Reduction", - ProjectURL: "https://github.com/orgs/githubnext/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"daily-file-diet"}, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Fatalf("Expected no validation problems for basic spec, got: %v", problems) - } - - if spec.Version != "v1" { - t.Errorf("Expected default version 'v1', got %q", spec.Version) - } -} - -// TestValidateCampaignSpec_InvalidState verifies that invalid state -// values are reported by validation. -func TestValidateCampaignSpec_InvalidState(t *testing.T) { - spec := &CampaignSpec{ - ID: "rollout-q1-2025", - Name: "Rollout", - ProjectURL: "https://github.com/orgs/githubnext/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"daily-file-diet"}, - State: "launching", // invalid - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatalf("Expected validation problems for invalid state, got none") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "state must be one of") { - found = true - break - } - } - if !found { - t.Errorf("Expected state validation problem, got: %v", problems) - } -} - -// TestComputeCompiledState_LockFilePath verifies that lock file paths are -// correctly constructed (workflow.lock.yml, not workflow.md.lock.yml). -func TestComputeCompiledState_LockFilePath(t *testing.T) { - // Create a temporary directory for test workflows - tmpDir := t.TempDir() - - // Create a workflow .md file and its .lock.yml companion - workflowID := "test-workflow" - mdPath := filepath.Join(tmpDir, workflowID+".md") - lockPath := filepath.Join(tmpDir, workflowID+".lock.yml") - - if err := os.WriteFile(mdPath, []byte("test content"), 0o644); err != nil { - t.Fatalf("Failed to create test workflow: %v", err) - } - if err := os.WriteFile(lockPath, []byte("test lock"), 0o644); err != nil { - t.Fatalf("Failed to create test lock file: %v", err) - } - - spec := CampaignSpec{ - ID: "test-campaign", - Scope: []string{"org/repo1"}, - Workflows: []string{workflowID}, - } - - // This should find the lock file and return "Yes" - state := ComputeCompiledState(spec, tmpDir) - if state != "Yes" { - t.Errorf("Expected compiled state 'Yes' when both .md and .lock.yml exist, got %q", state) - } - - // Now test with only the .md file (remove lock file) - if err := os.Remove(lockPath); err != nil { - t.Fatalf("Failed to remove lock file: %v", err) - } - - state = ComputeCompiledState(spec, tmpDir) - if state != "No" { - t.Errorf("Expected compiled state 'No' when .lock.yml is missing, got %q", state) - } - - // Test that we don't look for the wrong path (workflow.md.lock.yml) - wrongLockPath := mdPath + ".lock.yml" // This would be workflow.md.lock.yml - if err := os.WriteFile(wrongLockPath, []byte("wrong lock"), 0o644); err != nil { - t.Fatalf("Failed to create wrong lock file: %v", err) - } - - state = ComputeCompiledState(spec, tmpDir) - if state != "No" { - t.Errorf("Expected compiled state 'No' because correct lock file doesn't exist (only wrong path exists), got %q", state) - } -} diff --git a/pkg/campaign/command.go b/pkg/campaign/command.go deleted file mode 100644 index 0a4965cfb58..00000000000 --- a/pkg/campaign/command.go +++ /dev/null @@ -1,435 +0,0 @@ -package campaign - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/githubnext/gh-aw/pkg/console" - "github.com/githubnext/gh-aw/pkg/constants" - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/spf13/cobra" -) - -var campaignLog = logger.New("campaign:command") - -// getWorkflowsDir returns the .github/workflows directory path. -// This is a helper to avoid circular dependencies with cli package. -func getWorkflowsDir() string { - return ".github/workflows" -} - -// NewCommand creates the `gh aw campaign` command that surfaces -// first-class campaign definitions from YAML files. -func NewCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "campaign [filter]", - Short: "Inspect first-class campaign definitions from .github/workflows/*.campaign.md", - Long: `List and inspect first-class campaign definitions declared in YAML files. - -Campaigns are defined using Markdown files with YAML frontmatter under the local repository: - - .github/workflows/*.campaign.md - -Each file describes a campaign pattern (ID, name, owners, associated -workflows, repo-memory paths, and risk level). This command provides a -single place to see all campaigns configured for the repo. - -Available subcommands: - • status - Show live status for campaigns (compiled workflows, repo-memory) - • new - Create a new campaign spec file - • validate - Validate campaign spec files for common issues - -Examples: - ` + string(constants.CLIExtensionPrefix) + ` campaign # List all campaigns - ` + string(constants.CLIExtensionPrefix) + ` campaign security # Filter campaigns by ID or name - ` + string(constants.CLIExtensionPrefix) + ` campaign --json # Output campaign definitions as JSON - ` + string(constants.CLIExtensionPrefix) + ` campaign status # Show live campaign status with issue/PR counts - ` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 # Create new campaign spec -`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - return runStatus(pattern, jsonOutput) - }, - } - - cmd.Flags().Bool("json", false, "Output campaign definitions in JSON format") - - // Subcommand: campaign status - statusCmd := &cobra.Command{ - Use: "status [filter]", - Short: "Show live status for campaigns (compiled workflows, repo-memory)", - Long: `Show live status for campaigns, including whether referenced workflows -are compiled and best-effort campaign metrics derived from repo-memory. - -Examples: - ` + string(constants.CLIExtensionPrefix) + ` campaign status # Status for all campaigns - ` + string(constants.CLIExtensionPrefix) + ` campaign status security # Filter by ID or name - ` + string(constants.CLIExtensionPrefix) + ` campaign status --json # JSON status output -`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - return runRuntimeStatus(pattern, jsonOutput) - }, - } - - statusCmd.Flags().Bool("json", false, "Output campaign status in JSON format") - cmd.AddCommand(statusCmd) - - // Subcommand: campaign new - newCmd := &cobra.Command{ - Use: "new ", - Short: "Create a new Markdown campaign spec under .github/workflows/", - Long: `Create a new campaign spec Markdown file under .github/workflows/. - -The file will be created as .github/workflows/.campaign.md with YAML -frontmatter (id, name, version, state, project-url) followed by a -Markdown body. You can then -update owners, workflows, memory paths, metrics-glob, and governance -fields to match your initiative. - -With --interactive flag, enter an interactive wizard to create a comprehensive -campaign spec with guided prompts for: -- Campaign objective and description -- Workflow discovery (optional: scan additional repos/orgs for worker workflows) -- Repository scope (current, multiple repos, or org-wide) -- Workflow selection -- Owners and stakeholders -- Risk level assessment -- Project board creation - -With --project flag, a GitHub Project will be created with: -- Required fields: Campaign Id, Worker Workflow, Priority, Size, Start Date, End Date -- Views: Progress Board (board), Task Tracker (table), Campaign Roadmap (roadmap) -- Linked to a repository (best-effort): defaults to current repo; override with --repo; disable with --no-link-repo -- The project URL will be automatically added to the campaign spec - -Examples: - ` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 - ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization-winter2025 --force - ` + string(constants.CLIExtensionPrefix) + ` campaign new --interactive # Interactive wizard - ` + string(constants.CLIExtensionPrefix) + ` campaign new security-q1-2025 --project --owner @me - ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg - ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg --no-link-repo - ` + string(constants.CLIExtensionPrefix) + ` campaign new modernization --project --owner myorg --repo myorg/myrepo`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - interactive, _ := cmd.Flags().GetBool("interactive") - - // Interactive mode doesn't require campaign ID as argument - if interactive { - force, _ := cmd.Flags().GetBool("force") - verbose, _ := cmd.Flags().GetBool("verbose") - - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - return RunInteractiveCampaignCreation(cwd, force, verbose) - } - - // Non-interactive mode requires campaign ID - if len(args) == 0 { - // Build an error message with suggestions but without the leading - // error prefix icon; the main CLI handler will add that. - var b strings.Builder - b.WriteString("missing campaign id argument") - b.WriteString("\n\nSuggestions:\n") - suggestions := []string{ - "Provide an ID: '" + string(constants.CLIExtensionPrefix) + " campaign new security-q1-2025'", - "Use '" + string(constants.CLIExtensionPrefix) + " campaign' to see existing campaigns", - "Run '" + string(constants.CLIExtensionPrefix) + " help campaign new' for full usage", - } - for _, s := range suggestions { - b.WriteString(" • ") - b.WriteString(s) - b.WriteString("\n") - } - - return errors.New(b.String()) - } - - id := args[0] - force, _ := cmd.Flags().GetBool("force") - createProject, _ := cmd.Flags().GetBool("project") - owner, _ := cmd.Flags().GetString("owner") - repo, _ := cmd.Flags().GetString("repo") - noLinkRepo, _ := cmd.Flags().GetBool("no-link-repo") - verbose, _ := cmd.Flags().GetBool("verbose") - - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - path, err := CreateSpecSkeleton(cwd, id, force) - if err != nil { - return err - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage( - "Created campaign spec at "+path, - )) - - // Create project if requested - if createProject { - if owner == "" { - return fmt.Errorf("--owner is required when using --project flag. Use '@me' for your personal projects or specify an organization name") - } - - // Load the spec to get the campaign name - specs, err := LoadSpecs(cwd) - if err != nil { - return fmt.Errorf("failed to load campaign spec: %w", err) - } - - // Find the newly created spec - var campaignName string - for _, spec := range specs { - if spec.ID == id { - campaignName = spec.Name - break - } - } - - if campaignName == "" { - campaignName = id - } - - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Creating GitHub Project...")) - - projectConfig := ProjectCreationConfig{ - CampaignID: id, - CampaignName: campaignName, - Owner: owner, - LinkRepo: repo, - NoLinkRepo: noLinkRepo, - Verbose: verbose, - } - - result, err := CreateCampaignProject(projectConfig) - if err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage( - fmt.Sprintf("Created project: %s", result.ProjectURL), - )) - - // Update the spec file with the project URL - fullPath := filepath.Join(cwd, path) - if err := UpdateSpecWithProjectURL(fullPath, result.ProjectURL); err != nil { - return fmt.Errorf("failed to update spec with project URL: %w", err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage( - "Updated campaign spec with project URL", - )) - } else { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage( - "Open the file and fill in owners, workflows, memory-paths, and other details.", - )) - } - - return nil - }, - } - - newCmd.Flags().Bool("force", false, "Overwrite existing spec file if it already exists") - newCmd.Flags().BoolP("interactive", "i", false, "Enter interactive mode to create campaign with guided prompts") - newCmd.Flags().Bool("project", false, "Create a GitHub Project with required views and fields") - newCmd.Flags().String("owner", "", "GitHub organization or user for the project (required with --project). Use '@me' for personal projects") - newCmd.Flags().StringP("repo", "r", "", "Repository to link the created project to (owner/name). Defaults to current repo") - newCmd.Flags().Bool("no-link-repo", false, "Disable best-effort project-to-repo linking") - newCmd.Flags().Bool("verbose", false, "Enable verbose output") - cmd.AddCommand(newCmd) - - // Subcommand: campaign validate - validateCmd := &cobra.Command{ - Use: "validate [filter]", - Short: "Validate campaign spec files for common issues", - Long: `Validate campaign spec files under .github/workflows/*.campaign.md. - -This command performs lightweight semantic validation of campaign -definitions (IDs, workflows, lifecycle state, and -other key fields). By default it exits with a non-zero status when -problems are found. - -Examples: - ` + string(constants.CLIExtensionPrefix) + ` campaign validate # Validate all campaigns - ` + string(constants.CLIExtensionPrefix) + ` campaign validate security # Filter by ID or name - ` + string(constants.CLIExtensionPrefix) + ` campaign validate --json # JSON validation report - ` + string(constants.CLIExtensionPrefix) + ` campaign validate --no-strict # Report problems without failing`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var pattern string - if len(args) > 0 { - pattern = args[0] - } - - jsonOutput, _ := cmd.Flags().GetBool("json") - strict, _ := cmd.Flags().GetBool("strict") - return runValidate(pattern, jsonOutput, strict) - }, - } - - validateCmd.Flags().Bool("json", false, "Output campaign validation results in JSON format") - validateCmd.Flags().Bool("strict", true, "Exit with non-zero status if any problems are found") - cmd.AddCommand(validateCmd) - - return cmd -} - -// runStatus is the implementation for the `gh aw campaign` command. -// It loads campaign specs from the local repository and renders them either -// as a console table or JSON. -func runStatus(pattern string, jsonOutput bool) error { - campaignLog.Printf("Running campaign status with pattern: %s, jsonOutput: %v", pattern, jsonOutput) - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := LoadSpecs(cwd) - if err != nil { - campaignLog.Printf("Failed to load campaign specs: %s", err) - return err - } - campaignLog.Printf("Loaded %d campaign specs", len(specs)) - - specs = FilterSpecs(specs, pattern) - campaignLog.Printf("Filtered to %d campaign specs", len(specs)) - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(specs, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaigns as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - return nil - } - - if len(specs) == 0 { - campaignLog.Print("No campaign specs found matching criteria") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under '.github/workflows/*.campaign.md' to define campaigns.")) - return nil - } - - // Render the actual campaign specs (thin, declarative config) as a table. - output := console.RenderStruct(specs) - fmt.Print(output) - return nil -} - -// runRuntimeStatus builds a higher-level view of campaign specs with -// live information derived from GitHub (issue/PR counts) and compiled -// workflow state. -func runRuntimeStatus(pattern string, jsonOutput bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := LoadSpecs(cwd) - if err != nil { - return err - } - - specs = FilterSpecs(specs, pattern) - if len(specs) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under '.github/workflows/*.campaign.md' to define campaigns.")) - return nil - } - - workflowsDir := getWorkflowsDir() - var statuses []CampaignRuntimeStatus - for _, spec := range specs { - status := BuildRuntimeStatus(spec, workflowsDir) - statuses = append(statuses, status) - } - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(statuses, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaign status as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - return nil - } - - output := console.RenderStruct(statuses) - fmt.Print(output) - return nil -} - -// runValidate loads campaign specs and validates them, returning -// a structured report. When strict is true, the command will exit with -// a non-zero status if any problems are found. -func runValidate(pattern string, jsonOutput bool, strict bool) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - - specs, err := LoadSpecs(cwd) - if err != nil { - return err - } - - specs = FilterSpecs(specs, pattern) - if len(specs) == 0 { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("No campaign specs found. Add files under '.github/workflows/*.campaign.md' to define campaigns.")) - return nil - } - - var results []CampaignValidationResult - var totalProblems int - - for i := range specs { - problems := ValidateSpec(&specs[i]) - if len(problems) > 0 { - log.Printf("Validation problems for campaign '%s' (%s): %v", specs[i].ID, specs[i].ConfigPath, problems) - } - - results = append(results, CampaignValidationResult{ - ID: specs[i].ID, - Name: specs[i].Name, - ConfigPath: specs[i].ConfigPath, - Problems: problems, - }) - totalProblems += len(problems) - } - - if jsonOutput { - jsonBytes, err := json.MarshalIndent(results, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal campaign validation results as JSON: %w", err) - } - fmt.Println(string(jsonBytes)) - } else { - output := console.RenderStruct(results) - fmt.Print(output) - } - - if strict && totalProblems > 0 { - return fmt.Errorf("campaign validation failed: %d problem(s) found across %d campaign(s)", totalProblems, len(results)) - } - - return nil -} diff --git a/pkg/campaign/create_test.go b/pkg/campaign/create_test.go deleted file mode 100644 index d4610f85850..00000000000 --- a/pkg/campaign/create_test.go +++ /dev/null @@ -1,237 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCreateSpecSkeleton_Basic(t *testing.T) { - tmpDir := t.TempDir() - - path, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) - if err != nil { - t.Fatalf("CreateSpecSkeleton failed: %v", err) - } - - expectedPath := ".github/workflows/test-campaign.campaign.md" - if path != expectedPath { - t.Errorf("Expected path '%s', got '%s'", expectedPath, path) - } - - // Verify file was created - fullPath := filepath.Join(tmpDir, ".github", "workflows", "test-campaign.campaign.md") - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - t.Errorf("Expected file to be created at %s", fullPath) - } - - // Read and verify content - content, err := os.ReadFile(fullPath) - if err != nil { - t.Fatalf("Failed to read created file: %v", err) - } - - contentStr := string(content) - if !strings.Contains(contentStr, "id: test-campaign") { - t.Error("Expected file to contain 'id: test-campaign'") - } - if !strings.Contains(contentStr, "name: Test campaign") { - t.Error("Expected file to contain 'name: Test campaign'") - } - if !strings.Contains(contentStr, "version: v1") { - t.Error("Expected file to contain 'version: v1'") - } - if !strings.Contains(contentStr, "state: planned") { - t.Error("Expected file to contain 'state: planned'") - } - if strings.Contains(contentStr, "tracker-label:") { - t.Error("Did not expect file to contain legacy 'tracker-label' frontmatter") - } - if !strings.Contains(contentStr, "project-url: https://github.com/orgs/ORG/projects/1") { - t.Error("Expected file to contain 'project-url: https://github.com/orgs/ORG/projects/1'") - } - if !strings.Contains(contentStr, "governance:") { - t.Error("Expected file to contain 'governance:'") - } - if !strings.Contains(contentStr, "max-new-items-per-run: 25") { - t.Error("Expected file to contain 'max-new-items-per-run: 25'") - } - if !strings.Contains(contentStr, "max-discovery-items-per-run: 200") { - t.Error("Expected file to contain 'max-discovery-items-per-run: 200'") - } - if !strings.Contains(contentStr, "max-discovery-pages-per-run: 10") { - t.Error("Expected file to contain 'max-discovery-pages-per-run: 10'") - } - if !strings.Contains(contentStr, "max-project-updates-per-run: 10") { - t.Error("Expected file to contain 'max-project-updates-per-run: 10'") - } - if !strings.Contains(contentStr, "max-comments-per-run: 10") { - t.Error("Expected file to contain 'max-comments-per-run: 10'") - } - if !strings.Contains(contentStr, "cursor-glob: memory/campaigns/test-campaign/cursor.json") { - t.Error("Expected file to contain 'cursor-glob: memory/campaigns/test-campaign/cursor.json'") - } -} - -func TestCreateSpecSkeleton_InvalidID_Empty(t *testing.T) { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, "", false) - if err == nil { - t.Fatal("Expected error for empty ID") - } - - if !strings.Contains(err.Error(), "id is required") { - t.Errorf("Expected 'id is required' error, got: %v", err) - } -} - -func TestCreateSpecSkeleton_InvalidID_Uppercase(t *testing.T) { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, "Test-Campaign", false) - if err == nil { - t.Fatal("Expected error for uppercase in ID") - } - - if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { - t.Errorf("Expected character restriction error, got: %v", err) - } -} - -func TestCreateSpecSkeleton_InvalidID_Underscore(t *testing.T) { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, "test_campaign", false) - if err == nil { - t.Fatal("Expected error for underscore in ID") - } - - if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { - t.Errorf("Expected character restriction error, got: %v", err) - } -} - -func TestCreateSpecSkeleton_InvalidID_Space(t *testing.T) { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, "test campaign", false) - if err == nil { - t.Fatal("Expected error for space in ID") - } - - if !strings.Contains(err.Error(), "lowercase letters, digits, and hyphens") { - t.Errorf("Expected character restriction error, got: %v", err) - } -} - -func TestCreateSpecSkeleton_FileExists_NoForce(t *testing.T) { - tmpDir := t.TempDir() - - // Create first time - _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) - if err != nil { - t.Fatalf("First CreateSpecSkeleton failed: %v", err) - } - - // Try to create again without force - _, err = CreateSpecSkeleton(tmpDir, "test-campaign", false) - if err == nil { - t.Fatal("Expected error when file exists without force flag") - } - - if !strings.Contains(err.Error(), "already exists") { - t.Errorf("Expected 'already exists' error, got: %v", err) - } -} - -func TestCreateSpecSkeleton_FileExists_WithForce(t *testing.T) { - tmpDir := t.TempDir() - - // Create first time - _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) - if err != nil { - t.Fatalf("First CreateSpecSkeleton failed: %v", err) - } - - // Try to create again with force - _, err = CreateSpecSkeleton(tmpDir, "test-campaign", true) - if err != nil { - t.Errorf("CreateSpecSkeleton with force should succeed: %v", err) - } -} - -func TestCreateSpecSkeleton_NameFormatting(t *testing.T) { - tests := []struct { - id string - expectedName string - }{ - {"test", "Test"}, - {"test-campaign", "Test campaign"}, - {"security-q1-2025", "Security q1 2025"}, - {"org-modernization", "Org modernization"}, - } - - for _, tt := range tests { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, tt.id, false) - if err != nil { - t.Fatalf("CreateSpecSkeleton failed for ID '%s': %v", tt.id, err) - } - - // Load the created spec - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 1 { - t.Fatalf("Expected 1 spec, got %d", len(specs)) - } - - if specs[0].Name != tt.expectedName { - t.Errorf("For ID '%s', expected name '%s', got '%s'", tt.id, tt.expectedName, specs[0].Name) - } - } -} - -func TestCreateSpecSkeleton_CreatesDirectory(t *testing.T) { - tmpDir := t.TempDir() - // Don't create .github/workflows directory beforehand - - _, err := CreateSpecSkeleton(tmpDir, "test-campaign", false) - if err != nil { - t.Fatalf("CreateSpecSkeleton failed: %v", err) - } - - // Verify .github/workflows directory was created - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { - t.Error("Expected .github/workflows directory to be created") - } -} - -func TestCreateSpecSkeleton_ValidIDs(t *testing.T) { - validIDs := []string{ - "test", - "test-campaign", - "test123", - "test-123-campaign", - "123-test", - "a", - "1", - } - - for _, id := range validIDs { - tmpDir := t.TempDir() - - _, err := CreateSpecSkeleton(tmpDir, id, false) - if err != nil { - t.Errorf("Expected ID '%s' to be valid, got error: %v", id, err) - } - } -} diff --git a/pkg/campaign/filter_test.go b/pkg/campaign/filter_test.go deleted file mode 100644 index c06be810448..00000000000 --- a/pkg/campaign/filter_test.go +++ /dev/null @@ -1,141 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "testing" -) - -func TestFilterSpecs_EmptyPattern(t *testing.T) { - specs := []CampaignSpec{ - {ID: "campaign1", Name: "Campaign One"}, - {ID: "campaign2", Name: "Campaign Two"}, - {ID: "campaign3", Name: "Campaign Three"}, - } - - filtered := FilterSpecs(specs, "") - - if len(filtered) != len(specs) { - t.Errorf("Expected %d specs with empty pattern, got %d", len(specs), len(filtered)) - } -} - -func TestFilterSpecs_MatchByID(t *testing.T) { - specs := []CampaignSpec{ - {ID: "security-alpha", Name: "Security Alpha"}, - {ID: "incident-beta", Name: "Incident Beta"}, - {ID: "modernization-gamma", Name: "Modernization Gamma"}, - } - - filtered := FilterSpecs(specs, "security") - - if len(filtered) != 1 { - t.Fatalf("Expected 1 spec matching 'security', got %d", len(filtered)) - } - - if filtered[0].ID != "security-alpha" { - t.Errorf("Expected to find 'security-alpha', got '%s'", filtered[0].ID) - } -} - -func TestFilterSpecs_MatchByName(t *testing.T) { - specs := []CampaignSpec{ - {ID: "sec-comp", Name: "Security Compliance"}, - {ID: "incident", Name: "Incident Response"}, - {ID: "modernization", Name: "Org Modernization"}, - } - - filtered := FilterSpecs(specs, "Response") - - if len(filtered) != 1 { - t.Fatalf("Expected 1 spec matching 'Response', got %d", len(filtered)) - } - - if filtered[0].ID != "incident" { - t.Errorf("Expected to find 'incident', got '%s'", filtered[0].ID) - } -} - -func TestFilterSpecs_CaseInsensitive(t *testing.T) { - specs := []CampaignSpec{ - {ID: "security-alpha", Name: "Security Alpha"}, - {ID: "incident-beta", Name: "Incident Beta"}, - } - - tests := []struct { - pattern string - wantLen int - }{ - {"SECURITY", 1}, - {"Security", 1}, - {"security", 1}, - {"INCIDENT", 1}, - {"incident", 1}, - } - - for _, tt := range tests { - filtered := FilterSpecs(specs, tt.pattern) - if len(filtered) != tt.wantLen { - t.Errorf("Pattern '%s': expected %d matches, got %d", tt.pattern, tt.wantLen, len(filtered)) - } - } -} - -func TestFilterSpecs_MultipleMatches(t *testing.T) { - specs := []CampaignSpec{ - {ID: "security-q1", Name: "Security Q1"}, - {ID: "security-q2", Name: "Security Q2"}, - {ID: "incident-beta", Name: "Incident Beta"}, - } - - filtered := FilterSpecs(specs, "security") - - if len(filtered) != 2 { - t.Fatalf("Expected 2 specs matching 'security', got %d", len(filtered)) - } - - foundQ1, foundQ2 := false, false - for _, spec := range filtered { - if spec.ID == "security-q1" { - foundQ1 = true - } - if spec.ID == "security-q2" { - foundQ2 = true - } - } - - if !foundQ1 || !foundQ2 { - t.Error("Expected to find both security-q1 and security-q2") - } -} - -func TestFilterSpecs_NoMatches(t *testing.T) { - specs := []CampaignSpec{ - {ID: "security-alpha", Name: "Security Alpha"}, - {ID: "incident-beta", Name: "Incident Beta"}, - } - - filtered := FilterSpecs(specs, "nonexistent") - - if len(filtered) != 0 { - t.Errorf("Expected 0 specs matching 'nonexistent', got %d", len(filtered)) - } -} - -func TestFilterSpecs_PartialMatch(t *testing.T) { - specs := []CampaignSpec{ - {ID: "compliance-alpha", Name: "Compliance Alpha"}, - {ID: "incident-beta", Name: "Incident Beta"}, - {ID: "modernization-gamma", Name: "Modernization Gamma"}, - } - - filtered := FilterSpecs(specs, "comp") - - if len(filtered) != 1 { - t.Fatalf("Expected 1 spec matching 'comp', got %d", len(filtered)) - } - - if filtered[0].ID != "compliance-alpha" { - t.Errorf("Expected to find 'compliance-alpha', got '%s'", filtered[0].ID) - } -} diff --git a/pkg/campaign/injection.go b/pkg/campaign/injection.go deleted file mode 100644 index 37276d6b50b..00000000000 --- a/pkg/campaign/injection.go +++ /dev/null @@ -1,264 +0,0 @@ -package campaign - -import ( - "fmt" - "regexp" - "strings" - - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/workflow" -) - -var injectionLog = logger.New("campaign:injection") - -var campaignIDSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) - -func normalizeCampaignID(id string) string { - // Keep IDs stable and safe for labels/paths. - id = strings.ToLower(strings.TrimSpace(id)) - id = strings.ReplaceAll(id, "_", "-") - id = strings.ReplaceAll(id, " ", "-") - id = campaignIDSanitizer.ReplaceAllString(id, "-") - // Collapse multiple hyphens into single hyphen - for strings.Contains(id, "--") { - id = strings.ReplaceAll(id, "--", "-") - } - id = strings.Trim(id, "-") - return id -} - -// InjectOrchestratorFeatures detects if a workflow has project field with campaign -// configuration and injects orchestrator features directly into the workflow during compilation. -// This transforms the workflow into a campaign orchestrator without generating separate files. -func InjectOrchestratorFeatures(workflowData *workflow.WorkflowData) error { - injectionLog.Print("Checking workflow for campaign orchestrator features") - - // Check if this workflow has project configuration with campaign fields - if workflowData.ParsedFrontmatter == nil || workflowData.ParsedFrontmatter.Project == nil { - injectionLog.Print("No project field detected, skipping campaign injection") - return nil - } - - project := workflowData.ParsedFrontmatter.Project - - // Determine whether the project config looks like "project tracking" only. - // A minimal campaign can specify only the project URL (either short or long form) and omit - // campaign fields like id/workflows; in that case we infer the campaign ID from the workflow filename. - hasTrackingOnlySettings := len(project.Scope) > 0 || - project.MaxUpdates > 0 || - project.MaxStatusUpdates > 0 || - strings.TrimSpace(project.GitHubToken) != "" || - project.DoNotDowngradeDoneItems != nil - - // Check if project has any campaign orchestration fields to determine if this is a campaign - // Campaign indicators (any of these present means it's a campaign orchestrator): - // - explicit campaign ID - // - workflows list (predefined workers) - // - governance policies (campaign-specific constraints) - // - bootstrap configuration (initial work item generation) - // - memory-paths, metrics-glob, cursor-glob (campaign state tracking) - // If only URL and scope are present, it's simple project tracking, not a campaign - hasCampaignIndicators := strings.TrimSpace(project.ID) != "" || - len(project.Workflows) > 0 || - project.Governance != nil || - project.Bootstrap != nil || - len(project.MemoryPaths) > 0 || - project.MetricsGlob != "" || - project.CursorGlob != "" - - // If the user used the object form with only a URL (no tracking-only knobs), treat it as a campaign - // and infer the campaign ID from the workflow filename (minus .md). - if !hasCampaignIndicators && !hasTrackingOnlySettings { - if workflowData.WorkflowID != "" { - project.ID = workflowData.WorkflowID - hasCampaignIndicators = true - } - } - - isCampaign := hasCampaignIndicators - - if !isCampaign { - injectionLog.Print("Project field present but no campaign indicators, treating as simple project tracking") - return nil - } - - injectionLog.Printf("Detected campaign orchestrator: workflows=%d, has_governance=%v, has_bootstrap=%v", - len(project.Workflows), project.Governance != nil, project.Bootstrap != nil) - - // Derive campaign ID (prefer explicit id, then workflow filename, then workflow name). - // Note: workflowData.FrontmatterName is the *frontmatter name field* (display name), not the file basename. - campaignID := "" - if strings.TrimSpace(project.ID) != "" { - campaignID = project.ID - } else if strings.TrimSpace(workflowData.WorkflowID) != "" { - campaignID = workflowData.WorkflowID - } else if strings.TrimSpace(workflowData.Name) != "" { - campaignID = workflowData.Name - } else { - campaignID = "campaign" - } - campaignID = normalizeCampaignID(campaignID) - if campaignID == "" { - campaignID = "campaign" - } - - // Apply campaign defaults (matching the historical .campaign.md defaults) when omitted. - // This keeps project-based campaigns minimal: users can specify just url + id. - project.ID = campaignID - if strings.TrimSpace(project.TrackerLabel) == "" { - project.TrackerLabel = fmt.Sprintf("z_campaign_%s", campaignID) - } - if len(project.MemoryPaths) == 0 { - project.MemoryPaths = []string{fmt.Sprintf("memory/campaigns/%s/**", campaignID)} - } - if strings.TrimSpace(project.MetricsGlob) == "" { - project.MetricsGlob = fmt.Sprintf("memory/campaigns/%s/metrics/*.json", campaignID) - } - if strings.TrimSpace(project.CursorGlob) == "" { - project.CursorGlob = fmt.Sprintf("memory/campaigns/%s/cursor.json", campaignID) - } - - // Build campaign prompt data from project configuration - promptData := CampaignPromptData{ - CampaignID: campaignID, - CampaignName: workflowData.Name, - ProjectURL: project.URL, - CursorGlob: project.CursorGlob, - MetricsGlob: project.MetricsGlob, - Workflows: project.Workflows, - } - - if project.Governance != nil { - promptData.MaxDiscoveryItemsPerRun = project.Governance.MaxDiscoveryItemsPerRun - promptData.MaxDiscoveryPagesPerRun = project.Governance.MaxDiscoveryPagesPerRun - promptData.MaxProjectUpdatesPerRun = project.Governance.MaxProjectUpdatesPerRun - promptData.MaxProjectCommentsPerRun = project.Governance.MaxCommentsPerRun - } - - if project.Bootstrap != nil { - promptData.BootstrapMode = project.Bootstrap.Mode - if project.Bootstrap.SeederWorker != nil { - promptData.SeederWorkerID = project.Bootstrap.SeederWorker.WorkflowID - promptData.SeederMaxItems = project.Bootstrap.SeederWorker.MaxItems - } - if project.Bootstrap.ProjectTodos != nil { - promptData.StatusField = project.Bootstrap.ProjectTodos.StatusField - if promptData.StatusField == "" { - promptData.StatusField = "Status" - } - promptData.TodoValue = project.Bootstrap.ProjectTodos.TodoValue - if promptData.TodoValue == "" { - promptData.TodoValue = "Todo" - } - promptData.TodoMaxItems = project.Bootstrap.ProjectTodos.MaxItems - promptData.RequireFields = project.Bootstrap.ProjectTodos.RequireFields - } - } - - if len(project.Workers) > 0 { - promptData.WorkerMetadata = make([]WorkerMetadata, len(project.Workers)) - for i, w := range project.Workers { - promptData.WorkerMetadata[i] = WorkerMetadata{ - ID: w.ID, - Name: w.Name, - Description: w.Description, - Capabilities: w.Capabilities, - IdempotencyStrategy: w.IdempotencyStrategy, - Priority: w.Priority, - } - // Convert payload schema - if len(w.PayloadSchema) > 0 { - promptData.WorkerMetadata[i].PayloadSchema = make(map[string]WorkerPayloadField) - for key, field := range w.PayloadSchema { - promptData.WorkerMetadata[i].PayloadSchema[key] = WorkerPayloadField{ - Type: field.Type, - Description: field.Description, - Required: field.Required, - Example: field.Example, - } - } - } - // Convert output labeling - promptData.WorkerMetadata[i].OutputLabeling = WorkerOutputLabeling{ - Labels: w.OutputLabeling.Labels, - KeyInTitle: w.OutputLabeling.KeyInTitle, - KeyFormat: w.OutputLabeling.KeyFormat, - MetadataFields: w.OutputLabeling.MetadataFields, - } - } - } - - // Append orchestrator instructions to markdown content - markdownBuilder := &strings.Builder{} - markdownBuilder.WriteString(workflowData.MarkdownContent) - markdownBuilder.WriteString("\n\n") - - // Add bootstrap instructions if configured - if project.Bootstrap != nil && project.Bootstrap.Mode != "" { - bootstrapInstructions := RenderBootstrapInstructions(promptData) - if bootstrapInstructions != "" { - AppendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions) - } - } - - // Add workflow execution instructions - workflowExecution := RenderWorkflowExecution(promptData) - if workflowExecution != "" { - AppendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution) - } - - // Add orchestrator instructions - orchestratorInstructions := RenderOrchestratorInstructions(promptData) - if orchestratorInstructions != "" { - AppendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions) - } - - // Add project update instructions - projectInstructions := RenderProjectUpdateInstructions(promptData) - if projectInstructions != "" { - AppendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions) - } - - // Add closing instructions - closingInstructions := RenderClosingInstructions() - if closingInstructions != "" { - AppendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions) - } - - // Update the workflow markdown content with injected instructions - workflowData.MarkdownContent = markdownBuilder.String() - injectionLog.Printf("Injected campaign orchestrator instructions into workflow markdown") - - // Configure safe-outputs for campaign orchestration - if workflowData.SafeOutputs == nil { - workflowData.SafeOutputs = &workflow.SafeOutputsConfig{} - } - - // Configure dispatch-workflow for worker coordination (optional - only if workflows are specified) - if len(project.Workflows) > 0 && workflowData.SafeOutputs.DispatchWorkflow == nil { - workflowData.SafeOutputs.DispatchWorkflow = &workflow.DispatchWorkflowConfig{ - BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3}, - Workflows: project.Workflows, - } - injectionLog.Printf("Configured dispatch-workflow safe-output for %d workflows", len(project.Workflows)) - } else if len(project.Workflows) == 0 { - injectionLog.Print("No workflows specified - campaign will use custom discovery and dispatch logic") - } - - // Configure update-project (already handled by applyProjectSafeOutputs, but ensure governance max is applied) - if project.Governance != nil && project.Governance.MaxProjectUpdatesPerRun > 0 { - if workflowData.SafeOutputs.UpdateProjects != nil { - workflowData.SafeOutputs.UpdateProjects.Max = project.Governance.MaxProjectUpdatesPerRun - injectionLog.Printf("Applied governance max-project-updates-per-run: %d", project.Governance.MaxProjectUpdatesPerRun) - } - } - - // Add concurrency control for campaigns if not already set - if workflowData.Concurrency == "" { - workflowData.Concurrency = fmt.Sprintf("concurrency:\n group: \"campaign-%s-orchestrator-${{ github.ref }}\"\n cancel-in-progress: false", campaignID) - injectionLog.Printf("Added campaign concurrency control") - } - - injectionLog.Printf("Successfully injected campaign orchestrator features for: %s", campaignID) - return nil -} diff --git a/pkg/campaign/injection_test.go b/pkg/campaign/injection_test.go deleted file mode 100644 index 1fd4d2256a3..00000000000 --- a/pkg/campaign/injection_test.go +++ /dev/null @@ -1,462 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "testing" - - "github.com/githubnext/gh-aw/pkg/workflow" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestInjectOrchestratorFeatures_ProjectIDTriggersCampaignAndDefaults(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Security Alert Burndown", - WorkflowID: "security-alert-burndown", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "campaign injection should succeed") - require.NotNil(t, data.ParsedFrontmatter, "ParsedFrontmatter should remain") - require.NotNil(t, data.ParsedFrontmatter.Project, "Project should remain") - - project := data.ParsedFrontmatter.Project - assert.Equal(t, "security-alert-burndown", project.ID, "Campaign ID should be inferred and normalized") - assert.Equal(t, "z_campaign_security-alert-burndown", project.TrackerLabel, "TrackerLabel should be defaulted") - assert.Equal(t, []string{"memory/campaigns/security-alert-burndown/**"}, project.MemoryPaths, "MemoryPaths should be defaulted") - assert.Equal(t, "memory/campaigns/security-alert-burndown/metrics/*.json", project.MetricsGlob, "MetricsGlob should be defaulted") - assert.Equal(t, "memory/campaigns/security-alert-burndown/cursor.json", project.CursorGlob, "CursorGlob should be defaulted") - - assert.Contains(t, data.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "Markdown should have orchestrator instructions injected") -} - -func TestInjectOrchestratorFeatures_ProjectTrackingOnly_DoesNotInject(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Project Tracking Only", - FrontmatterName: "project-tracking-only", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Scope: []string{"githubnext/gh-aw"}, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "non-campaign project tracking should be a no-op") - assert.NotContains(t, data.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "Should not inject campaign sections") -} - -func TestInjectOrchestratorFeatures_EdgeCases(t *testing.T) { - tests := []struct { - name string - workflowData *workflow.WorkflowData - shouldErr bool - expectInjection bool - expectedCampaignID string - }{ - { - name: "nil frontmatter - should skip", - workflowData: &workflow.WorkflowData{ - Name: "Test", - WorkflowID: "test", - MarkdownContent: "# Test", - ParsedFrontmatter: nil, - }, - shouldErr: false, - expectInjection: false, - }, - { - name: "nil project - should skip", - workflowData: &workflow.WorkflowData{ - Name: "Test", - WorkflowID: "test", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: nil, - }, - }, - shouldErr: false, - expectInjection: false, - }, - { - name: "infer campaign ID from WorkflowID", - workflowData: &workflow.WorkflowData{ - Name: "Security Alert Burndown", - WorkflowID: "security-alert-burndown", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - }, - }, - }, - shouldErr: false, - expectInjection: true, - expectedCampaignID: "security-alert-burndown", - }, - { - name: "explicit campaign ID with WorkflowID", - workflowData: &workflow.WorkflowData{ - Name: "Security Alert Burndown", - WorkflowID: "security-alert-burndown", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - ID: "custom-campaign-id", - }, - }, - }, - shouldErr: false, - expectInjection: true, - expectedCampaignID: "custom-campaign-id", - }, - { - name: "campaign ID inferred from Name when WorkflowID empty but campaign indicator present", - workflowData: &workflow.WorkflowData{ - Name: "Security Alert Burndown", - WorkflowID: "", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Workflows: []string{"worker.md"}, // Campaign indicator - }, - }, - }, - shouldErr: false, - expectInjection: true, - expectedCampaignID: "security-alert-burndown", - }, - { - name: "fallback to default campaign ID when all identifiers empty", - workflowData: &workflow.WorkflowData{ - Name: "", - WorkflowID: "", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Workflows: []string{"worker.md"}, // Campaign indicator - }, - }, - }, - shouldErr: false, - expectInjection: true, - expectedCampaignID: "campaign", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := InjectOrchestratorFeatures(tt.workflowData) - - if tt.shouldErr { - require.Error(t, err) - } else { - require.NoError(t, err) - - if tt.expectInjection { - assert.Contains(t, tt.workflowData.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "should inject campaign sections") - if tt.expectedCampaignID != "" { - assert.Equal(t, tt.expectedCampaignID, tt.workflowData.ParsedFrontmatter.Project.ID, "campaign ID should match expected") - } - } else { - assert.NotContains(t, tt.workflowData.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "should not inject campaign sections") - } - } - }) - } -} - -func TestInjectOrchestratorFeatures_GovernanceConfiguration(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Campaign with Governance", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Governance: &workflow.CampaignGovernanceConfig{ - MaxDiscoveryItemsPerRun: 10, - MaxDiscoveryPagesPerRun: 5, - MaxProjectUpdatesPerRun: 20, - MaxCommentsPerRun: 15, - }, - }, - }, - SafeOutputs: &workflow.SafeOutputsConfig{ - UpdateProjects: &workflow.UpdateProjectConfig{ - BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 5}, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should handle governance configuration") - - // Verify governance max is applied to safe-outputs - assert.Equal(t, 20, data.SafeOutputs.UpdateProjects.Max, "governance max should override safe-outputs max") -} - -func TestInjectOrchestratorFeatures_BootstrapConfiguration(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Campaign with Bootstrap", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Bootstrap: &workflow.CampaignBootstrapConfig{ - Mode: "seeder", - SeederWorker: &workflow.SeederWorkerConfig{ - WorkflowID: "seeder-workflow", - MaxItems: 50, - }, - }, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should handle bootstrap configuration") - assert.Contains(t, data.MarkdownContent, "BOOTSTRAP INSTRUCTIONS", "should include bootstrap instructions") -} - -func TestInjectOrchestratorFeatures_WorkersConfiguration(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Campaign with Workers", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Workers: []workflow.WorkerMetadata{ - { - ID: "worker-1", - Name: "Test Worker", - Description: "A test worker", - PayloadSchema: map[string]workflow.WorkerPayloadField{ - "field1": { - Type: "string", - Description: "Test field", - Required: true, - Example: "example", - }, - }, - OutputLabeling: workflow.WorkerOutputLabeling{ - Labels: []string{"test-label"}, - KeyInTitle: true, - }, - }, - }, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should handle workers configuration") - assert.Contains(t, data.MarkdownContent, "ORCHESTRATOR INSTRUCTIONS", "should inject orchestrator instructions") -} - -func TestInjectOrchestratorFeatures_SafeOutputsDispatchWorkflow(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Campaign with Workflows", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - Workflows: []string{"worker-1.md", "worker-2.md"}, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should configure dispatch-workflow") - require.NotNil(t, data.SafeOutputs, "SafeOutputs should be initialized") - require.NotNil(t, data.SafeOutputs.DispatchWorkflow, "DispatchWorkflow should be configured") - assert.Equal(t, 3, data.SafeOutputs.DispatchWorkflow.Max, "should have max of 3") - assert.Equal(t, []string{"worker-1.md", "worker-2.md"}, data.SafeOutputs.DispatchWorkflow.Workflows, "should have configured workflows") -} - -func TestInjectOrchestratorFeatures_ConcurrencyControl(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Campaign", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - Concurrency: "", // Empty - should be injected - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should add concurrency control") - assert.Contains(t, data.Concurrency, "campaign-test-campaign-orchestrator", "should include campaign ID in concurrency group") - assert.Contains(t, data.Concurrency, "cancel-in-progress: false", "should not cancel in progress") -} - -func TestInjectOrchestratorFeatures_ConcurrencyAlreadySet(t *testing.T) { - existingConcurrency := "concurrency:\n group: custom-group\n cancel-in-progress: true" - data := &workflow.WorkflowData{ - Name: "Campaign", - WorkflowID: "test-campaign", - MarkdownContent: "# Test", - Concurrency: existingConcurrency, - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should not override existing concurrency") - assert.Equal(t, existingConcurrency, data.Concurrency, "should preserve existing concurrency") -} - -func TestInjectOrchestratorFeatures_DefaultsApplication(t *testing.T) { - tests := []struct { - name string - campaignID string - initialTrackerLabel string - initialMemoryPaths []string - initialMetricsGlob string - initialCursorGlob string - expectedTrackerLabel string - expectedMemoryPaths []string - expectedMetricsGlob string - expectedCursorGlob string - }{ - { - name: "all defaults applied", - campaignID: "test-campaign", - initialTrackerLabel: "", - initialMemoryPaths: nil, - initialMetricsGlob: "", - initialCursorGlob: "", - expectedTrackerLabel: "z_campaign_test-campaign", - expectedMemoryPaths: []string{"memory/campaigns/test-campaign/**"}, - expectedMetricsGlob: "memory/campaigns/test-campaign/metrics/*.json", - expectedCursorGlob: "memory/campaigns/test-campaign/cursor.json", - }, - { - name: "explicit values preserved", - campaignID: "test-campaign", - initialTrackerLabel: "custom-label", - initialMemoryPaths: []string{"custom/path/**"}, - initialMetricsGlob: "custom/metrics/*.json", - initialCursorGlob: "custom/cursor.json", - expectedTrackerLabel: "custom-label", - expectedMemoryPaths: []string{"custom/path/**"}, - expectedMetricsGlob: "custom/metrics/*.json", - expectedCursorGlob: "custom/cursor.json", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := &workflow.WorkflowData{ - Name: "Test Campaign", - WorkflowID: tt.campaignID, - MarkdownContent: "# Test", - ParsedFrontmatter: &workflow.FrontmatterConfig{ - Project: &workflow.ProjectConfig{ - URL: "https://github.com/orgs/githubnext/projects/144", - TrackerLabel: tt.initialTrackerLabel, - MemoryPaths: tt.initialMemoryPaths, - MetricsGlob: tt.initialMetricsGlob, - CursorGlob: tt.initialCursorGlob, - }, - }, - } - - err := InjectOrchestratorFeatures(data) - require.NoError(t, err, "should apply defaults") - - project := data.ParsedFrontmatter.Project - assert.Equal(t, tt.expectedTrackerLabel, project.TrackerLabel, "TrackerLabel should match") - assert.Equal(t, tt.expectedMemoryPaths, project.MemoryPaths, "MemoryPaths should match") - assert.Equal(t, tt.expectedMetricsGlob, project.MetricsGlob, "MetricsGlob should match") - assert.Equal(t, tt.expectedCursorGlob, project.CursorGlob, "CursorGlob should match") - }) - } -} - -func TestNormalizeCampaignID(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "mixed case with spaces", - input: "Security Alert Burndown", - expected: "security-alert-burndown", - }, - { - name: "underscores to hyphens", - input: "security_alert_burndown", - expected: "security-alert-burndown", - }, - { - name: "special characters and extra hyphens", - input: " security---alert@@burndown ", - expected: "security-alert-burndown", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "only whitespace", - input: " ", - expected: "", - }, - { - name: "only special characters", - input: "@@##$$", - expected: "", - }, - { - name: "already normalized", - input: "campaign-name", - expected: "campaign-name", - }, - { - name: "numbers preserved", - input: "Campaign 123 Test", - expected: "campaign-123-test", - }, - { - name: "leading and trailing hyphens", - input: "---campaign---", - expected: "campaign", - }, - { - name: "unicode characters", - input: "Campaña Tëst", - expected: "campa-a-t-st", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeCampaignID(tt.input) - assert.Equal(t, tt.expected, result, "normalization should match expected output") - }) - } -} diff --git a/pkg/campaign/interactive.go b/pkg/campaign/interactive.go deleted file mode 100644 index 7d25fbac35a..00000000000 --- a/pkg/campaign/interactive.go +++ /dev/null @@ -1,511 +0,0 @@ -package campaign - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/huh" - "github.com/githubnext/gh-aw/pkg/console" - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/goccy/go-yaml" -) - -var interactiveLog = logger.New("campaign:interactive") - -// InteractiveCampaignConfig holds the configuration for interactive campaign creation -type InteractiveCampaignConfig struct { - ID string - Name string - Description string - Scope string // "current", "multiple", "org-wide" - ScopeSelectors []string - Workflows []string - Owners []string - RiskLevel string - CreateProject bool - ProjectOwner string - LinkRepo string - Force bool -} - -// RunInteractiveCampaignCreation runs an interactive wizard to create a campaign spec -func RunInteractiveCampaignCreation(rootDir string, force bool, verbose bool) error { - interactiveLog.Print("Starting interactive campaign creation") - - // Assert this function is not running in automated unit tests - if os.Getenv("GO_TEST_MODE") == "true" || os.Getenv("CI") != "" { - return fmt.Errorf("interactive mode cannot be used in automated tests or CI environments") - } - - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("🎯 Let's create your agentic campaign!")) - fmt.Fprintln(os.Stderr, "") - - config := &InteractiveCampaignConfig{ - Force: force, - } - - // Step 1: Campaign ID - if err := promptForCampaignID(config); err != nil { - return err - } - - // Step 2: Campaign objective/description - if err := promptForObjective(config); err != nil { - return err - } - - // Step 3: Repository scope - if err := promptForRepositoryScope(config); err != nil { - return err - } - - // Step 4: Workflow selection - if err := promptForWorkflows(config); err != nil { - return err - } - - // Step 5: Owners/stakeholders - if err := promptForOwners(config); err != nil { - return err - } - - // Step 6: Risk level - if err := promptForRiskLevel(config); err != nil { - return err - } - - // Step 7: Project board creation - if err := promptForProjectCreation(config); err != nil { - return err - } - - // Generate the campaign spec - if err := generateCampaignFromConfig(rootDir, config, verbose); err != nil { - return err - } - - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✅ Campaign spec created successfully!")) - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Next steps:")) - fmt.Fprintln(os.Stderr, " 1. Review and edit: .github/workflows/"+config.ID+".campaign.md") - fmt.Fprintln(os.Stderr, " 2. Compile the orchestrator: gh aw compile") - if config.CreateProject { - fmt.Fprintln(os.Stderr, " 3. Project board will be created during compilation") - } - fmt.Fprintln(os.Stderr, "") - - return nil -} - -func promptForCampaignID(config *InteractiveCampaignConfig) error { - var id string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Campaign ID"). - Description("Use lowercase letters, digits, and hyphens (e.g., security-q1-2025)"). - Placeholder("my-campaign"). - Value(&id). - Validate(func(s string) error { - s = strings.TrimSpace(s) - if s == "" { - return fmt.Errorf("campaign ID is required") - } - for _, ch := range s { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { - continue - } - return fmt.Errorf("campaign ID must use only lowercase letters, digits, and hyphens") - } - return nil - }), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("campaign ID input failed: %w", err) - } - - config.ID = strings.TrimSpace(id) - config.Name = formatCampaignName(config.ID) - interactiveLog.Printf("Campaign ID: %s", config.ID) - return nil -} - -func promptForObjective(config *InteractiveCampaignConfig) error { - var objective string - form := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("What is the main objective of this campaign?"). - Description("Describe what you want to achieve (e.g., 'Reduce critical vulnerabilities across all repositories')"). - Placeholder("Enter campaign objective..."). - Value(&objective). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("objective is required") - } - return nil - }), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("objective input failed: %w", err) - } - - config.Description = strings.TrimSpace(objective) - interactiveLog.Printf("Campaign description: %s", config.Description) - return nil -} - -func promptForRepositoryScope(config *InteractiveCampaignConfig) error { - var scopeType string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("What is the repository scope for this campaign?"). - Options( - huh.NewOption("Current repository only", "current"), - huh.NewOption("Specific repositories", "multiple"), - huh.NewOption("Organization-wide", "org-wide"), - ). - Value(&scopeType), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("scope selection failed: %w", err) - } - - config.Scope = scopeType - - switch scopeType { - case "current": - currentRepo, err := getCurrentRepository() - if err != nil { - interactiveLog.Printf("Warning: could not determine current repository for scope: %v", err) - break - } - config.ScopeSelectors = []string{currentRepo} - case "multiple": - var reposInput string - reposForm := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Allowed repositories"). - Description("Enter repositories this campaign can operate on (comma-separated, e.g., 'owner/repo1, owner/repo2')"). - Placeholder("owner/repo1, owner/repo2"). - Value(&reposInput). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("at least one repository is required") - } - return nil - }), - ), - ) - - if err := reposForm.Run(); err != nil { - return fmt.Errorf("allowed repos input failed: %w", err) - } - - repos := strings.Split(reposInput, ",") - for _, repo := range repos { - repo = strings.TrimSpace(repo) - if repo != "" { - config.ScopeSelectors = append(config.ScopeSelectors, repo) - } - } - case "org-wide": - var orgsInput string - orgsForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Organization name"). - Description("Enter the organization name (e.g., 'myorg')"). - Placeholder("myorg"). - Value(&orgsInput). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("organization name is required") - } - return nil - }), - ), - ) - - if err := orgsForm.Run(); err != nil { - return fmt.Errorf("allowed org input failed: %w", err) - } - - org := strings.TrimSpace(orgsInput) - if org != "" { - config.ScopeSelectors = append(config.ScopeSelectors, "org:"+org) - } - } - - interactiveLog.Printf("Scope: %s, selectors: %v", config.Scope, config.ScopeSelectors) - return nil -} - -func promptForWorkflows(config *InteractiveCampaignConfig) error { - var workflowsInput string - form := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Which workflows should this campaign use?"). - Description("Enter workflow names (comma-separated, e.g., 'vulnerability-scanner, dependency-updater'). Leave empty to configure later."). - Placeholder("workflow-1, workflow-2"). - Value(&workflowsInput), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("workflows input failed: %w", err) - } - - if strings.TrimSpace(workflowsInput) != "" { - workflows := strings.Split(workflowsInput, ",") - for _, workflow := range workflows { - workflow = strings.TrimSpace(workflow) - if workflow != "" { - config.Workflows = append(config.Workflows, workflow) - } - } - } - - interactiveLog.Printf("Workflows: %v", config.Workflows) - return nil -} - -func promptForOwners(config *InteractiveCampaignConfig) error { - var ownersInput string - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Who are the campaign owners?"). - Description("Enter GitHub usernames (comma-separated, with @ prefix, e.g., '@alice, @bob'). Leave empty to configure later."). - Placeholder("@username"). - Value(&ownersInput), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("owners input failed: %w", err) - } - - if strings.TrimSpace(ownersInput) != "" { - owners := strings.Split(ownersInput, ",") - for _, owner := range owners { - trimmedOwner := strings.TrimSpace(owner) - if trimmedOwner != "" { - if !strings.HasPrefix(trimmedOwner, "@") { - trimmedOwner = "@" + trimmedOwner - } - config.Owners = append(config.Owners, trimmedOwner) - } - } - } - - interactiveLog.Printf("Owners: %v", config.Owners) - return nil -} - -func promptForRiskLevel(config *InteractiveCampaignConfig) error { - var riskLevel string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("What is the risk level for this campaign?"). - Description("Risk level determines approval requirements"). - Options( - huh.NewOption("Low - Read-only, single repo", "low"), - huh.NewOption("Medium - Cross-repo, automated changes", "medium"), - huh.NewOption("High - Multi-repo, sensitive data, breaking changes", "high"), - ). - Value(&riskLevel), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("risk level selection failed: %w", err) - } - - config.RiskLevel = riskLevel - interactiveLog.Printf("Risk level: %s", config.RiskLevel) - return nil -} - -func promptForProjectCreation(config *InteractiveCampaignConfig) error { - var createProject bool - form := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Create a GitHub Project board for this campaign?"). - Description("This will set up project views and fields for tracking"). - Value(&createProject), - ), - ) - - if err := form.Run(); err != nil { - return fmt.Errorf("project creation prompt failed: %w", err) - } - - config.CreateProject = createProject - - if createProject { - var projectOwner string - ownerForm := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Project owner"). - Description("GitHub organization or user (use '@me' for personal projects)"). - Placeholder("@me or org-name"). - Value(&projectOwner). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("project owner is required") - } - return nil - }), - ), - ) - - if err := ownerForm.Run(); err != nil { - return fmt.Errorf("project owner input failed: %w", err) - } - - config.ProjectOwner = strings.TrimSpace(projectOwner) - } - - interactiveLog.Printf("Create project: %v, owner: %s", config.CreateProject, config.ProjectOwner) - return nil -} - -func generateCampaignFromConfig(rootDir string, config *InteractiveCampaignConfig, verbose bool) error { - interactiveLog.Print("Generating campaign spec from interactive config") - - workflowsDir := filepath.Join(rootDir, ".github", "workflows") - if err := os.MkdirAll(workflowsDir, 0o755); err != nil { - return fmt.Errorf("failed to create .github/workflows directory: %w", err) - } - - fileName := config.ID + ".campaign.md" - fullPath := filepath.Join(workflowsDir, fileName) - relPath := filepath.ToSlash(filepath.Join(".github", "workflows", fileName)) - - if _, err := os.Stat(fullPath); err == nil && !config.Force { - return fmt.Errorf("campaign spec already exists at %s (use --force to overwrite)", relPath) - } - - // Build the spec - spec := CampaignSpec{ - ID: config.ID, - Name: config.Name, - Description: config.Description, - ProjectURL: "https://github.com/orgs/ORG/projects/1", // Placeholder - Version: "v1", - State: "planned", - Workflows: config.Workflows, - Scope: config.ScopeSelectors, - Owners: config.Owners, - RiskLevel: config.RiskLevel, - MemoryPaths: []string{"memory/campaigns/" + config.ID + "/**"}, - MetricsGlob: "memory/campaigns/" + config.ID + "/metrics/*.json", - CursorGlob: "memory/campaigns/" + config.ID + "/cursor.json", - Governance: &CampaignGovernancePolicy{ - MaxNewItemsPerRun: 25, - MaxDiscoveryItemsPerRun: 200, - MaxDiscoveryPagesPerRun: 10, - OptOutLabels: []string{"no-campaign", "no-bot"}, - DoNotDowngradeDoneItems: boolPtr(true), - MaxProjectUpdatesPerRun: 10, - MaxCommentsPerRun: 10, - }, - } - - data, err := yaml.Marshal(&spec) - if err != nil { - return fmt.Errorf("failed to marshal campaign spec: %w", err) - } - - var buf strings.Builder - buf.WriteString("---\n") - buf.Write(data) - buf.WriteString("---\n\n") - buf.WriteString("# " + config.Name + "\n\n") - buf.WriteString(config.Description + "\n\n") - - buf.WriteString("## Workflows\n\n") - if len(config.Workflows) > 0 { - for _, workflow := range config.Workflows { - buf.WriteString("### " + workflow + "\n") - buf.WriteString("Description of what this workflow does in the context of this campaign.\n\n") - } - } else { - buf.WriteString("Add workflow descriptions here.\n\n") - } - - buf.WriteString("## Timeline\n\n") - buf.WriteString("- **Start**: TBD\n") - buf.WriteString("- **Target**: Ongoing\n\n") - - buf.WriteString("## Governance\n\n") - buf.WriteString("Describe risk mitigation, approval process, and stakeholder communication.\n") - - // Use restrictive permissions (0644) for proper git tracking - if err := os.WriteFile(fullPath, []byte(buf.String()), 0o644); err != nil { - return fmt.Errorf("failed to write campaign spec file '%s': %w", relPath, err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created campaign spec at %s", relPath))) - - // Create project if requested - if config.CreateProject { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Creating GitHub Project...")) - - projectConfig := ProjectCreationConfig{ - CampaignID: config.ID, - CampaignName: config.Name, - Owner: config.ProjectOwner, - LinkRepo: config.LinkRepo, - NoLinkRepo: false, - Verbose: verbose, - } - - result, err := CreateCampaignProject(projectConfig) - if err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created project: %s", result.ProjectURL))) - - // Update the spec file with the project URL - if err := UpdateSpecWithProjectURL(fullPath, result.ProjectURL); err != nil { - return fmt.Errorf("failed to update spec with project URL: %w", err) - } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Updated campaign spec with project URL")) - } - - return nil -} - -func formatCampaignName(id string) string { - name := strings.ReplaceAll(id, "-", " ") - if name != "" { - words := strings.Fields(name) - for i, word := range words { - if len(word) > 0 { - words[i] = strings.ToUpper(word[:1]) + word[1:] - } - } - name = strings.Join(words, " ") - } - return name -} diff --git a/pkg/campaign/interactive_test.go b/pkg/campaign/interactive_test.go deleted file mode 100644 index 738c65ef5ff..00000000000 --- a/pkg/campaign/interactive_test.go +++ /dev/null @@ -1,61 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "os" - "testing" -) - -func TestRunInteractiveCampaignCreation_SkipInAutomation(t *testing.T) { - // Set GO_TEST_MODE to simulate test environment - os.Setenv("GO_TEST_MODE", "true") - defer os.Unsetenv("GO_TEST_MODE") - - tmpDir := t.TempDir() - - err := RunInteractiveCampaignCreation(tmpDir, false, false) - if err == nil { - t.Error("Expected error when running interactive mode in test environment") - } - - expectedMsg := "interactive mode cannot be used in automated tests or CI environments" - if !containsString(err.Error(), expectedMsg) { - t.Errorf("Expected error containing %q, got: %v", expectedMsg, err) - } -} - -func TestFormatCampaignName(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"security-q1-2025", "Security Q1 2025"}, - {"my-test-campaign", "My Test Campaign"}, - {"single", "Single"}, - {"", ""}, - {"already-capitalized", "Already Capitalized"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := formatCampaignName(tt.input) - if result != tt.expected { - t.Errorf("formatCampaignName(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -func containsString(s, substr string) bool { - return len(s) > 0 && len(substr) > 0 && (s == substr || (len(s) >= len(substr) && findSubstring(s, substr))) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/pkg/campaign/loader.go b/pkg/campaign/loader.go deleted file mode 100644 index 11851e34be3..00000000000 --- a/pkg/campaign/loader.go +++ /dev/null @@ -1,282 +0,0 @@ -package campaign - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/cli/go-gh/v2/pkg/repository" - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/parser" - "github.com/goccy/go-yaml" -) - -var log = logger.New("campaign:loader") - -// LoadSpecs scans the repository for campaign spec files and returns -// a slice of CampaignSpec. Campaign specs are stored as .campaign.md files -// in .github/workflows/. If the workflows directory does not exist, it -// returns an empty slice and no error. -func LoadSpecs(rootDir string) ([]CampaignSpec, error) { - log.Printf("Loading campaign specs from rootDir=%s", rootDir) - - workflowsDir := filepath.Join(rootDir, ".github", "workflows") - entries, err := os.ReadDir(workflowsDir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - log.Print("No .github/workflows directory found; returning empty list") - return []CampaignSpec{}, nil - } - return nil, fmt.Errorf("failed to read .github/workflows directory '%s': %w", workflowsDir, err) - } - - var specs []CampaignSpec - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - name := entry.Name() - if !strings.HasSuffix(name, ".campaign.md") { - continue - } - - fullPath := filepath.Join(workflowsDir, name) - log.Printf("Found campaign spec file: %s", fullPath) - - data, err := os.ReadFile(fullPath) - if err != nil { - return nil, fmt.Errorf("failed to read campaign spec '%s': %w", fullPath, err) - } - - // Use parser package's frontmatter extraction helper - result, err := parser.ExtractFrontmatterFromContent(string(data)) - if err != nil { - return nil, fmt.Errorf("failed to parse campaign spec frontmatter '%s': %w", fullPath, err) - } - - if len(result.Frontmatter) == 0 { - return nil, fmt.Errorf("campaign spec '%s' must start with YAML frontmatter delimited by '---'", filepath.ToSlash(filepath.Join(".github", "workflows", name))) - } - - // Marshal frontmatter map to YAML and unmarshal to CampaignSpec - frontmatterYAML, err := yaml.Marshal(result.Frontmatter) - if err != nil { - return nil, fmt.Errorf("failed to marshal frontmatter for '%s': %w", fullPath, err) - } - - var spec CampaignSpec - if err := yaml.Unmarshal(frontmatterYAML, &spec); err != nil { - return nil, fmt.Errorf("failed to parse campaign spec frontmatter '%s': %w", fullPath, err) - } - - if strings.TrimSpace(spec.ID) == "" { - base := strings.TrimSuffix(name, ".campaign.md") - spec.ID = base - } - - if strings.TrimSpace(spec.Name) == "" { - spec.Name = spec.ID - } - - // Default state to "active" if not specified - if strings.TrimSpace(spec.State) == "" { - spec.State = "active" - log.Printf("Defaulted state to 'active' for campaign '%s'", spec.ID) - } - - // Default tracker-label based on campaign ID if not specified - if strings.TrimSpace(spec.TrackerLabel) == "" { - spec.TrackerLabel = fmt.Sprintf("z_campaign_%s", spec.ID) - log.Printf("Defaulted tracker-label to '%s' for campaign '%s'", spec.TrackerLabel, spec.ID) - } - - // Default memory-paths based on campaign ID if not specified - if len(spec.MemoryPaths) == 0 { - spec.MemoryPaths = []string{fmt.Sprintf("memory/campaigns/%s/**", spec.ID)} - log.Printf("Defaulted memory-paths to '%s' for campaign '%s'", spec.MemoryPaths[0], spec.ID) - } - - // Default metrics-glob based on campaign ID if not specified - if strings.TrimSpace(spec.MetricsGlob) == "" { - spec.MetricsGlob = fmt.Sprintf("memory/campaigns/%s/metrics/*.json", spec.ID) - log.Printf("Defaulted metrics-glob to '%s' for campaign '%s'", spec.MetricsGlob, spec.ID) - } - - // Default cursor-glob based on campaign ID if not specified - if strings.TrimSpace(spec.CursorGlob) == "" { - spec.CursorGlob = fmt.Sprintf("memory/campaigns/%s/cursor.json", spec.ID) - log.Printf("Defaulted cursor-glob to '%s' for campaign '%s'", spec.CursorGlob, spec.ID) - } - - // Default scope to current repository if not specified - if len(spec.Scope) == 0 { - currentRepo, err := getCurrentRepository() - if err != nil { - log.Printf("Warning: Could not determine current repository for campaign '%s': %v. Campaign will require explicit scope.", spec.ID, err) - } else { - spec.Scope = []string{currentRepo} - log.Printf("Defaulted scope to current repository for campaign '%s': %s", spec.ID, currentRepo) - } - } - - spec.ConfigPath = filepath.ToSlash(filepath.Join(".github", "workflows", name)) - specs = append(specs, spec) - } - - log.Printf("Loaded %d campaign specs", len(specs)) - return specs, nil -} - -// FilterSpecs filters campaigns by a simple substring match on ID or -// Name (case-insensitive). When pattern is empty, all campaigns are returned. -func FilterSpecs(specs []CampaignSpec, pattern string) []CampaignSpec { - if pattern == "" { - return specs - } - - var filtered []CampaignSpec - lowerPattern := strings.ToLower(pattern) - - for _, spec := range specs { - if strings.Contains(strings.ToLower(spec.ID), lowerPattern) || strings.Contains(strings.ToLower(spec.Name), lowerPattern) { - filtered = append(filtered, spec) - } - } - - return filtered -} - -// CreateSpecSkeleton creates a new campaign spec YAML file under -// .github/workflows/ with a minimal skeleton definition. It returns the -// relative file path created. -func CreateSpecSkeleton(rootDir, id string, force bool) (string, error) { - id = strings.TrimSpace(id) - if id == "" { - return "", fmt.Errorf("campaign id is required") - } - - // Reuse the same simple rules as ValidateSpec for IDs - for _, ch := range id { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { - continue - } - return "", fmt.Errorf("campaign id must use only lowercase letters, digits, and hyphens (%s)", id) - } - - workflowsDir := filepath.Join(rootDir, ".github", "workflows") - if err := os.MkdirAll(workflowsDir, 0o755); err != nil { - return "", fmt.Errorf("failed to create .github/workflows directory: %w", err) - } - - fileName := id + ".campaign.md" - fullPath := filepath.Join(workflowsDir, fileName) - relPath := filepath.ToSlash(filepath.Join(".github", "workflows", fileName)) - - if _, err := os.Stat(fullPath); err == nil && !force { - return "", fmt.Errorf("campaign spec already exists at %s (use --force to overwrite)", relPath) - } - - name := strings.ReplaceAll(id, "-", " ") - if name != "" { - first := strings.ToUpper(name[:1]) - if len(name) > 1 { - name = first + name[1:] - } else { - name = first - } - } - - spec := CampaignSpec{ - ID: id, - Name: name, - ProjectURL: "https://github.com/orgs/ORG/projects/1", - Version: "v1", - State: "planned", - MemoryPaths: []string{"memory/campaigns/" + id + "/**"}, - MetricsGlob: "memory/campaigns/" + id + "/metrics/*.json", - CursorGlob: "memory/campaigns/" + id + "/cursor.json", - Governance: &CampaignGovernancePolicy{ - MaxNewItemsPerRun: 25, - MaxDiscoveryItemsPerRun: 200, - MaxDiscoveryPagesPerRun: 10, - OptOutLabels: []string{"no-campaign", "no-bot"}, - DoNotDowngradeDoneItems: boolPtr(true), - MaxProjectUpdatesPerRun: 10, - MaxCommentsPerRun: 10, - }, - } - - data, err := yaml.Marshal(&spec) - if err != nil { - return "", fmt.Errorf("failed to marshal campaign spec: %w", err) - } - - var buf strings.Builder - buf.WriteString("---\n") - buf.Write(data) - buf.WriteString("---\n\n") - if name != "" { - buf.WriteString("# " + name + "\n\n") - } else { - buf.WriteString("# " + id + "\n\n") - } - buf.WriteString("Describe this campaign's goals, guardrails, stakeholders, and playbook.\n\n") - buf.WriteString("## Quick Start\n\n") - buf.WriteString("By default, this campaign will target the current repository. To target additional repositories:\n\n") - buf.WriteString("1. **Add scope** (optional): Specify repositories and/or organizations to target\n") - buf.WriteString("2. **Define workflows**: List workflows to execute (e.g., `vulnerability-scanner`)\n") - buf.WriteString("3. **Add narrative context**: Define campaign goals, workflows, and timeline in the markdown body\n") - buf.WriteString("4. **Set owners**: Specify who is responsible for this campaign\n") - buf.WriteString("5. **Compile**: Run `gh aw compile` to generate the orchestrator\n\n") - buf.WriteString("## Example Configuration\n\n") - buf.WriteString("```yaml\n") - buf.WriteString("# Add to the frontmatter above:\n") - buf.WriteString("\n") - buf.WriteString("# Optional: Target specific repositories and/or organizations (defaults to current repo)\n") - buf.WriteString("# scope:\n") - buf.WriteString("# - myorg/backend\n") - buf.WriteString("# - myorg/frontend\n") - buf.WriteString("# - org:myorg\n") - buf.WriteString("\n") - buf.WriteString("# Campaigns with workflows MUST be scoped via scope\n") - buf.WriteString("\n") - buf.WriteString("workflows:\n") - buf.WriteString(" - vulnerability-scanner\n") - buf.WriteString(" - dependency-updater\n") - buf.WriteString("owners:\n") - buf.WriteString(" - @security-team\n") - buf.WriteString("```\n") - - // Use restrictive permissions (0644) for proper git tracking - if err := os.WriteFile(fullPath, []byte(buf.String()), 0o644); err != nil { - return "", fmt.Errorf("failed to write campaign spec file '%s': %w", relPath, err) - } - - return relPath, nil -} - -func boolPtr(v bool) *bool { - return &v -} - -// getCurrentRepository gets the current repository from git context -// This function mirrors the logic from pkg/workflow/repository_features_validation.go -func getCurrentRepository() (string, error) { - // Use native repository.Current() to get the current repository - // This works when in a git repository with GitHub remote and respects GH_REPO - repo, err := repository.Current() - if err != nil { - return "", fmt.Errorf("failed to get current repository: %w", err) - } - - // Validate that owner and name are not empty - if repo.Owner == "" || repo.Name == "" { - return "", fmt.Errorf("repository owner or name is empty (owner: %q, name: %q)", repo.Owner, repo.Name) - } - - return fmt.Sprintf("%s/%s", repo.Owner, repo.Name), nil -} diff --git a/pkg/campaign/loader_test.go b/pkg/campaign/loader_test.go deleted file mode 100644 index 8974ae673f3..00000000000 --- a/pkg/campaign/loader_test.go +++ /dev/null @@ -1,242 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadSpecs_EmptyDirectory(t *testing.T) { - // Create temporary directory - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 0 { - t.Errorf("Expected 0 specs in empty directory, got %d", len(specs)) - } -} - -func TestLoadSpecs_NonExistentDirectory(t *testing.T) { - tmpDir := t.TempDir() - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs should not fail for non-existent .github/workflows directory: %v", err) - } - - if len(specs) != 0 { - t.Errorf("Expected 0 specs when .github/workflows directory doesn't exist, got %d", len(specs)) - } -} - -func TestLoadSpecs_InvalidFrontmatter(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - // Create file with invalid frontmatter - invalidFile := filepath.Join(campaignsDir, "invalid.campaign.md") - content := `--- -id: test -name: [invalid yaml here ---- -Test content` - if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - _, err := LoadSpecs(tmpDir) - if err == nil { - t.Fatalf("Expected error for invalid frontmatter, got nil") - } - - if !strings.Contains(err.Error(), "failed to parse") { - t.Errorf("Expected parse error, got: %v", err) - } -} - -func TestLoadSpecs_MissingFrontmatter(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - // Create file without frontmatter - noFrontmatterFile := filepath.Join(campaignsDir, "no-frontmatter.campaign.md") - content := `# Test Campaign - -This file has no frontmatter.` - if err := os.WriteFile(noFrontmatterFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - _, err := LoadSpecs(tmpDir) - if err == nil { - t.Fatalf("Expected error for missing frontmatter, got nil") - } - - if !strings.Contains(err.Error(), "must start with YAML frontmatter") { - t.Errorf("Expected frontmatter error, got: %v", err) - } -} - -func TestLoadSpecs_IDDefaults(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - // Create file without ID in frontmatter - testFile := filepath.Join(campaignsDir, "test-campaign.campaign.md") - content := `--- -name: Test Campaign -version: v1 ---- -Test content` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 1 { - t.Fatalf("Expected 1 spec, got %d", len(specs)) - } - - if specs[0].ID != "test-campaign" { - t.Errorf("Expected ID 'test-campaign' (derived from filename), got '%s'", specs[0].ID) - } -} - -func TestLoadSpecs_NameDefaults(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - // Create file without name in frontmatter - testFile := filepath.Join(campaignsDir, "test-id.campaign.md") - content := `--- -id: test-id -version: v1 ---- -Test content` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 1 { - t.Fatalf("Expected 1 spec, got %d", len(specs)) - } - - if specs[0].Name != "test-id" { - t.Errorf("Expected Name 'test-id' (derived from ID), got '%s'", specs[0].Name) - } -} - -func TestLoadSpecs_ConfigPath(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - testFile := filepath.Join(campaignsDir, "test.campaign.md") - content := `--- -id: test -name: Test ---- -Test content` - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 1 { - t.Fatalf("Expected 1 spec, got %d", len(specs)) - } - - expectedPath := ".github/workflows/test.campaign.md" - if specs[0].ConfigPath != expectedPath { - t.Errorf("Expected ConfigPath '%s', got '%s'", expectedPath, specs[0].ConfigPath) - } -} - -func TestLoadSpecs_MultipleSpecs(t *testing.T) { - tmpDir := t.TempDir() - campaignsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(campaignsDir, 0755); err != nil { - t.Fatalf("Failed to create .github/workflows directory: %v", err) - } - - // Create multiple campaign files - campaigns := []struct { - filename string - id string - }{ - {"campaign1.campaign.md", "campaign1"}, - {"campaign2.campaign.md", "campaign2"}, - {"campaign3.campaign.md", "campaign3"}, - } - - for _, c := range campaigns { - content := `--- -id: ` + c.id + ` -name: ` + c.id + ` ---- -Content` - testFile := filepath.Join(campaignsDir, c.filename) - if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file %s: %v", c.filename, err) - } - } - - specs, err := LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) != 3 { - t.Fatalf("Expected 3 specs, got %d", len(specs)) - } - - // Verify all IDs are present - foundIDs := make(map[string]bool) - for _, spec := range specs { - foundIDs[spec.ID] = true - } - - for _, c := range campaigns { - if !foundIDs[c.id] { - t.Errorf("Expected to find campaign with ID '%s'", c.id) - } - } -} diff --git a/pkg/campaign/metrics_snapshot_test.go b/pkg/campaign/metrics_snapshot_test.go deleted file mode 100644 index c0ffcbec94d..00000000000 --- a/pkg/campaign/metrics_snapshot_test.go +++ /dev/null @@ -1,224 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestCampaignMetricsSnapshot_JSONMarshaling verifies that required fields -// are always present in the JSON output, even when they have zero values. -// This ensures compatibility with push_repo_memory.cjs validation which -// requires tasks_total and tasks_completed to be present (not undefined). -func TestCampaignMetricsSnapshot_JSONMarshaling(t *testing.T) { - tests := []struct { - name string - snapshot CampaignMetricsSnapshot - expectedFields []string - omittedFields []string - }{ - { - name: "all fields populated", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "test-campaign", - TasksTotal: 100, - TasksCompleted: 50, - TasksInProgress: 30, - TasksBlocked: 5, - VelocityPerDay: 7.5, - EstimatedCompletion: "2025-02-15", - }, - expectedFields: []string{ - "date", "campaign_id", "tasks_total", "tasks_completed", - "tasks_in_progress", "tasks_blocked", "velocity_per_day", "estimated_completion", - }, - omittedFields: []string{}, - }, - { - name: "zero values for required fields (must be present)", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "test-campaign", - TasksTotal: 0, // Zero but must be present - TasksCompleted: 0, // Zero but must be present - }, - expectedFields: []string{ - "date", "campaign_id", "tasks_total", "tasks_completed", - }, - omittedFields: []string{ - "tasks_in_progress", "tasks_blocked", "velocity_per_day", "estimated_completion", - }, - }, - { - name: "only required fields", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "test-campaign", - TasksTotal: 10, - TasksCompleted: 5, - }, - expectedFields: []string{ - "date", "campaign_id", "tasks_total", "tasks_completed", - }, - omittedFields: []string{ - "tasks_in_progress", "tasks_blocked", "velocity_per_day", "estimated_completion", - }, - }, - { - name: "zero velocity should be omitted", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "test-campaign", - TasksTotal: 10, - TasksCompleted: 5, - VelocityPerDay: 0.0, // Zero optional field should be omitted - }, - expectedFields: []string{ - "date", "campaign_id", "tasks_total", "tasks_completed", - }, - omittedFields: []string{ - "tasks_in_progress", "tasks_blocked", "velocity_per_day", "estimated_completion", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Marshal to JSON - jsonBytes, err := json.Marshal(tt.snapshot) - require.NoError(t, err, "Failed to marshal snapshot") - - // Unmarshal to map to check presence of fields - var result map[string]interface{} - err = json.Unmarshal(jsonBytes, &result) - require.NoError(t, err, "Failed to unmarshal JSON") - - // Verify expected fields are present - for _, field := range tt.expectedFields { - assert.Contains(t, result, field, "Expected field %q to be present in JSON", field) - } - - // Verify omitted fields are not present - for _, field := range tt.omittedFields { - assert.NotContains(t, result, field, "Expected field %q to be omitted from JSON", field) - } - - // Specifically verify required integer fields are present even when zero - if tt.snapshot.TasksTotal == 0 { - assert.Contains(t, result, "tasks_total", "tasks_total must be present even when zero") - } - if tt.snapshot.TasksCompleted == 0 { - assert.Contains(t, result, "tasks_completed", "tasks_completed must be present even when zero") - } - }) - } -} - -// TestCampaignMetricsSnapshot_PushRepoMemoryValidation tests that the JSON -// output is compatible with push_repo_memory.cjs validation rules. -func TestCampaignMetricsSnapshot_PushRepoMemoryValidation(t *testing.T) { - // This test simulates what push_repo_memory.cjs expects: - // - campaign_id: required, non-empty string - // - date: required, non-empty string in YYYY-MM-DD format - // - tasks_total: required, must be present (not undefined), non-negative integer - // - tasks_completed: required, must be present (not undefined), non-negative integer - - snapshot := CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "file-size-reduction-project71", - TasksTotal: 0, - TasksCompleted: 0, - } - - jsonBytes, err := json.Marshal(snapshot) - require.NoError(t, err, "Failed to marshal snapshot") - - // Unmarshal to verify structure - var result map[string]interface{} - err = json.Unmarshal(jsonBytes, &result) - require.NoError(t, err, "Failed to unmarshal JSON") - - // Validate required fields (matching push_repo_memory.cjs validation) - assert.Contains(t, result, "campaign_id", "campaign_id must be present") - assert.IsType(t, "", result["campaign_id"], "campaign_id must be a string") - assert.NotEmpty(t, result["campaign_id"], "campaign_id must not be empty") - - assert.Contains(t, result, "date", "date must be present") - assert.IsType(t, "", result["date"], "date must be a string") - assert.NotEmpty(t, result["date"], "date must not be empty") - - assert.Contains(t, result, "tasks_total", "tasks_total must be present (not undefined)") - assert.IsType(t, float64(0), result["tasks_total"], "tasks_total must be a number") - assert.GreaterOrEqual(t, result["tasks_total"].(float64), 0.0, "tasks_total must be non-negative") - - assert.Contains(t, result, "tasks_completed", "tasks_completed must be present (not undefined)") - assert.IsType(t, float64(0), result["tasks_completed"], "tasks_completed must be a number") - assert.GreaterOrEqual(t, result["tasks_completed"].(float64), 0.0, "tasks_completed must be non-negative") -} - -// TestCampaignMetricsSnapshot_RealWorldScenarios tests common real-world cases -// that might trigger the push_repo_memory validation error. -func TestCampaignMetricsSnapshot_RealWorldScenarios(t *testing.T) { - tests := []struct { - name string - snapshot CampaignMetricsSnapshot - description string - }{ - { - name: "new campaign with no tasks yet", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "file-size-reduction-project71", - TasksTotal: 0, - TasksCompleted: 0, - }, - description: "First run of orchestrator before any tasks discovered", - }, - { - name: "campaign just started", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "file-size-reduction-project71", - TasksTotal: 10, - TasksCompleted: 0, - }, - description: "Tasks discovered but none completed yet", - }, - { - name: "campaign in progress", - snapshot: CampaignMetricsSnapshot{ - Date: "2025-01-05", - CampaignID: "file-size-reduction-project71", - TasksTotal: 100, - TasksCompleted: 25, - TasksInProgress: 15, - TasksBlocked: 2, - VelocityPerDay: 3.5, - EstimatedCompletion: "2025-02-15", - }, - description: "Active campaign with full metrics", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - jsonBytes, err := json.Marshal(tt.snapshot) - require.NoError(t, err, "Failed to marshal snapshot: %s", tt.description) - - var result map[string]interface{} - err = json.Unmarshal(jsonBytes, &result) - require.NoError(t, err, "Failed to unmarshal JSON: %s", tt.description) - - // Verify all required fields are present - assert.Contains(t, result, "campaign_id") - assert.Contains(t, result, "date") - assert.Contains(t, result, "tasks_total") - assert.Contains(t, result, "tasks_completed") - }) - } -} diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go deleted file mode 100644 index cf979e10554..00000000000 --- a/pkg/campaign/orchestrator.go +++ /dev/null @@ -1,376 +0,0 @@ -package campaign - -import ( - "fmt" - "strings" - - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/workflow" - "github.com/goccy/go-yaml" -) - -var orchestratorLog = logger.New("campaign:orchestrator") - -// convertStringsToAny converts a slice of strings to a slice of any -func convertStringsToAny(strings []string) []any { - result := make([]any, len(strings)) - for i, s := range strings { - result[i] = s - } - return result -} - -// extractFileGlobPatterns extracts all file glob patterns from memory-paths or -// metrics-glob configuration. These patterns are used for the file-glob filter in -// repo-memory configuration to match files that the agent creates. -// -// For campaigns that use dated directory patterns (e.g., campaign-id-*/), this -// function preserves all wildcard patterns from memory-paths to support multiple -// directory structures (both dated and non-dated). -// -// Examples: -// - memory-paths: ["memory/campaigns/project64-*/**", "memory/campaigns/project64/**"] -// -> ["project64-*/**", "project64/**"] -// - memory-paths: ["memory/campaigns/project64-*/**"] -> ["project64-*/**"] -// - metrics-glob: "memory/campaigns/project64-*/metrics/*.json" -> ["project64-*/**"] -// - no patterns with wildcards -> ["project64/**"] (fallback to ID) -func extractFileGlobPatterns(spec *CampaignSpec) []string { - var patterns []string - - // Extract all patterns from memory-paths - for _, memPath := range spec.MemoryPaths { - // Remove "memory/campaigns/" prefix if present - pattern := strings.TrimPrefix(memPath, "memory/campaigns/") - // If pattern has both wildcards and slashes, it's a valid pattern - if strings.Contains(pattern, "*") && strings.Contains(pattern, "/") { - patterns = append(patterns, pattern) - orchestratorLog.Printf("Extracted file-glob pattern from memory-paths: %s", pattern) - } - } - - // If we found patterns from memory-paths, return them - if len(patterns) > 0 { - return patterns - } - - // Try to extract pattern from metrics-glob as fallback - if spec.MetricsGlob != "" { - pattern := strings.TrimPrefix(spec.MetricsGlob, "memory/campaigns/") - if strings.Contains(pattern, "*") { - // Extract the base directory pattern (everything before /metrics/ or first file-specific part) - if idx := strings.Index(pattern, "/metrics/"); idx > 0 { - basePattern := pattern[:idx] + "/**" - orchestratorLog.Printf("Extracted file-glob pattern from metrics-glob: %s", basePattern) - return []string{basePattern} - } - } - } - - // Fallback to simple ID-based pattern - fallbackPattern := fmt.Sprintf("%s/**", spec.ID) - orchestratorLog.Printf("Using fallback file-glob pattern: %s", fallbackPattern) - return []string{fallbackPattern} -} - -// BuildOrchestrator constructs a minimal agentic workflow representation for a -// given CampaignSpec. The resulting WorkflowData is compiled via the standard -// CompileWorkflowDataWithValidation pipeline, and the orchestratorPath -// determines the emitted .lock.yml name. -func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.WorkflowData, string) { - orchestratorLog.Printf("Building orchestrator for campaign: id=%s, file=%s", spec.ID, campaignFilePath) - - // Derive orchestrator markdown path alongside the campaign spec, using a - // distinct suffix to avoid colliding with existing workflows. We use - // a `.campaign.g.md` suffix to make it clear that the file is generated - // from the corresponding `.campaign.md` spec. - base := strings.TrimSuffix(campaignFilePath, ".campaign.md") - orchestratorPath := base + ".campaign.g.md" - orchestratorLog.Printf("Generated orchestrator path: %s", orchestratorPath) - - name := spec.Name - if strings.TrimSpace(name) == "" { - name = fmt.Sprintf("Campaign: %s", spec.ID) - } - - description := spec.Description - if strings.TrimSpace(description) == "" { - description = fmt.Sprintf("Orchestrator workflow for campaign '%s'", spec.ID) - } - - // Default triggers: hourly schedule plus manual workflow_dispatch. - onSection := "on:\n schedule:\n - cron: \"0 * * * *\"\n workflow_dispatch:\n" - - // Prevent overlapping runs. This reduces sustained automated traffic on GitHub's - // infrastructure by ensuring only one orchestrator run executes at a time per ref. - concurrency := fmt.Sprintf("concurrency:\n group: \"campaign-%s-orchestrator-${{ github.ref }}\"\n cancel-in-progress: false", spec.ID) - - // Simple markdown body giving the agent context about the campaign. - markdownBuilder := &strings.Builder{} - markdownBuilder.WriteString("# Campaign Orchestrator\n\n") - fmt.Fprintf(markdownBuilder, "This workflow orchestrates the '%s' campaign.\n\n", name) - - // Track whether we have any meaningful campaign details - hasDetails := false - - if len(spec.Workflows) > 0 { - markdownBuilder.WriteString("- Associated workflows: ") - markdownBuilder.WriteString(strings.Join(spec.Workflows, ", ")) - markdownBuilder.WriteString("\n") - hasDetails = true - } - if len(spec.MemoryPaths) > 0 { - markdownBuilder.WriteString("- Memory paths: ") - markdownBuilder.WriteString(strings.Join(spec.MemoryPaths, ", ")) - markdownBuilder.WriteString("\n") - hasDetails = true - } - if spec.MetricsGlob != "" { - fmt.Fprintf(markdownBuilder, "- Metrics glob: `%s`\n", spec.MetricsGlob) - hasDetails = true - } - if spec.CursorGlob != "" { - fmt.Fprintf(markdownBuilder, "- Cursor glob: `%s`\n", spec.CursorGlob) - hasDetails = true - } - if strings.TrimSpace(spec.ProjectURL) != "" { - fmt.Fprintf(markdownBuilder, "- Project URL: %s\n", strings.TrimSpace(spec.ProjectURL)) - hasDetails = true - } - if spec.Governance != nil { - if spec.Governance.MaxNewItemsPerRun > 0 { - fmt.Fprintf(markdownBuilder, "- Governance: max new items per run: %d\n", spec.Governance.MaxNewItemsPerRun) - hasDetails = true - } - if spec.Governance.MaxDiscoveryItemsPerRun > 0 { - fmt.Fprintf(markdownBuilder, "- Governance: max discovery items per run: %d\n", spec.Governance.MaxDiscoveryItemsPerRun) - hasDetails = true - } - if spec.Governance.MaxDiscoveryPagesPerRun > 0 { - fmt.Fprintf(markdownBuilder, "- Governance: max discovery pages per run: %d\n", spec.Governance.MaxDiscoveryPagesPerRun) - hasDetails = true - } - if len(spec.Governance.OptOutLabels) > 0 { - markdownBuilder.WriteString("- Governance: opt-out labels: ") - markdownBuilder.WriteString(strings.Join(spec.Governance.OptOutLabels, ", ")) - markdownBuilder.WriteString("\n") - hasDetails = true - } - if spec.Governance.DoNotDowngradeDoneItems != nil { - fmt.Fprintf(markdownBuilder, "- Governance: do not downgrade done items: %t\n", *spec.Governance.DoNotDowngradeDoneItems) - hasDetails = true - } - if spec.Governance.MaxProjectUpdatesPerRun > 0 { - fmt.Fprintf(markdownBuilder, "- Governance: max project updates per run: %d\n", spec.Governance.MaxProjectUpdatesPerRun) - hasDetails = true - } - if spec.Governance.MaxCommentsPerRun > 0 { - fmt.Fprintf(markdownBuilder, "- Governance: max comments per run: %d\n", spec.Governance.MaxCommentsPerRun) - hasDetails = true - } - } - - // Return nil if the campaign spec has no meaningful details for the prompt - if !hasDetails { - orchestratorLog.Printf("Campaign '%s' has no meaningful details, skipping orchestrator build", spec.ID) - return nil, "" - } - - orchestratorLog.Printf("Campaign '%s' orchestrator includes: workflows=%d, memory_paths=%d", - spec.ID, len(spec.Workflows), len(spec.MemoryPaths)) - - // Render orchestrator instructions using templates - // All orchestrators follow the same system-agnostic rules with no conditional logic - promptData := CampaignPromptData{ - CampaignID: spec.ID, - CampaignName: spec.Name, - ProjectURL: strings.TrimSpace(spec.ProjectURL), - CursorGlob: strings.TrimSpace(spec.CursorGlob), - MetricsGlob: strings.TrimSpace(spec.MetricsGlob), - Workflows: spec.Workflows, - } - if spec.Governance != nil { - promptData.MaxDiscoveryItemsPerRun = spec.Governance.MaxDiscoveryItemsPerRun - promptData.MaxDiscoveryPagesPerRun = spec.Governance.MaxDiscoveryPagesPerRun - promptData.MaxProjectUpdatesPerRun = spec.Governance.MaxProjectUpdatesPerRun - promptData.MaxProjectCommentsPerRun = spec.Governance.MaxCommentsPerRun - } - - // Add bootstrap configuration if present - if spec.Bootstrap != nil { - promptData.BootstrapMode = spec.Bootstrap.Mode - if spec.Bootstrap.SeederWorker != nil { - promptData.SeederWorkerID = spec.Bootstrap.SeederWorker.WorkflowID - // Convert payload map to JSON string for template rendering - if len(spec.Bootstrap.SeederWorker.Payload) > 0 { - payloadBytes, err := yaml.Marshal(spec.Bootstrap.SeederWorker.Payload) - if err == nil { - promptData.SeederPayload = string(payloadBytes) - } - } - promptData.SeederMaxItems = spec.Bootstrap.SeederWorker.MaxItems - } - if spec.Bootstrap.ProjectTodos != nil { - promptData.StatusField = spec.Bootstrap.ProjectTodos.StatusField - if promptData.StatusField == "" { - promptData.StatusField = "Status" - } - promptData.TodoValue = spec.Bootstrap.ProjectTodos.TodoValue - if promptData.TodoValue == "" { - promptData.TodoValue = "Todo" - } - promptData.TodoMaxItems = spec.Bootstrap.ProjectTodos.MaxItems - promptData.RequireFields = spec.Bootstrap.ProjectTodos.RequireFields - } - } - - // Add worker metadata if present - if len(spec.Workers) > 0 { - promptData.WorkerMetadata = spec.Workers - } - - // Render bootstrap instructions if bootstrap is configured - if spec.Bootstrap != nil && spec.Bootstrap.Mode != "" { - bootstrapInstructions := RenderBootstrapInstructions(promptData) - if bootstrapInstructions == "" { - orchestratorLog.Print("Warning: Failed to render bootstrap instructions, template may be missing") - } else { - AppendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions) - orchestratorLog.Printf("Campaign '%s' orchestrator includes bootstrap mode: %s", spec.ID, spec.Bootstrap.Mode) - } - } - - // All campaigns include workflow execution capabilities - // The orchestrator can dispatch workflows and make decisions regardless of initial configuration - workflowExecution := RenderWorkflowExecution(promptData) - if workflowExecution == "" { - orchestratorLog.Print("Warning: Failed to render workflow execution instructions, template may be missing") - } else { - AppendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution) - orchestratorLog.Printf("Campaign '%s' orchestrator includes workflow execution", spec.ID) - } - - orchestratorInstructions := RenderOrchestratorInstructions(promptData) - if orchestratorInstructions == "" { - orchestratorLog.Print("Warning: Failed to render orchestrator instructions, template may be missing") - } else { - AppendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions) - } - - projectInstructions := RenderProjectUpdateInstructions(promptData) - if projectInstructions == "" { - orchestratorLog.Print("Warning: Failed to render project update instructions, template may be missing") - } else { - AppendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions) - } - - closingInstructions := RenderClosingInstructions() - if closingInstructions == "" { - orchestratorLog.Print("Warning: Failed to render closing instructions, template may be missing") - } else { - AppendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions) - } - - // Campaign orchestrators can dispatch workflows and perform limited Project operations. - // Project writes (update-project, create-project-status-update) are allowed to enable - // orchestrators to maintain campaign dashboards and status updates. - // - // Note: Campaign orchestrators intentionally omit explicit `permissions:` from - // the generated markdown; safe-output jobs have their own scoped permissions. - safeOutputs := &workflow.SafeOutputsConfig{} - - // Configure dispatch-workflow for worker coordination - if len(spec.Workflows) > 0 { - dispatchWorkflowConfig := &workflow.DispatchWorkflowConfig{ - BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3}, - Workflows: spec.Workflows, - } - safeOutputs.DispatchWorkflow = dispatchWorkflowConfig - orchestratorLog.Printf("Campaign orchestrator '%s' configured with dispatch_workflow for %d workflows", spec.ID, len(spec.Workflows)) - } - - // Configure update-project for campaign dashboard maintenance - maxProjectUpdates := 100 // default - increased from 10 to handle larger discovery sets - if spec.Governance != nil && spec.Governance.MaxProjectUpdatesPerRun > 0 { - maxProjectUpdates = spec.Governance.MaxProjectUpdatesPerRun - } - updateProjectConfig := &workflow.UpdateProjectConfig{ - BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxProjectUpdates}, - } - safeOutputs.UpdateProjects = updateProjectConfig - orchestratorLog.Printf("Campaign orchestrator '%s' configured with update-project (max: %d)", spec.ID, maxProjectUpdates) - - // Configure create-project-status-update for campaign summaries - statusUpdateConfig := &workflow.CreateProjectStatusUpdateConfig{ - BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1}, - } - safeOutputs.CreateProjectStatusUpdates = statusUpdateConfig - orchestratorLog.Printf("Campaign orchestrator '%s' configured with create-project-status-update", spec.ID) - - orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow, update-project, and create-project-status-update safe outputs", spec.ID) - - // Extract file-glob patterns from memory-paths or metrics-glob to support - // multiple directory structures (e.g., both dated "campaign-id-*/**" and non-dated "campaign-id/**") - fileGlobPatterns := extractFileGlobPatterns(spec) - - // Determine engine to use (default to claude if not specified) - engineID := "claude" - if spec.Engine != "" { - engineID = spec.Engine - orchestratorLog.Printf("Campaign orchestrator '%s' using specified engine: %s", spec.ID, engineID) - } else { - orchestratorLog.Printf("Campaign orchestrator '%s' using default engine: %s", spec.ID, engineID) - } - - // Configure GitHub MCP for discovery with budget enforcement - maxDiscoveryItems := 100 - maxDiscoveryPages := 10 - if spec.Governance != nil { - if spec.Governance.MaxDiscoveryItemsPerRun > 0 { - maxDiscoveryItems = spec.Governance.MaxDiscoveryItemsPerRun - } - if spec.Governance.MaxDiscoveryPagesPerRun > 0 { - maxDiscoveryPages = spec.Governance.MaxDiscoveryPagesPerRun - } - } - - tools := map[string]any{ - "github": map[string]any{ - "toolsets": []string{"repos", "issues", "pull_requests"}, - "mode": "remote", - }, - "repo-memory": []any{ - map[string]any{ - "id": "campaigns", - "branch-name": "memory/campaigns", - "file-glob": convertStringsToAny(fileGlobPatterns), - "campaign-id": spec.ID, - }, - }, - "bash": []any{"*"}, - "edit": nil, - } - orchestratorLog.Printf("Campaign orchestrator '%s' configured with GitHub MCP (max items: %d, max pages: %d)", - spec.ID, maxDiscoveryItems, maxDiscoveryPages) - - data := &workflow.WorkflowData{ - Name: name, - Description: description, - MarkdownContent: markdownBuilder.String(), - On: onSection, - Concurrency: concurrency, - // Use a standard Ubuntu runner for the main agent job so the - // compiled orchestrator always has a valid runs-on value. - RunsOn: "runs-on: ubuntu-latest", - // Default roles match the workflow compiler's defaults so that - // membership checks have a non-empty GH_AW_REQUIRED_ROLES value. - Roles: []string{"admin", "maintainer", "write"}, - // Set the engine configuration from campaign spec - EngineConfig: &workflow.EngineConfig{ - ID: engineID, - }, - Tools: tools, - SafeOutputs: safeOutputs, - } - - return data, orchestratorPath -} diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go deleted file mode 100644 index 1ba75cc99c1..00000000000 --- a/pkg/campaign/orchestrator_test.go +++ /dev/null @@ -1,452 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "strings" - "testing" -) - -func TestBuildOrchestrator_BasicShape(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "go-file-size-reduction-project64", - Name: "Campaign: Go File Size Reduction (Project 64)", - Description: "Reduce oversized non-test Go files under pkg/ to ≤800 LOC via tracked refactors.", - ProjectURL: "https://github.com/orgs/githubnext/projects/64", - Workflows: []string{"daily-file-diet"}, - MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64/**"}, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64/metrics/*.json", - } - - mdPath := ".github/workflows/go-file-size-reduction-project64.campaign.md" - data, orchestratorPath := BuildOrchestrator(spec, mdPath) - - if orchestratorPath != ".github/workflows/go-file-size-reduction-project64.campaign.g.md" { - t.Fatalf("unexpected orchestrator path: got %q", orchestratorPath) - } - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - if data.Name != spec.Name { - t.Fatalf("unexpected workflow name: got %q, want %q", data.Name, spec.Name) - } - - if strings.TrimSpace(data.On) == "" || !strings.Contains(data.On, "workflow_dispatch") { - t.Fatalf("expected On section with workflow_dispatch trigger, got %q", data.On) - } - - if !strings.Contains(data.On, "schedule:") || !strings.Contains(data.On, "0 * * * *") { - t.Fatalf("expected On section with hourly schedule cron, got %q", data.On) - } - - if strings.TrimSpace(data.Concurrency) == "" || !strings.Contains(data.Concurrency, "concurrency:") { - t.Fatalf("expected workflow-level concurrency to be set, got %q", data.Concurrency) - } - if !strings.Contains(data.Concurrency, "campaign-go-file-size-reduction-project64-orchestrator") { - t.Fatalf("expected concurrency group to include campaign id, got %q", data.Concurrency) - } - - if !strings.Contains(data.MarkdownContent, "Go File Size Reduction") { - t.Fatalf("expected markdown content to mention campaign name, got: %q", data.MarkdownContent) - } - - // Campaign orchestrators intentionally omit permissions from the generated markdown. - // Job permissions are computed during compilation. - if strings.TrimSpace(data.Permissions) != "" { - t.Fatalf("expected no permissions in generated orchestrator data, got: %q", data.Permissions) - } - }) -} - -func TestBuildOrchestrator_CompletionInstructions(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - Description: "A test campaign", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"test-workflow"}, - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Governed invariant: completion is reported explicitly in Step 5. - expectedPhrases := []string{ - "### Step 5 — Report", - "**Completion:**", - } - for _, expected := range expectedPhrases { - if !strings.Contains(data.MarkdownContent, expected) { - t.Errorf("expected markdown to contain %q, got: %q", expected, data.MarkdownContent) - } - } - }) -} - -func TestBuildOrchestrator_WorkflowsInDiscovery(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - Description: "A test campaign", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{ - "daily-doc-updater", - "docs-noob-tester", - "daily-multi-device-docs-tester", - }, - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Verify each workflow is mentioned in the header list - for _, workflow := range spec.Workflows { - if !strings.Contains(data.MarkdownContent, workflow) { - t.Errorf("expected markdown to mention workflow %q, got: %q", workflow, data.MarkdownContent) - } - } - - // Verify that discovery is now precomputed (not agent-side) - if !strings.Contains(data.MarkdownContent, "Discovery has been precomputed") { - t.Errorf("expected markdown to indicate precomputed discovery, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "./.gh-aw/campaign.discovery.json") { - t.Errorf("expected markdown to reference discovery manifest, got: %q", data.MarkdownContent) - } - - // Verify that discovered results reference normalized items from manifest - if !strings.Contains(data.MarkdownContent, "Parse discovered items from the manifest") { - t.Errorf("expected markdown to mention parsing items from manifest, got: %q", data.MarkdownContent) - } - }) -} - -func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "dispatch-only-campaign", - Name: "Dispatch Only Campaign", - Description: "Campaign orchestrator with dispatch and project capabilities", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"worker-a", "worker-b"}, - MemoryPaths: []string{"memory/campaigns/dispatch-only-campaign/**"}, - } - - mdPath := ".github/workflows/dispatch-only-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - if data.SafeOutputs == nil { - t.Fatalf("expected SafeOutputs to be set") - } - if data.SafeOutputs.DispatchWorkflow == nil { - t.Fatalf("expected dispatch-workflow safe output to be enabled") - } - if len(data.SafeOutputs.DispatchWorkflow.Workflows) != 2 { - t.Fatalf("expected 2 allowlisted workflows, got %d", len(data.SafeOutputs.DispatchWorkflow.Workflows)) - } - - // Orchestrators should have update-project and create-project-status-update for dashboard maintenance - if data.SafeOutputs.UpdateProjects == nil { - t.Fatalf("expected update-project safe output to be enabled") - } - if data.SafeOutputs.CreateProjectStatusUpdates == nil { - t.Fatalf("expected create-project-status-update safe output to be enabled") - } - - // Orchestrators should NOT have create-issue or add-comment (workers handle those) - if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil { - t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs") - } - - // Orchestrators should have GitHub tool access for discovery operations - if data.Tools == nil { - t.Fatalf("expected Tools to be configured") - } - if _, ok := data.Tools["github"]; !ok { - t.Fatalf("expected orchestrator to have github tools configured") - } - }) -} - -func TestBuildOrchestrator_TrackerIDMonitoring(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - Description: "A test campaign", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"daily-file-diet"}, - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Verify that the orchestrator uses manifest-based discovery (not agent-side search) - if !strings.Contains(data.MarkdownContent, "Correlation is explicit (tracker-id AND labels)") { - t.Errorf("expected markdown to mention tracker-id and labels correlation rule, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "Read the precomputed discovery manifest") { - t.Errorf("expected markdown to include manifest-based discovery instructions, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "./.gh-aw/campaign.discovery.json") { - t.Errorf("expected markdown to reference discovery manifest file, got: %q", data.MarkdownContent) - } - - // Verify that orchestrator does NOT monitor workflow runs by file name - if strings.Contains(data.MarkdownContent, "list_workflow_runs") { - t.Errorf("expected markdown to NOT use list_workflow_runs for monitoring, but it does: %q", data.MarkdownContent) - } - - if strings.Contains(data.MarkdownContent, ".lock.yml") { - t.Errorf("expected markdown to NOT reference .lock.yml files for monitoring, but it does: %q", data.MarkdownContent) - } - - // Verify it follows system-agnostic rules - if !strings.Contains(data.MarkdownContent, "Core Principles") { - t.Errorf("expected markdown to contain core principles section, got: %q", data.MarkdownContent) - } - - // Verify separation of steps (read / decide / dispatch / report) - if !strings.Contains(data.MarkdownContent, "Step 1") || !strings.Contains(data.MarkdownContent, "Read State") { - t.Errorf("expected markdown to contain Step 1 Read State, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "Step 2") || !strings.Contains(data.MarkdownContent, "Make Decisions") { - t.Errorf("expected markdown to contain Step 2 Make Decisions, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "Step 3") || !strings.Contains(data.MarkdownContent, "Dispatch Workers") { - t.Errorf("expected markdown to contain Step 3 Dispatch Workers, got: %q", data.MarkdownContent) - } - if !strings.Contains(data.MarkdownContent, "Step 4") || !strings.Contains(data.MarkdownContent, "Report") { - t.Errorf("expected markdown to contain Step 4 Report, got: %q", data.MarkdownContent) - } - }) -} - -func TestBuildOrchestrator_GovernanceDoesNotGrantWriteSafeOutputs(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/test/projects/1", - Workflows: []string{"test-workflow"}, - Governance: &CampaignGovernancePolicy{ - MaxCommentsPerRun: 3, - MaxProjectUpdatesPerRun: 4, - }, - } - - mdPath := ".github/workflows/test-campaign.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - if data.SafeOutputs == nil || data.SafeOutputs.DispatchWorkflow == nil { - t.Fatalf("expected dispatch-workflow safe output to be enabled") - } - if data.SafeOutputs.DispatchWorkflow.Max != 3 { - t.Fatalf("unexpected dispatch-workflow max: got %d, want %d", data.SafeOutputs.DispatchWorkflow.Max, 3) - } - - // Governance should control update-project max - if data.SafeOutputs.UpdateProjects == nil { - t.Fatalf("expected update-project safe output to be enabled") - } - if data.SafeOutputs.UpdateProjects.Max != 4 { - t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4) - } - - // create-project-status-update should always be enabled - if data.SafeOutputs.CreateProjectStatusUpdates == nil { - t.Fatalf("expected create-project-status-update safe output to be enabled") - } - if data.SafeOutputs.CreateProjectStatusUpdates.Max != 1 { - t.Fatalf("unexpected create-project-status-update max: got %d, want %d", data.SafeOutputs.CreateProjectStatusUpdates.Max, 1) - } - - // Orchestrators should NOT have create-issue or add-comment (governance MaxCommentsPerRun doesn't grant add-comment) - if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil { - t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs regardless of governance") - } - }) -} - -func TestExtractFileGlobPatterns(t *testing.T) { - tests := []struct { - name string - spec *CampaignSpec - expectedGlobs []string - expectedLogMsg string - }{ - { - name: "flexible pattern matching both dated and non-dated", - spec: &CampaignSpec{ - ID: "go-file-size-reduction-project64", - MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64*/**"}, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", - }, - expectedGlobs: []string{"go-file-size-reduction-project64*/**"}, - expectedLogMsg: "Extracted file-glob pattern from memory-paths", - }, - { - name: "dated pattern in memory-paths", - spec: &CampaignSpec{ - ID: "go-file-size-reduction-project64", - MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64-*/**"}, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", - }, - expectedGlobs: []string{"go-file-size-reduction-project64-*/**"}, - expectedLogMsg: "Extracted file-glob pattern from memory-paths", - }, - { - name: "multiple patterns in memory-paths", - spec: &CampaignSpec{ - ID: "go-file-size-reduction-project64", - MemoryPaths: []string{ - "memory/campaigns/go-file-size-reduction-project64-*/**", - "memory/campaigns/go-file-size-reduction-project64/**", - }, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", - }, - expectedGlobs: []string{"go-file-size-reduction-project64-*/**", "go-file-size-reduction-project64/**"}, - expectedLogMsg: "Extracted file-glob pattern from memory-paths", - }, - { - name: "dated pattern in metrics-glob only", - spec: &CampaignSpec{ - ID: "go-file-size-reduction-project64", - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", - }, - expectedGlobs: []string{"go-file-size-reduction-project64-*/**"}, - expectedLogMsg: "Extracted file-glob pattern from metrics-glob", - }, - { - name: "simple pattern without wildcards", - spec: &CampaignSpec{ - ID: "simple-campaign", - MemoryPaths: []string{"memory/campaigns/simple-campaign/**"}, - }, - expectedGlobs: []string{"simple-campaign/**"}, - expectedLogMsg: "Extracted file-glob pattern from memory-paths", - }, - { - name: "no memory paths or metrics glob", - spec: &CampaignSpec{ - ID: "minimal-campaign", - }, - expectedGlobs: []string{"minimal-campaign/**"}, - expectedLogMsg: "Using fallback file-glob pattern", - }, - { - name: "multiple memory paths with wildcard", - spec: &CampaignSpec{ - ID: "multi-path", - MemoryPaths: []string{ - "memory/campaigns/multi-path-staging/**", - "memory/campaigns/multi-path-*/data/**", - }, - }, - expectedGlobs: []string{"multi-path-staging/**", "multi-path-*/data/**"}, - expectedLogMsg: "Extracted file-glob pattern from memory-paths", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractFileGlobPatterns(tt.spec) - if len(result) != len(tt.expectedGlobs) { - t.Errorf("extractFileGlobPatterns(%q) returned %d patterns, want %d", tt.spec.ID, len(result), len(tt.expectedGlobs)) - return - } - for i, expected := range tt.expectedGlobs { - if result[i] != expected { - t.Errorf("extractFileGlobPatterns(%q)[%d] = %q, want %q", tt.spec.ID, i, result[i], expected) - } - } - }) - } -} - -func TestBuildOrchestrator_FileGlobMatchesMemoryPaths(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - // This test verifies that the file-glob pattern in repo-memory configuration - // matches the pattern defined in memory-paths, including wildcards - spec := &CampaignSpec{ - ID: "go-file-size-reduction-project64", - Name: "Go File Size Reduction Campaign", - Description: "Test campaign with dated memory paths", - ProjectURL: "https://github.com/orgs/githubnext/projects/64", - Workflows: []string{"daily-file-diet"}, - MemoryPaths: []string{"memory/campaigns/go-file-size-reduction-project64-*/**"}, - MetricsGlob: "memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json", - } - - mdPath := ".github/workflows/go-file-size-reduction-project64.campaign.md" - data, _ := BuildOrchestrator(spec, mdPath) - - if data == nil { - t.Fatalf("expected non-nil WorkflowData") - } - - // Extract repo-memory configuration from Tools - repoMemoryConfig, ok := data.Tools["repo-memory"] - if !ok { - t.Fatalf("expected repo-memory to be configured in Tools") - } - - repoMemoryArray, ok := repoMemoryConfig.([]any) - if !ok || len(repoMemoryArray) == 0 { - t.Fatalf("expected repo-memory to be an array with at least one entry") - } - - repoMemoryEntry, ok := repoMemoryArray[0].(map[string]any) - if !ok { - t.Fatalf("expected repo-memory entry to be a map") - } - - fileGlob, ok := repoMemoryEntry["file-glob"] - if !ok { - t.Fatalf("expected file-glob to be present in repo-memory entry") - } - - fileGlobArray, ok := fileGlob.([]any) - if !ok || len(fileGlobArray) == 0 { - t.Fatalf("expected file-glob to be an array with at least one entry") - } - - fileGlobPattern, ok := fileGlobArray[0].(string) - if !ok { - t.Fatalf("expected file-glob pattern to be a string") - } - - // Verify that the file-glob pattern includes the wildcard for dated directories - expectedPattern := "go-file-size-reduction-project64-*/**" - if fileGlobPattern != expectedPattern { - t.Errorf("file-glob pattern = %q, want %q", fileGlobPattern, expectedPattern) - } - - // Verify that the pattern would match dated directories - if !strings.Contains(fileGlobPattern, "*") { - t.Errorf("file-glob pattern should include wildcard for dated directories, got %q", fileGlobPattern) - } - }) -} diff --git a/pkg/campaign/project.go b/pkg/campaign/project.go deleted file mode 100644 index f3310c3a479..00000000000 --- a/pkg/campaign/project.go +++ /dev/null @@ -1,800 +0,0 @@ -package campaign - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - - "github.com/githubnext/gh-aw/pkg/console" - "github.com/githubnext/gh-aw/pkg/logger" -) - -var projectLog = logger.New("campaign:project") - -// ProjectCreationConfig holds configuration for creating a campaign project -type ProjectCreationConfig struct { - CampaignID string - CampaignName string - Owner string // GitHub org or user - LinkRepo string // Optional: owner/name to link the project to - NoLinkRepo bool // Disable best-effort repo linking - Verbose bool -} - -// ProjectCreationResult holds the result of project creation -type ProjectCreationResult struct { - ProjectURL string - ProjectNumber int -} - -// CreateCampaignProject creates a GitHub Project with required views and fields for a campaign -func CreateCampaignProject(config ProjectCreationConfig) (*ProjectCreationResult, error) { - projectLog.Printf("Creating campaign project for campaign ID: %s", config.CampaignID) - - // Check if gh CLI is available - if !isGHCLIAvailable() { - return nil, fmt.Errorf("GitHub CLI (gh) is not available. Install it from https://cli.github.com/") - } - - // Create the project - projectURL, projectNumber, err := createProject(config) - if err != nil { - return nil, fmt.Errorf("failed to create project: %w", err) - } - - console.LogVerbose(config.Verbose, fmt.Sprintf("Created project: %s", projectURL)) - - // Create required fields - if err := createProjectFields(config, projectNumber); err != nil { - return nil, fmt.Errorf("failed to create project fields: %w", err) - } - - console.LogVerbose(config.Verbose, "Created project fields") - - // Create standard views (board/table/roadmap) - if err := createProjectViews(config, projectURL); err != nil { - return nil, fmt.Errorf("failed to create project views: %w", err) - } - - console.LogVerbose(config.Verbose, "Created project views") - - // Best-effort: link the project to the current repository. - // This should not block campaign creation if linking fails due to permissions. - if err := linkProjectToRepo(config, projectURL); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage( - "Could not link project to repository automatically: "+err.Error(), - )) - } - - // Ensure the Progress Board's typical grouping field (Status) includes a - // "Review Required" option, which effectively becomes a new column. - if err := ensureStatusOption(config, projectURL, "Review Required"); err != nil { - return nil, fmt.Errorf("failed to update project status options: %w", err) - } - - result := &ProjectCreationResult{ - ProjectURL: projectURL, - ProjectNumber: projectNumber, - } - - return result, nil -} - -func linkProjectToRepo(config ProjectCreationConfig, projectURL string) error { - if config.NoLinkRepo { - console.LogVerbose(config.Verbose, "Skipping project-to-repo linking (--no-link-repo)") - return nil - } - - nameWithOwner := strings.TrimSpace(config.LinkRepo) - if nameWithOwner == "" { - var err error - nameWithOwner, err = getCurrentRepoNameWithOwner() - if err != nil { - return err - } - } - - owner, repo, err := parseRepoNameWithOwner(nameWithOwner) - if err != nil { - return err - } - - info, err := parseProjectURL(projectURL) - if err != nil { - return err - } - - // GitHub only allows linking when the project and repository share the same owner. - // Avoid calling the mutation to prevent a noisy (and expected) GraphQL validation error. - if !strings.EqualFold(info.ownerLogin, owner) { - return fmt.Errorf( - "project is owned by %q but current repository is %q; GitHub only allows linking projects to repositories owned by the same account/org. Re-run with --owner %s (or --owner @me for personal repos) from the repo you want linked", - info.ownerLogin, - nameWithOwner, - owner, - ) - } - - projectID, err := getProjectID(info) - if err != nil { - return err - } - - repoID, err := getRepositoryID(owner, repo) - if err != nil { - return err - } - - if err := linkProjectV2ToRepository(projectID, repoID); err != nil { - return err - } - - console.LogVerbose(config.Verbose, fmt.Sprintf("Linked project to repository: %s", nameWithOwner)) - return nil -} - -func parseRepoNameWithOwner(nameWithOwner string) (string, string, error) { - trimmed := strings.TrimSpace(nameWithOwner) - owner, repo, ok := strings.Cut(trimmed, "/") - owner = strings.TrimSpace(owner) - repo = strings.TrimSpace(repo) - owner = strings.TrimPrefix(owner, "@") - if !ok || owner == "" || repo == "" { - return "", "", fmt.Errorf("invalid repository %q; expected format owner/name", nameWithOwner) - } - return owner, repo, nil -} - -func getCurrentRepoNameWithOwner() (string, error) { - cmd := exec.Command("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("gh repo view failed: %w\nOutput: %s", err, string(out)) - } - nameWithOwner := strings.TrimSpace(string(out)) - if nameWithOwner == "" { - return "", fmt.Errorf("failed to determine current repository (empty nameWithOwner)") - } - return nameWithOwner, nil -} - -func getRepositoryID(owner, name string) (string, error) { - query := `query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { id } - }` - - cmd := exec.Command( - "gh", - "api", - "graphql", - "-F", - "owner="+owner, - "-F", - "name="+name, - "-f", - "query="+query, - ) - - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) - } - - var resp struct { - Data struct { - Repository struct { - ID string `json:"id"` - } `json:"repository"` - } `json:"data"` - } - - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) - } - - if resp.Data.Repository.ID == "" { - return "", fmt.Errorf("failed to find repository ID for %s/%s", owner, name) - } - - return resp.Data.Repository.ID, nil -} - -func getProjectID(info projectURLInfo) (string, error) { - query := "" - if info.scope == "orgs" { - query = `query($login: String!, $number: Int!) { - organization(login: $login) { - projectV2(number: $number) { id } - } - }` - } else { - query = `query($login: String!, $number: Int!) { - user(login: $login) { - projectV2(number: $number) { id } - } - }` - } - - cmd := exec.Command( - "gh", - "api", - "graphql", - "-F", - "login="+info.ownerLogin, - "-F", - fmt.Sprintf("number=%d", info.projectNumber), - "-f", - "query="+query, - ) - - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) - } - - if info.scope == "orgs" { - var resp struct { - Data struct { - Organization struct { - ProjectV2 struct { - ID string `json:"id"` - } `json:"projectV2"` - } `json:"organization"` - } `json:"data"` - } - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) - } - if resp.Data.Organization.ProjectV2.ID == "" { - return "", fmt.Errorf("failed to find project ID in GraphQL response") - } - return resp.Data.Organization.ProjectV2.ID, nil - } - - var resp struct { - Data struct { - User struct { - ProjectV2 struct { - ID string `json:"id"` - } `json:"projectV2"` - } `json:"user"` - } `json:"data"` - } - if err := json.Unmarshal(out, &resp); err != nil { - return "", fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) - } - if resp.Data.User.ProjectV2.ID == "" { - return "", fmt.Errorf("failed to find project ID in GraphQL response") - } - return resp.Data.User.ProjectV2.ID, nil -} - -func linkProjectV2ToRepository(projectID, repositoryID string) error { - mutation := `mutation($input: LinkProjectV2ToRepositoryInput!) { - linkProjectV2ToRepository(input: $input) { - clientMutationId - } - }` - - requestBody := map[string]any{ - "query": mutation, - "variables": map[string]any{ - "input": map[string]any{ - "projectId": projectID, - "repositoryId": repositoryID, - }, - }, - } - - requestJSON, err := json.Marshal(requestBody) - if err != nil { - return fmt.Errorf("failed to marshal GraphQL request body: %w", err) - } - - cmd := exec.Command("gh", "api", "graphql", "--input", "-") - cmd.Stdin = bytes.NewReader(requestJSON) - - out, err := cmd.CombinedOutput() - if err != nil { - // If it's already linked, treat it as success. - msg := string(out) - if strings.Contains(strings.ToLower(msg), "already") && strings.Contains(strings.ToLower(msg), "link") { - return nil - } - return fmt.Errorf("gh api graphql link failed: %w\nOutput: %s", err, msg) - } - - return nil -} - -type projectURLInfo struct { - scope string // "users" or "orgs" - ownerLogin string - projectNumber int -} - -var projectURLRegexp = regexp.MustCompile(`github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)`) - -func parseProjectURL(projectURL string) (projectURLInfo, error) { - match := projectURLRegexp.FindStringSubmatch(projectURL) - if match == nil { - return projectURLInfo{}, fmt.Errorf("invalid project URL: %q. Expected format: https://github.com/orgs/myorg/projects/123", projectURL) - } - - projectNumber, err := strconv.Atoi(match[3]) - if err != nil { - return projectURLInfo{}, fmt.Errorf("invalid project number in URL %q: %w", projectURL, err) - } - - return projectURLInfo{ - scope: match[1], - ownerLogin: match[2], - projectNumber: projectNumber, - }, nil -} - -func createProjectViews(config ProjectCreationConfig, projectURL string) error { - projectLog.Printf("Creating standard views for project URL: %s", projectURL) - - info, err := parseProjectURL(projectURL) - if err != nil { - return err - } - - views := []struct { - name string - layout string - }{ - {name: "Progress Board", layout: "board"}, - {name: "Task Tracker", layout: "table"}, - {name: "Campaign Roadmap", layout: "roadmap"}, - } - - for _, view := range views { - if err := createProjectView(info, view.name, view.layout); err != nil { - return fmt.Errorf("failed to create view %q (%s): %w", view.name, view.layout, err) - } - console.LogVerbose(config.Verbose, fmt.Sprintf("Created view: %s (%s)", view.name, view.layout)) - } - - return nil -} - -func createProjectView(info projectURLInfo, name, layout string) error { - path := "" - if info.scope == "orgs" { - path = fmt.Sprintf("/orgs/%s/projectsV2/%d/views", info.ownerLogin, info.projectNumber) - } else { - path = fmt.Sprintf("/users/%s/projectsV2/%d/views", info.ownerLogin, info.projectNumber) - } - - cmd := exec.Command( - "gh", - "api", - "--method", - "POST", - path, - "-H", - "Accept: application/vnd.github+json", - "-H", - "X-GitHub-Api-Version: 2022-11-28", - "-f", - "name="+name, - "-f", - "layout="+layout, - ) - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("gh api failed: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -type singleSelectOption struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} - -type statusFieldLookup struct { - ProjectID string - FieldID string - Options []singleSelectOption -} - -func ensureStatusOption(config ProjectCreationConfig, projectURL string, optionName string) error { - projectLog.Printf("Ensuring Status option %q exists for project URL: %s", optionName, projectURL) - - info, err := parseProjectURL(projectURL) - if err != nil { - return err - } - - lookup, err := getStatusField(config, info) - if err != nil { - return err - } - - updatedOptions, changed := ensureSingleSelectOptionBefore( - lookup.Options, - singleSelectOption{Name: optionName, Color: "BLUE", Description: "Needs review before moving to Done"}, - "Done", - ) - if !changed { - console.LogVerbose(config.Verbose, fmt.Sprintf("Status option already present and ordered: %s", optionName)) - return nil - } - - if err := updateSingleSelectFieldOptions(lookup.FieldID, updatedOptions); err != nil { - return err - } - - console.LogVerbose(config.Verbose, fmt.Sprintf("Ensured Status option is ordered before Done: %s", optionName)) - return nil -} - -func ensureSingleSelectOptionBefore(options []singleSelectOption, desired singleSelectOption, beforeName string) ([]singleSelectOption, bool) { - var existing *singleSelectOption - without := make([]singleSelectOption, 0, len(options)) - for _, opt := range options { - if opt.Name == desired.Name { - if existing == nil { - copyOpt := opt - existing = ©Opt - } - continue - } - without = append(without, opt) - } - - toInsert := desired - if existing != nil { - toInsert = *existing - toInsert.Color = desired.Color - if desired.Description != "" { - toInsert.Description = desired.Description - } - } - - insertAt := len(without) - for i, opt := range without { - if opt.Name == beforeName { - insertAt = i - break - } - } - - withInserted := make([]singleSelectOption, 0, len(without)+1) - withInserted = append(withInserted, without[:insertAt]...) - withInserted = append(withInserted, toInsert) - withInserted = append(withInserted, without[insertAt:]...) - - return withInserted, !singleSelectOptionsEqual(options, withInserted) -} - -func singleSelectOptionsEqual(a, b []singleSelectOption) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func getStatusField(config ProjectCreationConfig, info projectURLInfo) (statusFieldLookup, error) { - // Query the project ID and the built-in Status single-select field. - // We use the REST URL components (org/user + number) to locate the project. - query := "" - if info.scope == "orgs" { - query = `query($login: String!, $number: Int!) { - organization(login: $login) { - projectV2(number: $number) { - id - fields(first: 100) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { name color description } - } - } - } - } - } - }` - } else { - query = `query($login: String!, $number: Int!) { - user(login: $login) { - projectV2(number: $number) { - id - fields(first: 100) { - nodes { - ... on ProjectV2SingleSelectField { - id - name - options { name color description } - } - } - } - } - } - }` - } - - cmd := exec.Command( - "gh", - "api", - "graphql", - "-F", - "login="+info.ownerLogin, - "-F", - fmt.Sprintf("number=%d", info.projectNumber), - "-f", - "query="+query, - ) - - out, err := cmd.CombinedOutput() - if err != nil { - return statusFieldLookup{}, fmt.Errorf("gh api graphql failed: %w\nOutput: %s", err, string(out)) - } - - // Parse response - if info.scope == "orgs" { - var resp struct { - Data struct { - Organization struct { - ProjectV2 struct { - ID string `json:"id"` - Fields struct { - Nodes []struct { - ID string `json:"id"` - Name string `json:"name"` - Options []singleSelectOption `json:"options"` - } `json:"nodes"` - } `json:"fields"` - } `json:"projectV2"` - } `json:"organization"` - } `json:"data"` - } - - if err := json.Unmarshal(out, &resp); err != nil { - return statusFieldLookup{}, fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) - } - - projectID := resp.Data.Organization.ProjectV2.ID - if projectID == "" { - return statusFieldLookup{}, fmt.Errorf("failed to find project ID in GraphQL response") - } - - for _, node := range resp.Data.Organization.ProjectV2.Fields.Nodes { - if node.Name == "Status" { - return statusFieldLookup{ProjectID: projectID, FieldID: node.ID, Options: node.Options}, nil - } - } - } else { - var resp struct { - Data struct { - User struct { - ProjectV2 struct { - ID string `json:"id"` - Fields struct { - Nodes []struct { - ID string `json:"id"` - Name string `json:"name"` - Options []singleSelectOption `json:"options"` - } `json:"nodes"` - } `json:"fields"` - } `json:"projectV2"` - } `json:"user"` - } `json:"data"` - } - - if err := json.Unmarshal(out, &resp); err != nil { - return statusFieldLookup{}, fmt.Errorf("failed to parse GraphQL response: %w\nOutput: %s", err, string(out)) - } - - projectID := resp.Data.User.ProjectV2.ID - if projectID == "" { - return statusFieldLookup{}, fmt.Errorf("failed to find project ID in GraphQL response") - } - - for _, node := range resp.Data.User.ProjectV2.Fields.Nodes { - if node.Name == "Status" { - return statusFieldLookup{ProjectID: projectID, FieldID: node.ID, Options: node.Options}, nil - } - } - } - - return statusFieldLookup{}, fmt.Errorf("failed to locate Status field on project") -} - -func updateSingleSelectFieldOptions(fieldID string, options []singleSelectOption) error { - mutation := `mutation($input: UpdateProjectV2FieldInput!) { - updateProjectV2Field(input: $input) { - projectV2Field { - ... on ProjectV2SingleSelectField { - name - options { name } - } - } - } - }` - - input := map[string]any{ - "fieldId": fieldID, - "singleSelectOptions": options, - } - - requestBody := map[string]any{ - "query": mutation, - "variables": map[string]any{ - "input": input, - }, - } - - requestJSON, err := json.Marshal(requestBody) - if err != nil { - return fmt.Errorf("failed to marshal GraphQL request body: %w", err) - } - - cmd := exec.Command("gh", "api", "graphql", "--input", "-") - cmd.Stdin = bytes.NewReader(requestJSON) - - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("gh api graphql update failed: %w\nOutput: %s", err, string(out)) - } - - return nil -} - -// isGHCLIAvailable checks if the gh CLI is installed and available -func isGHCLIAvailable() bool { - cmd := exec.Command("gh", "--version") - return cmd.Run() == nil -} - -func normalizeProjectOwner(owner string) string { - trimmed := strings.TrimSpace(owner) - if strings.EqualFold(trimmed, "@me") { - return "@me" - } - trimmed = strings.TrimPrefix(trimmed, "@") - return trimmed -} - -// createProject creates a new GitHub Project and returns its URL and number -func createProject(config ProjectCreationConfig) (string, int, error) { - projectLog.Printf("Creating project with title: %s", config.CampaignName) - - owner := normalizeProjectOwner(config.Owner) - - // Create project using gh CLI - cmd := exec.Command("gh", "project", "create", - "--owner", owner, - "--title", config.CampaignName, - "--format", "json") - - output, err := cmd.CombinedOutput() - if err != nil { - return "", 0, fmt.Errorf("failed to create project: %w\nOutput: %s", err, string(output)) - } - - // Parse JSON output to get project URL and number - var result struct { - URL string `json:"url"` - Number int `json:"number"` - } - - if err := json.Unmarshal(output, &result); err != nil { - return "", 0, fmt.Errorf("failed to parse project creation output: %w\nOutput: %s", err, string(output)) - } - - projectLog.Printf("Project created: URL=%s, Number=%d", result.URL, result.Number) - return result.URL, result.Number, nil -} - -// createProjectFields creates the required fields for a campaign project -func createProjectFields(config ProjectCreationConfig, projectNumber int) error { - projectLog.Printf("Creating fields for project number: %d", projectNumber) - - // Define required fields - // Note: We use "Target Repo" instead of "Repository" because GitHub has a built-in - // REPOSITORY field type that conflicts with custom field creation - fields := []struct { - name string - dataType string - options []string // For SINGLE_SELECT fields - }{ - {"Campaign Id", "TEXT", nil}, - {"Worker Workflow", "TEXT", nil}, - {"Target Repo", "TEXT", nil}, - {"Priority", "SINGLE_SELECT", []string{"High", "Medium", "Low"}}, - {"Size", "SINGLE_SELECT", []string{"Small", "Medium", "Large"}}, - {"Start Date", "DATE", nil}, - {"End Date", "DATE", nil}, - } - - // Create each field - for _, field := range fields { - if err := createField(config, projectNumber, field.name, field.dataType, field.options); err != nil { - return fmt.Errorf("failed to create field '%s': %w", field.name, err) - } - console.LogVerbose(config.Verbose, fmt.Sprintf("Created field: %s", field.name)) - } - - return nil -} - -// createField creates a single field in the project -func createField(config ProjectCreationConfig, projectNumber int, name, dataType string, options []string) error { - projectLog.Printf("Creating field: name=%s, type=%s", name, dataType) - - owner := normalizeProjectOwner(config.Owner) - - args := []string{ - "project", "field-create", fmt.Sprintf("%d", projectNumber), - "--owner", owner, - "--name", name, - "--data-type", dataType, - } - - // Add options for SINGLE_SELECT fields - // The gh CLI expects --single-select-options to be passed multiple times, - // once for each option, rather than as a comma-separated list - if dataType == "SINGLE_SELECT" && len(options) > 0 { - for _, option := range options { - args = append(args, "--single-select-options", option) - } - } - - cmd := exec.Command("gh", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to create field: %w\nOutput: %s", err, string(output)) - } - - return nil -} - -// UpdateSpecWithProjectURL updates a campaign spec file with the project URL -func UpdateSpecWithProjectURL(specPath, projectURL string) error { - projectLog.Printf("Updating spec file %s with project URL: %s", specPath, projectURL) - - // Read the spec file - content, err := os.ReadFile(specPath) - if err != nil { - return fmt.Errorf("failed to read spec file: %w", err) - } - - specContent := string(content) - - // Replace the placeholder project URL with the actual one - placeholderURL := "https://github.com/orgs/ORG/projects/1" - if !strings.Contains(specContent, placeholderURL) { - // If placeholder doesn't exist, the spec might have been manually edited - projectLog.Print("Placeholder project URL not found, spec may have been edited") - return nil - } - - updatedContent := strings.Replace(specContent, placeholderURL, projectURL, 1) - - // Write the updated content back - if err := os.WriteFile(specPath, []byte(updatedContent), 0o644); err != nil { - return fmt.Errorf("failed to write updated spec file: %w", err) - } - - projectLog.Print("Successfully updated spec file with project URL") - return nil -} diff --git a/pkg/campaign/project_owner_normalization_test.go b/pkg/campaign/project_owner_normalization_test.go deleted file mode 100644 index dae0f2f6869..00000000000 --- a/pkg/campaign/project_owner_normalization_test.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build !integration - -package campaign - -import "testing" - -func TestNormalizeProjectOwner(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "plain login unchanged", - in: "mnkiefer", - want: "mnkiefer", - }, - { - name: "strip leading at", - in: "@mnkiefer", - want: "mnkiefer", - }, - { - name: "keep special @me", - in: "@me", - want: "@me", - }, - { - name: "trim whitespace", - in: " @mnkiefer ", - want: "mnkiefer", - }, - { - name: "empty stays empty", - in: "", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeProjectOwner(tt.in) - if got != tt.want { - t.Fatalf("normalizeProjectOwner(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} diff --git a/pkg/campaign/project_repo_parsing_test.go b/pkg/campaign/project_repo_parsing_test.go deleted file mode 100644 index 7fffbd9225c..00000000000 --- a/pkg/campaign/project_repo_parsing_test.go +++ /dev/null @@ -1,72 +0,0 @@ -//go:build !integration - -package campaign - -import "testing" - -func TestParseRepoNameWithOwner(t *testing.T) { - tests := []struct { - name string - in string - wantOwner string - wantRepo string - wantErr bool - }{ - { - name: "basic", - in: "githubnext/gh-aw", - wantOwner: "githubnext", - wantRepo: "gh-aw", - }, - { - name: "trims whitespace", - in: " githubnext / gh-aw ", - wantOwner: "githubnext", - wantRepo: "gh-aw", - }, - { - name: "strips leading at on owner", - in: "@mnkiefer/gh-aw", - wantOwner: "mnkiefer", - wantRepo: "gh-aw", - }, - { - name: "missing slash", - in: "githubnext", - wantErr: true, - }, - { - name: "empty owner", - in: "/repo", - wantErr: true, - }, - { - name: "empty repo", - in: "owner/", - wantErr: true, - }, - { - name: "empty", - in: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - owner, repo, err := parseRepoNameWithOwner(tt.in) - if tt.wantErr { - if err == nil { - t.Fatalf("parseRepoNameWithOwner(%q) expected error", tt.in) - } - return - } - if err != nil { - t.Fatalf("parseRepoNameWithOwner(%q) unexpected error: %v", tt.in, err) - } - if owner != tt.wantOwner || repo != tt.wantRepo { - t.Fatalf("parseRepoNameWithOwner(%q) = (%q, %q), want (%q, %q)", tt.in, owner, repo, tt.wantOwner, tt.wantRepo) - } - }) - } -} diff --git a/pkg/campaign/project_status_options_test.go b/pkg/campaign/project_status_options_test.go deleted file mode 100644 index 90870e5bc88..00000000000 --- a/pkg/campaign/project_status_options_test.go +++ /dev/null @@ -1,91 +0,0 @@ -//go:build !integration - -package campaign - -import "testing" - -func TestEnsureSingleSelectOptionBefore(t *testing.T) { - tests := []struct { - name string - options []singleSelectOption - want []singleSelectOption - changed bool - }{ - { - name: "inserts before Done when missing", - options: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "In Progress", Color: "BLUE", Description: ""}, - {Name: "Done", Color: "GREEN", Description: ""}, - }, - want: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "In Progress", Color: "BLUE", Description: ""}, - {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - {Name: "Done", Color: "GREEN", Description: ""}, - }, - changed: true, - }, - { - name: "moves existing option before Done", - options: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "Done", Color: "GREEN", Description: ""}, - {Name: "Review Required", Color: "PINK", Description: "keep"}, - }, - want: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - {Name: "Done", Color: "GREEN", Description: ""}, - }, - changed: true, - }, - { - name: "no change when already before Done", - options: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - {Name: "Done", Color: "GREEN", Description: ""}, - }, - want: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - {Name: "Done", Color: "GREEN", Description: ""}, - }, - changed: false, - }, - { - name: "appends when Done missing", - options: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - }, - want: []singleSelectOption{ - {Name: "Todo", Color: "GRAY", Description: ""}, - {Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - }, - changed: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, changed := ensureSingleSelectOptionBefore( - tt.options, - singleSelectOption{Name: "Review Required", Color: "BLUE", Description: "Needs review before moving to Done"}, - "Done", - ) - - if changed != tt.changed { - t.Fatalf("changed=%v, want %v", changed, tt.changed) - } - if len(got) != len(tt.want) { - t.Fatalf("len(got)=%d, want %d", len(got), len(tt.want)) - } - for i := range got { - if got[i] != tt.want[i] { - t.Fatalf("got[%d]=%+v, want %+v", i, got[i], tt.want[i]) - } - } - }) - } -} diff --git a/pkg/campaign/project_test.go b/pkg/campaign/project_test.go deleted file mode 100644 index ce2e2634cd6..00000000000 --- a/pkg/campaign/project_test.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestUpdateSpecWithProjectURL(t *testing.T) { - // Create a temporary directory - tmpDir := t.TempDir() - - // Create a spec file with placeholder URL - specContent := `--- -id: test-campaign -name: Test Campaign -project-url: https://github.com/orgs/ORG/projects/1 -version: v1 -state: planned ---- - -# Test Campaign - -This is a test campaign. -` - - specPath := filepath.Join(tmpDir, "test.campaign.md") - if err := os.WriteFile(specPath, []byte(specContent), 0o644); err != nil { - t.Fatalf("Failed to write test spec file: %v", err) - } - - // Update the spec with a real project URL - newProjectURL := "https://github.com/orgs/myorg/projects/42" - if err := UpdateSpecWithProjectURL(specPath, newProjectURL); err != nil { - t.Fatalf("UpdateSpecWithProjectURL failed: %v", err) - } - - // Read the updated spec - updatedContent, err := os.ReadFile(specPath) - if err != nil { - t.Fatalf("Failed to read updated spec file: %v", err) - } - - updatedStr := string(updatedContent) - - // Verify the URL was updated - if !strings.Contains(updatedStr, newProjectURL) { - t.Errorf("Expected updated spec to contain '%s', but it doesn't", newProjectURL) - } - - // Verify the placeholder URL is gone - if strings.Contains(updatedStr, "https://github.com/orgs/ORG/projects/1") { - t.Error("Updated spec still contains placeholder URL") - } -} - -func TestUpdateSpecWithProjectURL_NoPlaceholder(t *testing.T) { - // Create a temporary directory - tmpDir := t.TempDir() - - // Create a spec file without placeholder URL - specContent := `--- -id: test-campaign -name: Test Campaign -project-url: https://github.com/orgs/myorg/projects/99 -version: v1 -state: planned ---- - -# Test Campaign - -This is a test campaign. -` - - specPath := filepath.Join(tmpDir, "test.campaign.md") - if err := os.WriteFile(specPath, []byte(specContent), 0o644); err != nil { - t.Fatalf("Failed to write test spec file: %v", err) - } - - // Try to update the spec (should succeed but not change anything) - newProjectURL := "https://github.com/orgs/myorg/projects/42" - if err := UpdateSpecWithProjectURL(specPath, newProjectURL); err != nil { - t.Fatalf("UpdateSpecWithProjectURL failed: %v", err) - } - - // Read the content - updatedContent, err := os.ReadFile(specPath) - if err != nil { - t.Fatalf("Failed to read updated spec file: %v", err) - } - - updatedStr := string(updatedContent) - - // Verify the original URL is still there (not replaced) - if !strings.Contains(updatedStr, "https://github.com/orgs/myorg/projects/99") { - t.Error("Original project URL was incorrectly modified") - } - - // Verify the new URL was not added - if strings.Contains(updatedStr, newProjectURL) { - t.Error("New project URL was incorrectly added") - } -} - -func TestUpdateSpecWithProjectURL_FileNotFound(t *testing.T) { - // Try to update a non-existent file - err := UpdateSpecWithProjectURL("/nonexistent/path/spec.md", "https://github.com/orgs/myorg/projects/1") - if err == nil { - t.Error("Expected error for non-existent file, got nil") - } - - if !strings.Contains(err.Error(), "failed to read spec file") { - t.Errorf("Expected 'failed to read spec file' error, got: %v", err) - } -} - -func TestIsGHCLIAvailable(t *testing.T) { - - // This test just verifies the function doesn't panic - // The actual result depends on the test environment - available := isGHCLIAvailable() - t.Logf("GitHub CLI available: %v", available) -} - -func TestCreateProjectFieldsConfiguration(t *testing.T) { - // This test verifies that project fields are correctly configured - // We can't actually create fields without gh CLI and authentication, - // but we can verify the field definitions are correct - - // Expected fields that should be created - expectedFields := map[string]struct { - dataType string - hasOptions bool - }{ - "Campaign Id": {"TEXT", false}, - "Worker Workflow": {"TEXT", false}, - "Target Repo": {"TEXT", false}, - "Priority": {"SINGLE_SELECT", true}, - "Size": {"SINGLE_SELECT", true}, - "Start Date": {"DATE", false}, - "End Date": {"DATE", false}, - } - - // Note: This is a design test - we're checking that the field configuration - // matches our requirements. The actual field creation is tested via integration tests. - for fieldName := range expectedFields { - // Just verify the expected field names exist in our test expectations - t.Logf("Expected field: %s", fieldName) - } - - // Verify "Repository" is NOT in the expected fields (to avoid GitHub conflict) - if _, exists := expectedFields["Repository"]; exists { - t.Error("Field 'Repository' should not be created (conflicts with GitHub built-in REPOSITORY field)") - } - - // Verify "Target Repo" IS in the expected fields - if _, exists := expectedFields["Target Repo"]; !exists { - t.Error("Field 'Target Repo' should be created (replacement for 'repository')") - } -} diff --git a/pkg/campaign/project_update_contract_validator.go b/pkg/campaign/project_update_contract_validator.go deleted file mode 100644 index 5bfc0f57949..00000000000 --- a/pkg/campaign/project_update_contract_validator.go +++ /dev/null @@ -1,144 +0,0 @@ -package campaign - -import ( - "embed" - "encoding/json" - "fmt" - "strings" - "sync" - - "github.com/santhosh-tekuri/jsonschema/v6" -) - -//go:embed schemas/project_update_schema.json -var projectUpdateSchemaFS embed.FS - -var ( - compiledProjectUpdateSchemaOnce sync.Once - compiledProjectUpdateSchema *jsonschema.Schema - projectUpdateSchemaCompileError error -) - -func getCompiledProjectUpdateSchema() (*jsonschema.Schema, error) { - compiledProjectUpdateSchemaOnce.Do(func() { - schemaData, err := projectUpdateSchemaFS.ReadFile("schemas/project_update_schema.json") - if err != nil { - projectUpdateSchemaCompileError = fmt.Errorf("failed to load project update schema: %w", err) - return - } - - var schemaDoc any - if err := json.Unmarshal(schemaData, &schemaDoc); err != nil { - projectUpdateSchemaCompileError = fmt.Errorf("failed to parse project update schema: %w", err) - return - } - - compiler := jsonschema.NewCompiler() - schemaURL := "project-update.json" - if err := compiler.AddResource(schemaURL, schemaDoc); err != nil { - projectUpdateSchemaCompileError = fmt.Errorf("failed to add project update schema resource: %w", err) - return - } - - schema, err := compiler.Compile(schemaURL) - if err != nil { - projectUpdateSchemaCompileError = fmt.Errorf("failed to compile project update schema: %w", err) - return - } - - compiledProjectUpdateSchema = schema - }) - - return compiledProjectUpdateSchema, projectUpdateSchemaCompileError -} - -// ValidateProjectUpdatePayload enforces the governed update-project contract. -// -// This validator is intentionally deterministic: -// - JSON Schema enforces payload shape. -// - Semantic checks enforce equality constraints that JSON Schema cannot. -// -// payload is expected to be a YAML/JSON-decoded structure (map[string]any, etc.). -func ValidateProjectUpdatePayload(payload any, expectedProjectURL string, expectedCampaignID string) []string { - schema, err := getCompiledProjectUpdateSchema() - if err != nil { - return []string{err.Error()} - } - - normalized := normalizeYAMLValue(payload) - if err := schema.Validate(normalized); err != nil { - return formatValidationErrors(err) - } - - var problems []string - root, ok := normalized.(map[string]any) - if !ok { - return []string{"root: payload must be an object"} - } - - if expectedProjectURL != "" { - if project, _ := root["project"].(string); project != expectedProjectURL { - problems = append(problems, fmt.Sprintf("project: must equal %q", expectedProjectURL)) - } - } - - if expectedCampaignID != "" { - if campaignID, _ := root["campaign_id"].(string); campaignID != expectedCampaignID { - problems = append(problems, fmt.Sprintf("campaign_id: must equal %q", expectedCampaignID)) - } - } - - fields, _ := root["fields"].(map[string]any) - if expectedCampaignID != "" { - if backfillCampaignID, ok := fields["campaign_id"].(string); ok && backfillCampaignID != expectedCampaignID { - problems = append(problems, fmt.Sprintf("fields.campaign_id: must equal %q", expectedCampaignID)) - } - } - - // Deterministic normalization: for status-only updates, never allow other writes. - if len(fields) > 1 { - // Schema already enforces the full-backfill set, but we keep a stable message for checklist enforcement. - requiredKeys := []string{"campaign_id", "worker_workflow", "target_repo", "priority", "size", "start_date", "end_date"} - var missing []string - for _, key := range requiredKeys { - if _, ok := fields[key]; !ok { - missing = append(missing, key) - } - } - if len(missing) > 0 { - problems = append(problems, fmt.Sprintf("fields: missing required backfill keys: %s", strings.Join(missing, ", "))) - } - } - - return problems -} - -// normalizeYAMLValue converts map[any]any (from YAML decoding) into JSON-friendly structures. -func normalizeYAMLValue(value any) any { - switch v := value.(type) { - case map[string]any: - out := make(map[string]any, len(v)) - for key, child := range v { - out[key] = normalizeYAMLValue(child) - } - return out - case map[any]any: - out := make(map[string]any, len(v)) - for key, child := range v { - keyStr, ok := key.(string) - if !ok { - keyStr = fmt.Sprintf("%v", key) - } - out[keyStr] = normalizeYAMLValue(child) - } - return out - case []any: - out := make([]any, 0, len(v)) - for _, child := range v { - out = append(out, normalizeYAMLValue(child)) - } - return out - default: - return value - } -} diff --git a/pkg/campaign/project_views_test.go b/pkg/campaign/project_views_test.go deleted file mode 100644 index 8b601483a37..00000000000 --- a/pkg/campaign/project_views_test.go +++ /dev/null @@ -1,54 +0,0 @@ -//go:build !integration - -package campaign - -import "testing" - -func TestParseProjectURL(t *testing.T) { - tests := []struct { - name string - input string - wantScope string - wantOwner string - wantNum int - wantErr bool - }{ - { - name: "org project", - input: "https://github.com/orgs/githubnext/projects/123", - wantScope: "orgs", - wantOwner: "githubnext", - wantNum: 123, - }, - { - name: "user project", - input: "https://github.com/users/mnkiefer/projects/7", - wantScope: "users", - wantOwner: "mnkiefer", - wantNum: 7, - }, - { - name: "invalid url", - input: "https://github.com/githubnext/projects/123", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseProjectURL(tt.input) - if tt.wantErr { - if err == nil { - t.Fatalf("expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("parseProjectURL error: %v", err) - } - if got.scope != tt.wantScope || got.ownerLogin != tt.wantOwner || got.projectNumber != tt.wantNum { - t.Fatalf("parseProjectURL(%q) = %+v, want scope=%q owner=%q number=%d", tt.input, got, tt.wantScope, tt.wantOwner, tt.wantNum) - } - }) - } -} diff --git a/pkg/campaign/prompt_sections.go b/pkg/campaign/prompt_sections.go deleted file mode 100644 index 118b2ba0832..00000000000 --- a/pkg/campaign/prompt_sections.go +++ /dev/null @@ -1,19 +0,0 @@ -package campaign - -import ( - "fmt" - "strings" -) - -// AppendPromptSection appends a section with a header and body to the builder. -// This is used to structure campaign orchestrator prompts with clear section boundaries. -func AppendPromptSection(b *strings.Builder, title, body string) { - body = strings.TrimSpace(body) - if body == "" { - return - } - - // Titles should be single-line to keep markdown structure stable. - title = strings.Join(strings.Fields(title), " ") - fmt.Fprintf(b, "\n---\n# %s\n---\n%s", title, body) -} diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json deleted file mode 100644 index 45534f0be36..00000000000 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ /dev/null @@ -1,386 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/githubnext/gh-aw/schemas/campaign_spec_schema.json", - "title": "Campaign Spec Schema", - "description": "Schema for GitHub Agentic Workflows campaign specifications", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the campaign (lowercase letters, digits, and hyphens only)", - "pattern": "^[a-z0-9-]+$", - "minLength": 1 - }, - "name": { - "type": "string", - "description": "Human-readable name for the campaign", - "minLength": 1 - }, - "description": { - "type": "string", - "description": "Brief description of the campaign" - }, - "project-url": { - "type": "string", - "description": "URL of the GitHub Project used as the primary campaign dashboard", - "format": "uri", - "pattern": "/projects/", - "minLength": 1 - }, - "version": { - "type": "string", - "description": "Spec version (e.g., v1)", - "pattern": "^v[0-9]+$", - "default": "v1" - }, - "workflows": { - "type": "array", - "description": "List of workflow IDs (basenames without .md) implementing this campaign", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - }, - "scope": { - "type": "array", - "description": "Optional scope selectors defining which repositories and organizations this campaign is allowed to discover and operate on. When omitted, defaults to the current repository where the campaign is defined.", - "items": { - "type": "string", - "minLength": 1, - "anyOf": [ - { - "pattern": "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$", - "description": "Repository selector: owner/repo" - }, - { - "pattern": "^org:[a-zA-Z0-9_.-]+$", - "description": "Organization selector: org:" - }, - { - "pattern": "^[a-zA-Z0-9_.-]+/\\*$", - "description": "Organization selector (sugar): /*" - } - ] - }, - "minItems": 1 - }, - "memory-paths": { - "type": "array", - "description": "Paths where this campaign writes its repo-memory", - "items": { - "type": "string", - "minLength": 1 - } - }, - "metrics-glob": { - "type": "string", - "description": "Glob pattern to locate JSON metrics snapshots in memory/campaigns branch" - }, - "cursor-glob": { - "type": "string", - "description": "Glob pattern to locate a durable cursor/checkpoint file in memory/campaigns branch", - "pattern": "^memory/campaigns/", - "minLength": 1 - }, - "owners": { - "type": "array", - "description": "Primary human owners for this campaign", - "items": { - "type": "string", - "minLength": 1 - } - }, - "executive-sponsors": { - "type": "array", - "description": "Executive stakeholders or sponsors", - "items": { - "type": "string", - "minLength": 1 - } - }, - "risk-level": { - "type": "string", - "description": "Risk level (e.g., low, medium, high)", - "enum": ["low", "medium", "high"] - }, - "state": { - "type": "string", - "description": "Lifecycle stage of the campaign", - "enum": ["planned", "active", "paused", "completed", "archived"] - }, - "tags": { - "type": "array", - "description": "Free-form categorization tags", - "items": { - "type": "string", - "minLength": 1 - } - }, - "allowed-safe-outputs": { - "type": "array", - "description": "Safe-outputs operations this campaign is expected to use", - "items": { - "type": "string", - "minLength": 1 - } - }, - "approval-policy": { - "type": "object", - "description": "Approval expectations for this campaign", - "properties": { - "required-approvals": { - "type": "integer", - "description": "Number of required approvals", - "minimum": 0 - }, - "required-roles": { - "type": "array", - "description": "Required roles for approval", - "items": { - "type": "string", - "minLength": 1 - } - }, - "change-control": { - "type": "boolean", - "description": "Whether change control is required" - } - }, - "additionalProperties": false - }, - "launcher": { - "type": "object", - "description": "Optional launcher layer configuration. When omitted, the launcher is enabled by default.", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to generate and compile the campaign launcher workflow" - } - }, - "additionalProperties": false - }, - "engine": { - "type": "string", - "description": "AI engine to use for the campaign orchestrator", - "enum": ["copilot", "claude", "codex", "custom"] - }, - "bootstrap": { - "type": "object", - "description": "Initial work item creation strategy when discovery returns zero items", - "properties": { - "mode": { - "type": "string", - "description": "Bootstrap strategy: seeder-worker (dispatch scanner), project-todos (read from Project board), manual (skip bootstrap)", - "enum": ["seeder-worker", "project-todos", "manual"] - }, - "seeder-worker": { - "type": "object", - "description": "Configuration for dispatching a seeder/scanner worker. Only used when mode is 'seeder-worker'.", - "properties": { - "workflow-id": { - "type": "string", - "description": "Workflow identifier (basename without .md) to dispatch", - "minLength": 1 - }, - "payload": { - "type": "object", - "description": "JSON payload to send to the seeder worker" - }, - "max-items": { - "type": "integer", - "description": "Maximum number of work items the seeder should return (0 = worker default)", - "minimum": 0 - } - }, - "required": ["workflow-id", "payload"], - "additionalProperties": false - }, - "project-todos": { - "type": "object", - "description": "Configuration for reading from Project board's Todo column. Only used when mode is 'project-todos'.", - "properties": { - "status-field": { - "type": "string", - "description": "Name of the Project status field to filter on (default: 'Status')", - "minLength": 1 - }, - "todo-value": { - "type": "string", - "description": "Status value indicating a work item is ready to start (default: 'Todo')", - "minLength": 1 - }, - "max-items": { - "type": "integer", - "description": "Maximum number of Todo items to process per run (0 = governance default)", - "minimum": 0 - }, - "require-fields": { - "type": "array", - "description": "Project field names that must be populated for an item to be valid work", - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "additionalProperties": false - } - }, - "required": ["mode"], - "additionalProperties": false - }, - "workers": { - "type": "array", - "description": "Worker workflow metadata including capabilities, payload schemas, and output labeling contracts", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Workflow identifier (basename without .md)", - "minLength": 1 - }, - "name": { - "type": "string", - "description": "Human-readable worker name", - "minLength": 1 - }, - "description": { - "type": "string", - "description": "Description of what the worker does", - "minLength": 1 - }, - "capabilities": { - "type": "array", - "description": "Types of work this worker can perform", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - }, - "payload-schema": { - "type": "object", - "description": "Expected structure of the JSON payload", - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Field data type", - "enum": ["string", "number", "boolean", "array", "object"] - }, - "description": { - "type": "string", - "description": "Field description", - "minLength": 1 - }, - "required": { - "type": "boolean", - "description": "Whether this field is required" - }, - "example": { - "description": "Sample value for this field" - } - }, - "required": ["type", "description"], - "additionalProperties": false - } - }, - "output-labeling": { - "type": "object", - "description": "Labeling and metadata contract for worker outputs", - "properties": { - "labels": { - "type": "array", - "description": "Labels the worker applies to created items (in addition to the campaign's tracker-label)", - "items": { - "type": "string", - "minLength": 1 - } - }, - "key-in-title": { - "type": "boolean", - "description": "Whether worker includes deterministic key in title" - }, - "key-format": { - "type": "string", - "description": "Key format when key-in-title is true", - "minLength": 1 - }, - "metadata-fields": { - "type": "array", - "description": "Project fields the worker populates", - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "required": ["key-in-title"], - "additionalProperties": false - }, - "idempotency-strategy": { - "type": "string", - "description": "How the worker ensures idempotent execution", - "enum": ["branch-based", "pr-title-based", "issue-title-based", "cursor-based"] - }, - "priority": { - "type": "integer", - "description": "Worker priority for deterministic selection (higher = higher priority)", - "minimum": 0 - } - }, - "required": ["id", "capabilities", "payload-schema", "output-labeling", "idempotency-strategy"], - "additionalProperties": false - } - }, - "governance": { - "type": "object", - "description": "Lightweight pacing and opt-out policies for campaign coordinator workflows (launcher/orchestrator)", - "properties": { - "max-new-items-per-run": { - "type": "integer", - "description": "Maximum number of new items (issues/PRs) the launcher should add to the Project board per run", - "minimum": 0 - }, - "max-discovery-items-per-run": { - "type": "integer", - "description": "Maximum number of candidate issues/PRs to scan during discovery in a single run", - "minimum": 0 - }, - "max-discovery-pages-per-run": { - "type": "integer", - "description": "Maximum number of result pages to fetch during discovery in a single run", - "minimum": 0 - }, - "opt-out-labels": { - "type": "array", - "description": "Labels that opt an issue/PR out of campaign tracking", - "items": { - "type": "string", - "minLength": 1 - } - }, - "do-not-downgrade-done-items": { - "type": "boolean", - "description": "If true, prevent moving Project status backwards (e.g., Done -> In Progress) when issues/PRs are reopened" - }, - "max-project-updates-per-run": { - "type": "integer", - "description": "Maximum number of update-project safe-output operations per run for generated coordinator workflows", - "minimum": 0 - }, - "max-comments-per-run": { - "type": "integer", - "description": "Maximum number of add-comment safe-output operations per run for generated coordinator workflows", - "minimum": 0 - } - }, - "additionalProperties": false - } - }, - "required": ["id", "name", "project-url"], - "additionalProperties": false -} diff --git a/pkg/campaign/schemas/project_update_schema.json b/pkg/campaign/schemas/project_update_schema.json deleted file mode 100644 index 7f51c670d69..00000000000 --- a/pkg/campaign/schemas/project_update_schema.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "update-project payload", - "type": "object", - "$comment": "Governed contract for campaign orchestrator writes. See prompts/project_update_instructions.md (law) and prompts/project_update_contract_checklist.md (enforcement). This schema validates payload shape only; it cannot enforce read/write phase separation or equality to a specific campaign ID value.", - "additionalProperties": false, - "required": ["project", "campaign_id", "content_type", "content_number", "fields"], - "properties": { - "project": { - "type": "string", - "minLength": 1 - }, - "campaign_id": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "content_type": { - "type": "string", - "enum": ["issue", "pull_request"] - }, - "content_number": { - "type": "integer", - "minimum": 1 - }, - "fields": { - "$ref": "#/$defs/fields" - } - }, - "$defs": { - "isoDate": { - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - }, - "fields": { - "$comment": "Either a status-only update, or a full backfill payload. Normal status updates MUST NOT overwrite other fields with defaults.", - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/$defs/status" - } - }, - "required": ["status"], - "maxProperties": 1 - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "status": { - "$ref": "#/$defs/status" - }, - "campaign_id": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "worker_workflow": { - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "target_repo": { - "type": "string", - "pattern": "^[^/]+/[^/]+$" - }, - "priority": { - "type": "string", - "enum": ["High", "Medium", "Low"] - }, - "size": { - "type": "string", - "enum": ["Small", "Medium", "Large"] - }, - "start_date": { - "$ref": "#/$defs/isoDate" - }, - "end_date": { - "$ref": "#/$defs/isoDate" - } - }, - "required": ["status", "campaign_id", "worker_workflow", "target_repo", "priority", "size", "start_date", "end_date"] - } - ] - } - } -} diff --git a/pkg/campaign/scope.go b/pkg/campaign/scope.go deleted file mode 100644 index a8d0dd96e21..00000000000 --- a/pkg/campaign/scope.go +++ /dev/null @@ -1,100 +0,0 @@ -package campaign - -import ( - "fmt" - "regexp" - "strings" -) - -type ParsedScope struct { - Repos []string - Orgs []string -} - -var ( - repoSelectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$`) - orgNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) -) - -func parseScopeSelectors(selectors []string) (ParsedScope, []string) { - var parsed ParsedScope - var problems []string - - for _, raw := range selectors { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - problems = append(problems, "scope must not contain empty entries - remove empty strings from the list") - continue - } - - // org: - if strings.HasPrefix(trimmed, "org:") { - org := strings.TrimSpace(strings.TrimPrefix(trimmed, "org:")) - if org == "" { - problems = append(problems, "scope entry 'org:' must include an organization name - example: 'org:github'") - continue - } - if strings.Contains(org, "/") { - problems = append(problems, fmt.Sprintf("scope entry '%s' must be 'org:' (no slashes) - example: 'org:github'", trimmed)) - continue - } - if strings.Contains(org, "*") { - problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - example: 'org:github'", trimmed)) - continue - } - if !orgNamePattern.MatchString(org) { - problems = append(problems, fmt.Sprintf("scope entry '%s' has an invalid organization name - example: 'org:github'", trimmed)) - continue - } - parsed.Orgs = appendUniqueString(parsed.Orgs, org) - continue - } - - // Optional sugar: /* - if strings.HasSuffix(trimmed, "/*") && strings.Count(trimmed, "/") == 1 && !strings.Contains(trimmed, ":") { - org := strings.TrimSuffix(trimmed, "/*") - if org == "" { - problems = append(problems, fmt.Sprintf("scope entry '%s' must include an organization name - example: 'org:github'", trimmed)) - continue - } - if strings.Contains(org, "*") { - problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - example: 'org:github'", trimmed)) - continue - } - if !orgNamePattern.MatchString(org) { - problems = append(problems, fmt.Sprintf("scope entry '%s' has an invalid organization name - example: 'org:github'", trimmed)) - continue - } - parsed.Orgs = appendUniqueString(parsed.Orgs, org) - continue - } - - if strings.Contains(trimmed, "*") { - problems = append(problems, fmt.Sprintf("scope entry '%s' cannot contain wildcards - list repositories explicitly or use 'org:' for organization-wide scope", trimmed)) - continue - } - - if strings.Contains(trimmed, ":") { - problems = append(problems, fmt.Sprintf("scope entry '%s' is not recognized - valid formats: 'owner/repo' or 'org:'", trimmed)) - continue - } - - if repoSelectorPattern.MatchString(trimmed) { - parsed.Repos = appendUniqueString(parsed.Repos, trimmed) - continue - } - - problems = append(problems, fmt.Sprintf("scope entry '%s' must be 'owner/repo' or 'org:' - example: 'github/docs' or 'org:github'", trimmed)) - } - - return parsed, problems -} - -func appendUniqueString(values []string, value string) []string { - for _, v := range values { - if v == value { - return values - } - } - return append(values, value) -} diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go deleted file mode 100644 index d53481138c3..00000000000 --- a/pkg/campaign/spec.go +++ /dev/null @@ -1,333 +0,0 @@ -package campaign - -// CampaignSpec defines a first-class campaign configuration loaded from -// YAML frontmatter in Markdown files. -// -// Files are discovered from the local repository under: -// -// .github/workflows/*.campaign.md -// -// This provides a thin, declarative layer on top of existing agentic -// workflows and repo-memory conventions. -type CampaignSpec struct { - ID string `yaml:"id" json:"id" console:"header:ID"` - Name string `yaml:"name" json:"name" console:"header:Name,maxlen:30"` - Description string `yaml:"description,omitempty" json:"description,omitempty" console:"header:Description,omitempty,maxlen:60"` - - // ProjectURL points to the GitHub Project used as the primary campaign - // dashboard. - ProjectURL string `yaml:"project-url,omitempty" json:"project_url,omitempty" console:"header:Project URL,omitempty,maxlen:40"` - - // Version is an optional spec version string (for example: v1). - // When omitted, it defaults to v1 during validation. - Version string `yaml:"version,omitempty" json:"version,omitempty" console:"header:Version,omitempty"` - - // Workflows associates this campaign with one or more workflow IDs - // (basename of the Markdown file without .md). - Workflows []string `yaml:"workflows,omitempty" json:"workflows,omitempty" console:"header:Workflows,omitempty,maxlen:40"` - - // TrackerLabel is an optional label used to discover worker-created issues/PRs - // (for example: campaign:security-q1-2025). When set, the discovery precomputation - // step will search for items with this label. - TrackerLabel string `yaml:"tracker-label,omitempty" json:"tracker_label,omitempty" console:"header:Tracker Label,omitempty,maxlen:40"` - - // Scope defines the explicit set of repositories and organizations that this - // campaign is allowed to discover and operate on. - // - // Supported selectors: - // - "owner/repo" (specific repository) - // - "org:" (all repositories in an organization) - // - // When omitted, it defaults to the current repository where the campaign is defined. - Scope []string `yaml:"scope,omitempty" json:"scope,omitempty" console:"header:Scope,omitempty,maxlen:60"` - - // MemoryPaths documents where this campaign writes its repo-memory - // (for example: memory/campaigns/incident-response/**). - MemoryPaths []string `yaml:"memory-paths,omitempty" json:"memory_paths,omitempty" console:"header:Memory Paths,omitempty,maxlen:40"` - - // MetricsGlob is an optional glob (relative to the repository root) - // used to locate JSON metrics snapshots stored in the - // memory/campaigns branch. When set, `gh aw campaign status` will - // attempt to read the latest matching metrics file and surface a few - // key fields. - MetricsGlob string `yaml:"metrics-glob,omitempty" json:"metrics_glob,omitempty" console:"header:Metrics Glob,omitempty,maxlen:30"` - - // CursorGlob is an optional glob (relative to the repository root) - // used to locate a durable cursor/checkpoint file stored in the - // memory/campaigns branch. When set, generated coordinator workflows - // will be instructed to continue incremental discovery from this cursor - // and `gh aw campaign status` will surface its freshness. - CursorGlob string `yaml:"cursor-glob,omitempty" json:"cursor_glob,omitempty" console:"header:Cursor Glob,omitempty,maxlen:30"` - - // Owners lists the primary human owners for this campaign. - Owners []string `yaml:"owners,omitempty" json:"owners,omitempty" console:"header:Owners,omitempty,maxlen:30"` - - // ExecutiveSponsors lists executive stakeholders or sponsors who are - // accountable for the outcome of this campaign. - ExecutiveSponsors []string `yaml:"executive-sponsors,omitempty" json:"executive_sponsors,omitempty" console:"header:Executive Sponsors,omitempty,maxlen:30"` - - // RiskLevel is an optional free-form field (e.g. low/medium/high). - RiskLevel string `yaml:"risk-level,omitempty" json:"risk_level,omitempty" console:"header:Risk Level,omitempty"` - - // State describes the lifecycle stage of the campaign definition. - // Valid values are: planned, active, paused, completed, archived. - State string `yaml:"state,omitempty" json:"state,omitempty" console:"header:State,omitempty"` - - // Tags provide free-form categorization for reporting (for example: - // security, modernization, rollout). - Tags []string `yaml:"tags,omitempty" json:"tags,omitempty" console:"header:Tags,omitempty,maxlen:30"` - - // AllowedSafeOutputs documents which safe-outputs operations this - // campaign is expected to use (for example: create-issue, - // create-pull-request). This is currently informational but can be - // enforced by validation in the future. - AllowedSafeOutputs []string `yaml:"allowed-safe-outputs,omitempty" json:"allowed_safe_outputs,omitempty" console:"header:Allowed Safe Outputs,omitempty,maxlen:30"` - - // Governance configures lightweight pacing and opt-out policies for campaign - // orchestrator workflows. These guardrails are primarily enforced through - // generated prompts and safe-output maxima. - Governance *CampaignGovernancePolicy `yaml:"governance,omitempty" json:"governance,omitempty"` - - // ApprovalPolicy describes high-level approval expectations for this - // campaign (for example: number of approvals and required roles). - ApprovalPolicy *CampaignApprovalPolicy `yaml:"approval-policy,omitempty" json:"approval-policy,omitempty"` - - // Engine specifies the AI engine to use for the campaign orchestrator. - // Valid values: copilot, claude, codex, custom. - // Default: copilot (when not specified). - Engine string `yaml:"engine,omitempty" json:"engine,omitempty" console:"header:Engine,omitempty"` - - // Bootstrap defines the initial work item creation strategy when discovery - // returns zero items. This provides a way to seed the campaign with initial - // work when there are no worker-created items to discover. - Bootstrap *CampaignBootstrapConfig `yaml:"bootstrap,omitempty" json:"bootstrap,omitempty"` - - // Workers defines metadata for worker workflows, including their capabilities, - // payload schemas, and output labeling contracts. This enables deterministic - // worker selection and ensures worker outputs are discoverable. - Workers []WorkerMetadata `yaml:"workers,omitempty" json:"workers,omitempty"` - - // ConfigPath is populated at load time with the relative path of - // the YAML file on disk, to help users locate definitions. - ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path,maxlen:60"` -} - -// CampaignGovernancePolicy captures lightweight pacing and opt-out policies. -// This is intentionally scoped to what gh-aw can apply safely and consistently -// via prompts and safe-output job limits. -type CampaignGovernancePolicy struct { - // MaxNewItemsPerRun caps how many new items (issues/PRs) the launcher should - // add to the Project board per run. 0 means "use defaults". - MaxNewItemsPerRun int `yaml:"max-new-items-per-run,omitempty" json:"max_new_items_per_run,omitempty"` - - // MaxDiscoveryItemsPerRun caps how many candidate issues/PRs the launcher - // and orchestrator may scan during discovery in a single run. - // 0 means "use defaults". - MaxDiscoveryItemsPerRun int `yaml:"max-discovery-items-per-run,omitempty" json:"max_discovery_items_per_run,omitempty"` - - // MaxDiscoveryPagesPerRun caps how many pages of results the launcher and - // orchestrator may fetch in a single run. - // 0 means "use defaults". - MaxDiscoveryPagesPerRun int `yaml:"max-discovery-pages-per-run,omitempty" json:"max_discovery_pages_per_run,omitempty"` - - // OptOutLabels is a list of labels that opt an issue/PR out of campaign - // tracking. Items with any of these labels should be ignored by launcher/ - // orchestrator. - OptOutLabels []string `yaml:"opt-out-labels,omitempty" json:"opt_out_labels,omitempty"` - - // DoNotDowngradeDoneItems prevents moving Project status backwards (e.g. - // Done -> In Progress) if the underlying issue/PR is reopened. - DoNotDowngradeDoneItems *bool `yaml:"do-not-downgrade-done-items,omitempty" json:"do_not_downgrade_done_items,omitempty"` - - // MaxProjectUpdatesPerRun controls the update-project safe-output maximum - // for generated coordinator workflows. 0 means "use defaults". - MaxProjectUpdatesPerRun int `yaml:"max-project-updates-per-run,omitempty" json:"max_project_updates_per_run,omitempty"` - - // MaxCommentsPerRun controls the add-comment safe-output maximum for - // generated coordinator workflows. 0 means "use defaults". - MaxCommentsPerRun int `yaml:"max-comments-per-run,omitempty" json:"max_comments_per_run,omitempty"` -} - -// CampaignApprovalPolicy captures basic approval expectations for a -// campaign. It is intentionally lightweight and advisory; enforcement -// is left to workflows and organizational process. -type CampaignApprovalPolicy struct { - RequiredApprovals int `yaml:"required-approvals,omitempty" json:"required-approvals,omitempty"` - RequiredRoles []string `yaml:"required-roles,omitempty" json:"required-roles,omitempty"` - ChangeControl bool `yaml:"change-control,omitempty" json:"change-control,omitempty"` -} - -// CampaignRuntimeStatus represents the live status of a campaign, including -// compiled workflow state and optional metrics/cursor info. -type CampaignRuntimeStatus struct { - ID string `json:"id" console:"header:ID"` - Name string `json:"name" console:"header:Name"` - Workflows []string `json:"workflows,omitempty" console:"header:Workflows,omitempty"` - Compiled string `json:"compiled" console:"header:Compiled"` - - // Optional metrics from repo-memory (when MetricsGlob is set and a - // matching JSON snapshot is found on the memory/campaigns branch). - MetricsTasksTotal int `json:"metrics_tasks_total,omitempty" console:"header:Tasks Total,omitempty"` - MetricsTasksCompleted int `json:"metrics_tasks_completed,omitempty" console:"header:Tasks Completed,omitempty"` - MetricsVelocityPerDay float64 `json:"metrics_velocity_per_day,omitempty" console:"header:Velocity/Day,omitempty"` - MetricsEstimatedCompletion string `json:"metrics_estimated_completion,omitempty" console:"header:ETA,omitempty"` - - // Optional durable cursor/checkpoint info from repo-memory. - CursorPath string `json:"cursor_path,omitempty" console:"header:Cursor Path,omitempty,maxlen:40"` - CursorUpdatedAt string `json:"cursor_updated_at,omitempty" console:"header:Cursor Updated,omitempty,maxlen:30"` -} - -// CampaignMetricsSnapshot describes the JSON structure used by campaign -// metrics snapshots written into the memory/campaigns branch. -// -// This mirrors the example in the campaigns guide: -// -// { -// "date": "2025-01-16", -// "campaign_id": "security-q1-2025", -// "tasks_total": 200, -// "tasks_completed": 15, -// "tasks_in_progress": 30, -// "tasks_blocked": 5, -// "velocity_per_day": 7.5, -// "estimated_completion": "2025-02-12" -// } -type CampaignMetricsSnapshot struct { - Date string `json:"date"` // Required: YYYY-MM-DD format - CampaignID string `json:"campaign_id"` // Required: campaign identifier - TasksTotal int `json:"tasks_total"` // Required: total task count (>= 0) - TasksCompleted int `json:"tasks_completed"` // Required: completed task count (>= 0) - TasksInProgress int `json:"tasks_in_progress,omitempty"` - TasksBlocked int `json:"tasks_blocked,omitempty"` - VelocityPerDay float64 `json:"velocity_per_day,omitempty"` - EstimatedCompletion string `json:"estimated_completion,omitempty"` -} - -// CampaignValidationResult represents the result of validating a campaign spec. -type CampaignValidationResult struct { - ID string `json:"id" console:"header:ID"` - Name string `json:"name" console:"header:Name"` - ConfigPath string `json:"config_path" console:"header:Config Path"` - Problems []string `json:"problems,omitempty" console:"header:Problems,omitempty"` -} - -// CampaignBootstrapConfig defines how to create initial work items when discovery -// returns zero items. This provides multiple strategies for bootstrapping a campaign. -type CampaignBootstrapConfig struct { - // Mode determines the bootstrap strategy when no work items are discovered. - // Valid values: - // - "seeder-worker": Dispatch a seeder/scanner worker workflow to discover work - // - "project-todos": Read items from Project board's "Todo" column - // - "manual": Skip bootstrap, wait for manual work item creation - Mode string `yaml:"mode" json:"mode"` - - // SeederWorker specifies which worker workflow to dispatch for initial scanning. - // Only used when Mode is "seeder-worker". - SeederWorker *SeederWorkerConfig `yaml:"seeder-worker,omitempty" json:"seeder_worker,omitempty"` - - // ProjectTodos specifies configuration for reading from Project board. - // Only used when Mode is "project-todos". - ProjectTodos *ProjectTodosConfig `yaml:"project-todos,omitempty" json:"project_todos,omitempty"` -} - -// SeederWorkerConfig defines configuration for dispatching a seeder/scanner worker. -type SeederWorkerConfig struct { - // WorkflowID is the workflow identifier (basename without .md) to dispatch. - WorkflowID string `yaml:"workflow-id" json:"workflow_id"` - - // Payload is the JSON payload to send to the seeder worker. - // This should conform to the worker's payload schema. - Payload map[string]any `yaml:"payload" json:"payload"` - - // MaxItems caps the number of work items the seeder should return. - // 0 means use the worker's default. - MaxItems int `yaml:"max-items,omitempty" json:"max_items,omitempty"` -} - -// ProjectTodosConfig defines configuration for reading from a Project board's Todo column. -type ProjectTodosConfig struct { - // StatusField is the name of the Project status field to filter on. - // Defaults to "Status". - StatusField string `yaml:"status-field,omitempty" json:"status_field,omitempty"` - - // TodoValue is the status value that indicates a work item is ready to start. - // Defaults to "Todo". - TodoValue string `yaml:"todo-value,omitempty" json:"todo_value,omitempty"` - - // MaxItems caps the number of Todo items to process per orchestrator run. - // 0 means use the governance default. - MaxItems int `yaml:"max-items,omitempty" json:"max_items,omitempty"` - - // RequireFields lists Project field names that must be populated for an - // item to be considered valid work. Empty items are skipped. - RequireFields []string `yaml:"require-fields,omitempty" json:"require_fields,omitempty"` -} - -// WorkerMetadata defines metadata for a worker workflow, including its capabilities, -// payload schema, and output labeling contract. -type WorkerMetadata struct { - // ID is the workflow identifier (basename without .md). - ID string `yaml:"id" json:"id"` - - // Name is a human-readable name for the worker. - Name string `yaml:"name,omitempty" json:"name,omitempty"` - - // Description describes what the worker does. - Description string `yaml:"description,omitempty" json:"description,omitempty"` - - // Capabilities lists what types of work this worker can perform. - // Examples: "scan-security-alerts", "fix-dependencies", "update-docs" - Capabilities []string `yaml:"capabilities" json:"capabilities"` - - // PayloadSchema defines the expected structure of the JSON payload. - // This is a map of field names to field types/descriptions. - PayloadSchema map[string]WorkerPayloadField `yaml:"payload-schema" json:"payload_schema"` - - // OutputLabeling defines the labeling contract for worker outputs. - // This guarantees outputs are discoverable and attributable. - OutputLabeling WorkerOutputLabeling `yaml:"output-labeling" json:"output_labeling"` - - // IdempotencyStrategy describes how the worker ensures idempotent execution. - // Valid values: "branch-based", "pr-title-based", "issue-title-based", "cursor-based" - IdempotencyStrategy string `yaml:"idempotency-strategy" json:"idempotency_strategy"` - - // Priority indicates worker priority for deterministic selection when multiple - // workers can handle the same work item. Higher numbers = higher priority. - Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` -} - -// WorkerPayloadField defines a single field in the worker payload schema. -type WorkerPayloadField struct { - // Type is the field's data type: "string", "number", "boolean", "array", "object" - Type string `yaml:"type" json:"type"` - - // Description explains what this field contains and how it's used. - Description string `yaml:"description" json:"description"` - - // Required indicates whether this field must be present in the payload. - Required bool `yaml:"required,omitempty" json:"required,omitempty"` - - // Example provides a sample value for this field. - Example any `yaml:"example,omitempty" json:"example,omitempty"` -} - -// WorkerOutputLabeling defines the labeling and metadata contract for worker outputs. -type WorkerOutputLabeling struct { - // Labels are the labels the worker applies to created items. - // The campaign's tracker-label is automatically applied by the worker - // in addition to these labels. - // Examples: "security", "automated", "dependencies" - Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` - - // KeyInTitle indicates whether the worker includes a deterministic key in - // the title of created items (format: "[{key}] {title}"). - KeyInTitle bool `yaml:"key-in-title" json:"key_in_title"` - - // KeyFormat describes the key format when KeyInTitle is true. - // Example: "campaign-{campaign_id}-{repository}-{work_item_id}" - KeyFormat string `yaml:"key-format,omitempty" json:"key_format,omitempty"` - - // MetadataFields lists Project fields the worker populates with work metadata. - // Examples: "Campaign Id", "Worker Workflow", "Priority" - MetadataFields []string `yaml:"metadata-fields,omitempty" json:"metadata_fields,omitempty"` -} diff --git a/pkg/campaign/status.go b/pkg/campaign/status.go deleted file mode 100644 index 528b690d3b6..00000000000 --- a/pkg/campaign/status.go +++ /dev/null @@ -1,226 +0,0 @@ -package campaign - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path" - "path/filepath" - "strings" - - "github.com/githubnext/gh-aw/pkg/logger" -) - -var statusLog = logger.New("campaign:status") - -// ComputeCompiledState inspects the compiled state of all -// workflows referenced by a campaign. It returns: -// -// "Yes" - all referenced workflows exist and are compiled & up-to-date -// "No" - at least one workflow exists but is missing a lock file or is stale -// "Missing workflow" - at least one referenced workflow markdown file does not exist -// "N/A" - campaign does not reference any workflows -func ComputeCompiledState(spec CampaignSpec, workflowsDir string) string { - statusLog.Printf("Computing compiled state for campaign '%s' with %d workflows", spec.ID, len(spec.Workflows)) - - if len(spec.Workflows) == 0 { - return "N/A" - } - - compiledAll := true - missingAny := false - - for _, wf := range spec.Workflows { - mdPath := filepath.Join(workflowsDir, wf+".md") - lockPath := filepath.Join(workflowsDir, wf+".lock.yml") - - mdInfo, err := os.Stat(mdPath) - if err != nil { - statusLog.Printf("Workflow markdown not found for campaign '%s': %s", spec.ID, mdPath) - missingAny = true - compiledAll = false - continue - } - - lockInfo, err := os.Stat(lockPath) - if err != nil { - statusLog.Printf("Lock file not found for workflow '%s' in campaign '%s': %s", wf, spec.ID, lockPath) - compiledAll = false - continue - } - - if mdInfo.ModTime().After(lockInfo.ModTime()) { - statusLog.Printf("Lock file out of date for workflow '%s' in campaign '%s'", wf, spec.ID) - compiledAll = false - } - } - - if missingAny { - return "Missing workflow" - } - if compiledAll { - return "Yes" - } - return "No" -} - -// FetchMetricsFromRepoMemory attempts to load the latest JSON -// metrics snapshot matching the provided glob from the -// memory/campaigns branch. It is best-effort: errors are logged and -// treated as "no metrics" rather than failing the command. -func FetchMetricsFromRepoMemory(metricsGlob string) (*CampaignMetricsSnapshot, error) { - statusLog.Printf("Fetching metrics from repo memory with glob: %s", metricsGlob) - - if strings.TrimSpace(metricsGlob) == "" { - return nil, nil - } - - // List all files in the memory/campaigns branch - cmd := exec.Command("git", "ls-tree", "-r", "--name-only", "memory/campaigns") - output, err := cmd.Output() - if err != nil { - statusLog.Printf("Unable to list repo-memory branch for metrics (memory/campaigns): %v", err) - return nil, nil - } - - scanner := bufio.NewScanner(bytes.NewReader(output)) - var matches []string - for scanner.Scan() { - pathStr := strings.TrimSpace(scanner.Text()) - if pathStr == "" { - continue - } - matched, err := path.Match(metricsGlob, pathStr) - if err != nil { - statusLog.Printf("Invalid metrics_glob '%s': %v", metricsGlob, err) - return nil, nil - } - if matched { - matches = append(matches, pathStr) - } - } - - if len(matches) == 0 { - return nil, nil - } - - // Pick the lexicographically last match as the "latest" snapshot. - latest := matches[0] - for _, m := range matches[1:] { - if m > latest { - latest = m - } - } - - showArg := fmt.Sprintf("memory/campaigns:%s", latest) - showCmd := exec.Command("git", "show", showArg) - fileData, err := showCmd.Output() - if err != nil { - statusLog.Printf("Failed to read metrics file '%s' from memory/campaigns: %v", latest, err) - return nil, nil - } - - var snapshot CampaignMetricsSnapshot - if err := json.Unmarshal(fileData, &snapshot); err != nil { - statusLog.Printf("Failed to decode metrics JSON from '%s': %v", latest, err) - return nil, nil - } - - return &snapshot, nil -} - -// FetchCursorFreshnessFromRepoMemory finds the latest cursor/checkpoint file -// matching cursorGlob in the memory/campaigns branch and returns the matched -// path along with a best-effort freshness timestamp derived from git history. -// -// Errors are treated as "no cursor" rather than failing the command. -func FetchCursorFreshnessFromRepoMemory(cursorGlob string) (cursorPath string, cursorUpdatedAt string) { - statusLog.Printf("Fetching cursor freshness from repo memory with glob: %s", cursorGlob) - - if strings.TrimSpace(cursorGlob) == "" { - return "", "" - } - - cmd := exec.Command("git", "ls-tree", "-r", "--name-only", "memory/campaigns") - output, err := cmd.Output() - if err != nil { - statusLog.Printf("Unable to list repo-memory branch for cursor (memory/campaigns): %v", err) - return "", "" - } - - scanner := bufio.NewScanner(bytes.NewReader(output)) - var matches []string - for scanner.Scan() { - pathStr := strings.TrimSpace(scanner.Text()) - if pathStr == "" { - continue - } - matched, err := path.Match(cursorGlob, pathStr) - if err != nil { - statusLog.Printf("Invalid cursor_glob '%s': %v", cursorGlob, err) - return "", "" - } - if matched { - matches = append(matches, pathStr) - } - } - - if len(matches) == 0 { - return "", "" - } - - latest := matches[0] - for _, m := range matches[1:] { - if m > latest { - latest = m - } - } - - // Best-effort: use git log to get the last commit time for this path - // on the memory/campaigns branch. - logCmd := exec.Command("git", "log", "-1", "--format=%cI", "memory/campaigns", "--", latest) - logOut, err := logCmd.Output() - if err != nil { - statusLog.Printf("Failed to read cursor freshness for '%s' from memory/campaigns: %v", latest, err) - return latest, "" - } - - return latest, strings.TrimSpace(string(logOut)) -} - -// BuildRuntimeStatus builds a CampaignRuntimeStatus for a single campaign spec. -func BuildRuntimeStatus(spec CampaignSpec, workflowsDir string) CampaignRuntimeStatus { - compiled := ComputeCompiledState(spec, workflowsDir) - - cursorPath, cursorUpdatedAt := FetchCursorFreshnessFromRepoMemory(spec.CursorGlob) - - var metricsTasksTotal, metricsTasksCompleted int - var metricsVelocity float64 - var metricsETA string - if strings.TrimSpace(spec.MetricsGlob) != "" { - if snapshot, err := FetchMetricsFromRepoMemory(spec.MetricsGlob); err != nil { - statusLog.Printf("Failed to fetch metrics for campaign '%s': %v", spec.ID, err) - } else if snapshot != nil { - metricsTasksTotal = snapshot.TasksTotal - metricsTasksCompleted = snapshot.TasksCompleted - metricsVelocity = snapshot.VelocityPerDay - metricsETA = snapshot.EstimatedCompletion - } - } - - return CampaignRuntimeStatus{ - ID: spec.ID, - Name: spec.Name, - Workflows: spec.Workflows, - Compiled: compiled, - MetricsTasksTotal: metricsTasksTotal, - MetricsTasksCompleted: metricsTasksCompleted, - MetricsVelocityPerDay: metricsVelocity, - MetricsEstimatedCompletion: metricsETA, - CursorPath: cursorPath, - CursorUpdatedAt: cursorUpdatedAt, - } -} diff --git a/pkg/campaign/template.go b/pkg/campaign/template.go deleted file mode 100644 index 1f21b585ea9..00000000000 --- a/pkg/campaign/template.go +++ /dev/null @@ -1,200 +0,0 @@ -package campaign - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "text/template" - - "github.com/githubnext/gh-aw/pkg/logger" -) - -var templateLog = logger.New("campaign:template") - -// findGitRoot finds the root directory of the git repository -func findGitRoot() (string, error) { - cmd := exec.Command("git", "rev-parse", "--show-toplevel") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("not in a git repository or git command failed: %w", err) - } - return strings.TrimSpace(string(output)), nil -} - -// loadTemplate loads a template file from .github/aw/ directory -func loadTemplate(filename string) (string, error) { - gitRoot, err := findGitRoot() - if err != nil { - return "", fmt.Errorf("failed to find git root: %w", err) - } - - templatePath := filepath.Join(gitRoot, ".github", "aw", filename) - templateLog.Printf("Loading template from: %s", templatePath) - - content, err := os.ReadFile(templatePath) - if err != nil { - return "", fmt.Errorf("failed to read template file %s: %w", filename, err) - } - - return string(content), nil -} - -// CampaignPromptData holds data for rendering campaign orchestrator prompts. -type CampaignPromptData struct { - // CampaignID is the unique identifier for this campaign. - CampaignID string - - // CampaignName is the human-readable name of this campaign. - CampaignName string - - // ProjectURL is the GitHub Project URL - ProjectURL string - - // CursorGlob is a glob for locating the durable cursor/checkpoint file in repo-memory. - CursorGlob string - - // MetricsGlob is a glob for locating the metrics snapshot directory in repo-memory. - MetricsGlob string - - // MaxDiscoveryItemsPerRun caps how many candidate items may be scanned during discovery. - MaxDiscoveryItemsPerRun int - - // MaxDiscoveryPagesPerRun caps how many pages may be fetched during discovery. - MaxDiscoveryPagesPerRun int - - // MaxProjectUpdatesPerRun caps how many project update writes may happen per run. - MaxProjectUpdatesPerRun int - - // MaxProjectCommentsPerRun caps how many comments may be written per run. - MaxProjectCommentsPerRun int - - // Workflows is the list of worker workflow IDs associated with this campaign. - Workflows []string - - // Bootstrap configuration - BootstrapMode string - SeederWorkerID string - SeederPayload string - SeederMaxItems int - StatusField string - TodoValue string - TodoMaxItems int - RequireFields []string - WorkerMetadata []WorkerMetadata -} - -// renderTemplate renders a template string with the given data. -func renderTemplate(tmplStr string, data CampaignPromptData) (string, error) { - // Create custom template functions for Handlebars-style conditionals - funcMap := template.FuncMap{ - "if": func(condition bool) bool { - return condition - }, - "add1": func(i int) int { - return i + 1 - }, - } - - // Parse template with custom delimiters to match Handlebars style - tmpl, err := template.New("prompt"). - Delims("{{", "}}"). - Funcs(funcMap). - Parse(tmplStr) - if err != nil { - templateLog.Printf("Failed to parse template: %v", err) - return "", err - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - templateLog.Printf("Failed to execute template: %v", err) - return "", err - } - - return buf.String(), nil -} - -// RenderWorkflowExecution renders the workflow execution instructions with the given data. -func RenderWorkflowExecution(data CampaignPromptData) string { - tmplStr, err := loadTemplate("execute-agentic-campaign-workflow.md") - if err != nil { - templateLog.Printf("Failed to load workflow execution template: %v", err) - return "" - } - - result, err := renderTemplate(tmplStr, data) - if err != nil { - templateLog.Printf("Failed to render workflow execution instructions: %v", err) - return "" - } - return strings.TrimSpace(result) -} - -// RenderOrchestratorInstructions renders the orchestrator instructions with the given data. -func RenderOrchestratorInstructions(data CampaignPromptData) string { - tmplStr, err := loadTemplate("orchestrate-agentic-campaign.md") - if err != nil { - templateLog.Printf("Failed to load orchestrator instructions template: %v", err) - // Fallback to a simple version if template loading fails - return "Each time this orchestrator runs, generate a concise status report for this campaign." - } - - result, err := renderTemplate(tmplStr, data) - if err != nil { - templateLog.Printf("Failed to render orchestrator instructions: %v", err) - // Fallback to a simple version if template rendering fails - return "Each time this orchestrator runs, generate a concise status report for this campaign." - } - return strings.TrimSpace(result) -} - -// RenderProjectUpdateInstructions renders the project update instructions with the given data -func RenderProjectUpdateInstructions(data CampaignPromptData) string { - tmplStr, err := loadTemplate("update-agentic-campaign-project.md") - if err != nil { - templateLog.Printf("Failed to load project update instructions template: %v", err) - return "" - } - - result, err := renderTemplate(tmplStr, data) - if err != nil { - templateLog.Printf("Failed to render project update instructions: %v", err) - return "" - } - return strings.TrimSpace(result) -} - -// RenderClosingInstructions renders the closing instructions -func RenderClosingInstructions() string { - tmplStr, err := loadTemplate("close-agentic-campaign.md") - if err != nil { - templateLog.Printf("Failed to load closing instructions template: %v", err) - return "Use these details to coordinate workers and track progress." - } - - result, err := renderTemplate(tmplStr, CampaignPromptData{}) - if err != nil { - templateLog.Printf("Failed to render closing instructions: %v", err) - return "Use these details to coordinate workers and track progress." - } - return strings.TrimSpace(result) -} - -// RenderBootstrapInstructions renders the bootstrap instructions with the given data. -func RenderBootstrapInstructions(data CampaignPromptData) string { - tmplStr, err := loadTemplate("bootstrap-agentic-campaign.md") - if err != nil { - templateLog.Printf("Failed to load bootstrap instructions template: %v", err) - return "" - } - - result, err := renderTemplate(tmplStr, data) - if err != nil { - templateLog.Printf("Failed to render bootstrap instructions: %v", err) - return "" - } - return strings.TrimSpace(result) -} diff --git a/pkg/campaign/template_test.go b/pkg/campaign/template_test.go deleted file mode 100644 index 9ece54978da..00000000000 --- a/pkg/campaign/template_test.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "strings" - "testing" -) - -func TestRenderOrchestratorInstructions(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - tests := []struct { - name string - data CampaignPromptData - shouldContain []string - }{ - { - name: "system-agnostic rules", - data: CampaignPromptData{}, - shouldContain: []string{ - "Orchestrator Instructions", - "Traffic and Rate Limits (Required)", - "Prefer incremental discovery", - "strict pagination budgets", - "durable cursor/checkpoint", - "On throttling", - "Workers are immutable and campaign-agnostic", - "Step 1", - "Read State", - "Step 2", - "Make Decisions", - "Step 3", - "Dispatch Workers", - "Step 4", - "Report", - }, - }, - { - name: "explicit state management", - data: CampaignPromptData{}, - shouldContain: []string{ - "Parse discovered items from the manifest", - "Discovery cursor is maintained automatically", - "Determine desired `status`", - }, - }, - { - name: "separation of concerns", - data: CampaignPromptData{}, - shouldContain: []string{ - "Reads and writes are separate steps", - "never interleave", - }, - }, - { - name: "date field calculation in Step 2", - data: CampaignPromptData{}, - shouldContain: []string{ - "Calculate required date fields", - "start_date", - "end_date", - "format `created_at` as `YYYY-MM-DD`", - "format `closed_at`/`merged_at` as `YYYY-MM-DD`", - "today's date", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := RenderOrchestratorInstructions(tt.data) - - for _, expected := range tt.shouldContain { - if !strings.Contains(result, expected) { - t.Errorf("Expected result to contain %q, but it didn't. Result: %s", expected, result) - } - } - }) - } - }) -} - -func TestRenderProjectUpdateInstructions(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - tests := []struct { - name string - data CampaignPromptData - shouldContain []string - shouldBeEmpty bool - }{ - { - name: "with project URL", - data: CampaignPromptData{ - ProjectURL: "https://github.com/orgs/test/projects/1", - }, - shouldContain: []string{ - "Project Update Instructions (Authoritative Write Contract)", - "update-project", - "https://github.com/orgs/test/projects/1", - "Hard Requirements", - "Required Project Fields", - "Read-Write Separation", - "Adding an Issue or PR", - "Updating an Existing Item", - "Idempotency Rules", - }, - shouldBeEmpty: false, - }, - { - name: "with project URL and campaign ID", - data: CampaignPromptData{ - ProjectURL: "https://github.com/orgs/test/projects/1", - CampaignID: "my-campaign", - }, - shouldContain: []string{ - "Project Update Instructions (Authoritative Write Contract)", - "update-project", - "https://github.com/orgs/test/projects/1", - "campaign_id", - "campaign_id:", - "my-campaign", - }, - shouldBeEmpty: false, - }, - { - name: "without project URL", - data: CampaignPromptData{ - ProjectURL: "", - }, - shouldContain: []string{}, - shouldBeEmpty: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := RenderProjectUpdateInstructions(tt.data) - - if tt.shouldBeEmpty && result != "" { - t.Errorf("Expected empty result, but got: %s", result) - } - - for _, expected := range tt.shouldContain { - if !strings.Contains(result, expected) { - t.Errorf("Expected result to contain %q, but it didn't. Result: %s", expected, result) - } - } - }) - } - }) -} - -func TestRenderClosingInstructions(t *testing.T) { - withTempGitRepoWithInstalledCampaignPrompts(t, func(_ string) { - result := RenderClosingInstructions() - - expectedPhrases := []string{ - "Closing Instructions (Highest Priority)", - "Execute all four steps in strict order", - "Read State (no writes)", - "Make Decisions (no writes)", - "Apply Updates (writes)", - "Report", - "Workers are immutable and campaign-agnostic", - "GitHub Project board is the single source of truth", - } - - for _, expected := range expectedPhrases { - if !strings.Contains(result, expected) { - t.Errorf("Expected result to contain %q, but it didn't. Result: %s", expected, result) - } - } - }) -} diff --git a/pkg/campaign/test_helpers_test.go b/pkg/campaign/test_helpers_test.go deleted file mode 100644 index a8b0b800cf2..00000000000 --- a/pkg/campaign/test_helpers_test.go +++ /dev/null @@ -1,64 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "testing" -) - -func withTempGitRepoWithInstalledCampaignPrompts(t *testing.T, run func(repoRoot string)) { - t.Helper() - - originalDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get current directory: %v", err) - } - - repoRoot := t.TempDir() - - if err := os.MkdirAll(filepath.Join(repoRoot, ".github", "aw"), 0o755); err != nil { - t.Fatalf("failed to create .github/aw directory: %v", err) - } - - srcTemplatesDir := filepath.Clean(filepath.Join(originalDir, "..", "cli", "templates")) - installed := map[string]string{ - "generate-agentic-campaign.md": "generate-agentic-campaign.md", - "orchestrate-agentic-campaign.md": "orchestrate-agentic-campaign.md", - "execute-agentic-campaign-workflow.md": "execute-agentic-campaign-workflow.md", - "update-agentic-campaign-project.md": "update-agentic-campaign-project.md", - "close-agentic-campaign.md": "close-agentic-campaign.md", - } - - for srcName, dstName := range installed { - srcPath := filepath.Join(srcTemplatesDir, srcName) - dstPath := filepath.Join(repoRoot, ".github", "aw", dstName) - content, err := os.ReadFile(srcPath) - if err != nil { - t.Fatalf("failed to read template %s: %v", srcPath, err) - } - if err := os.WriteFile(dstPath, content, 0o644); err != nil { - t.Fatalf("failed to write installed prompt %s: %v", dstPath, err) - } - } - - cmd := exec.Command("git", "init") - cmd.Dir = repoRoot - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to init git repo: %v (output: %s)", err, string(out)) - } - - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("failed to chdir to temp repo: %v", err) - } - t.Cleanup(func() { - if err := os.Chdir(originalDir); err != nil { - panic(fmt.Sprintf("failed to restore working dir: %v", err)) - } - }) - - run(repoRoot) -} diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go deleted file mode 100644 index 146031a75c2..00000000000 --- a/pkg/campaign/validation.go +++ /dev/null @@ -1,416 +0,0 @@ -package campaign - -import ( - "embed" - "encoding/json" - "fmt" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/parser" - "github.com/goccy/go-yaml" - "github.com/santhosh-tekuri/jsonschema/v6" -) - -var validationLog = logger.New("campaign:validation") - -//go:embed schemas/campaign_spec_schema.json -var campaignSpecSchemaFS embed.FS - -// Cached compiled schema to avoid recompiling on every validation -var ( - compiledSchemaOnce sync.Once - compiledSchema *jsonschema.Schema - schemaCompileError error -) - -// ValidateSpec performs lightweight semantic validation of a -// single CampaignSpec and returns a slice of human-readable problems. -// -// It uses JSON schema validation first, then adds additional semantic checks. -func ValidateSpec(spec *CampaignSpec) []string { - validationLog.Printf("Validating campaign spec: id=%s", spec.ID) - var problems []string - - // First, validate against JSON schema - schemaProblems := ValidateSpecWithSchema(spec) - problems = append(problems, schemaProblems...) - if len(schemaProblems) > 0 { - validationLog.Printf("Schema validation found %d problems for campaign '%s'", len(schemaProblems), spec.ID) - } - - // Additional semantic validation beyond schema - trimmedID := strings.TrimSpace(spec.ID) - if trimmedID == "" { - problems = append(problems, "id is required and must be non-empty - example: 'security-q1-2025'") - } else { - // Enforce a simple, URL-safe pattern for IDs - for _, ch := range trimmedID { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' { - continue - } - problems = append(problems, fmt.Sprintf("id must use only lowercase letters, digits, and hyphens - got '%s', try '%s'", trimmedID, suggestValidID(trimmedID))) - break - } - } - - if strings.TrimSpace(spec.Name) == "" { - problems = append(problems, "name should be provided (falls back to id, but explicit names are recommended) - example: 'Security Q1 2025'") - } - - if len(spec.Workflows) == 0 { - problems = append(problems, "workflows should list at least one workflow implementing this campaign - example: ['vulnerability-scanner', 'dependency-updater']") - } - - // Validate tracker-label format if provided - if strings.TrimSpace(spec.TrackerLabel) != "" { - trimmedLabel := strings.TrimSpace(spec.TrackerLabel) - // Tracker labels should follow the pattern "z_campaign_" - expectedLabel := fmt.Sprintf("z_campaign_%s", strings.ToLower(strings.ReplaceAll(spec.ID, "_", "-"))) - if !strings.HasPrefix(trimmedLabel, "z_campaign_") { - problems = append(problems, fmt.Sprintf("tracker-label should start with 'z_campaign_' prefix - got '%s', recommended: '%s'", trimmedLabel, expectedLabel)) - } - // Check for invalid characters in labels (GitHub label restrictions) - if strings.Contains(trimmedLabel, " ") { - problems = append(problems, fmt.Sprintf("tracker-label cannot contain spaces - got '%s', try replacing spaces with hyphens", trimmedLabel)) - } - } - - _, scopeProblems := parseScopeSelectors(spec.Scope) - problems = append(problems, scopeProblems...) - - // Note: scope validation removed - loader defaults to current repository when omitted - // See pkg/campaign/loader.go lines 115-124 - - if strings.TrimSpace(spec.ProjectURL) == "" { - problems = append(problems, "project-url is required (GitHub Project URL used as the campaign dashboard) - example: 'https://github.com/orgs/myorg/projects/1'") - } else { - parsed, err := url.Parse(strings.TrimSpace(spec.ProjectURL)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - problems = append(problems, "project-url must be a valid absolute URL - example: 'https://github.com/orgs/myorg/projects/1' or 'https://github.com/users/username/projects/1'") - } else if !strings.Contains(parsed.Path, "/projects/") { - problems = append(problems, "project-url must point to a GitHub Project (URL path should include '/projects/') - you may have provided a repository URL instead") - } - } - - // Normalize and validate version/state when present. - if strings.TrimSpace(spec.Version) == "" { - // Default version for v1 specs when omitted. - spec.Version = "v1" - } - - if spec.State != "" { - switch spec.State { - case "planned", "active", "paused", "completed", "archived": - // valid - default: - problems = append(problems, "state must be one of: planned, active, paused, completed, archived") - } - } - - if spec.Governance != nil { - if spec.Governance.MaxNewItemsPerRun < 0 { - problems = append(problems, "governance.max-new-items-per-run must be >= 0") - } - if spec.Governance.MaxDiscoveryItemsPerRun < 0 { - problems = append(problems, "governance.max-discovery-items-per-run must be >= 0") - } - if spec.Governance.MaxDiscoveryPagesPerRun < 0 { - problems = append(problems, "governance.max-discovery-pages-per-run must be >= 0") - } - if spec.Governance.MaxProjectUpdatesPerRun < 0 { - problems = append(problems, "governance.max-project-updates-per-run must be >= 0") - } - if spec.Governance.MaxCommentsPerRun < 0 { - problems = append(problems, "governance.max-comments-per-run must be >= 0") - } - } - - if len(problems) == 0 { - validationLog.Printf("Campaign spec '%s' validation passed with no problems", spec.ID) - } else { - validationLog.Printf("Campaign spec '%s' validation completed with %d problems", spec.ID, len(problems)) - } - - return problems -} - -// suggestValidID converts an invalid campaign ID into a valid one by: -// - Converting to lowercase -// - Replacing invalid characters with hyphens -// - Collapsing multiple hyphens -// - Trimming leading/trailing hyphens -func suggestValidID(id string) string { - // Convert to lowercase - result := strings.ToLower(id) - - // Replace invalid characters with hyphens - var builder strings.Builder - for _, ch := range result { - if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') { - builder.WriteRune(ch) - } else { - builder.WriteRune('-') - } - } - result = builder.String() - - // Collapse multiple hyphens into single hyphen - for strings.Contains(result, "--") { - result = strings.ReplaceAll(result, "--", "-") - } - - // Trim leading/trailing hyphens - result = strings.Trim(result, "-") - - return result -} - -// getCompiledSchema returns the compiled campaign spec schema, compiling it once and caching -func getCompiledSchema() (*jsonschema.Schema, error) { - compiledSchemaOnce.Do(func() { - // Read embedded schema - schemaData, err := campaignSpecSchemaFS.ReadFile("schemas/campaign_spec_schema.json") - if err != nil { - schemaCompileError = fmt.Errorf("failed to load campaign spec schema: %w", err) - return - } - - // Parse schema as JSON - var schemaDoc any - if err := json.Unmarshal(schemaData, &schemaDoc); err != nil { - schemaCompileError = fmt.Errorf("failed to parse campaign spec schema: %w", err) - return - } - - // Create compiler and add schema resource - compiler := jsonschema.NewCompiler() - schemaURL := "campaign-spec.json" - if err := compiler.AddResource(schemaURL, schemaDoc); err != nil { - schemaCompileError = fmt.Errorf("failed to add schema resource: %w", err) - return - } - - // Compile schema once - schema, err := compiler.Compile(schemaURL) - if err != nil { - schemaCompileError = fmt.Errorf("failed to compile schema: %w", err) - return - } - - compiledSchema = schema - }) - - return compiledSchema, schemaCompileError -} - -// ValidateSpecWithSchema validates a CampaignSpec against the JSON schema. -// Returns a list of validation error messages, or an empty list if valid. -func ValidateSpecWithSchema(spec *CampaignSpec) []string { - // Get the cached compiled schema - schema, err := getCompiledSchema() - if err != nil { - return []string{err.Error()} - } - - // Convert spec to JSON for validation, excluding runtime fields. - // Create a copy without ConfigPath (which is set at runtime, not in YAML). - // - // JSON property names intentionally mirror the kebab-case YAML keys so the - // JSON Schema can validate both YAML and JSON representations consistently. - type CampaignGovernancePolicyForValidation struct { - MaxNewItemsPerRun int `json:"max-new-items-per-run,omitempty"` - MaxDiscoveryItemsPerRun int `json:"max-discovery-items-per-run,omitempty"` - MaxDiscoveryPagesPerRun int `json:"max-discovery-pages-per-run,omitempty"` - OptOutLabels []string `json:"opt-out-labels,omitempty"` - DoNotDowngradeDoneItems *bool `json:"do-not-downgrade-done-items,omitempty"` - MaxProjectUpdatesPerRun int `json:"max-project-updates-per-run,omitempty"` - MaxCommentsPerRun int `json:"max-comments-per-run,omitempty"` - } - - type CampaignSpecForValidation struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - ProjectURL string `json:"project-url,omitempty"` - Version string `json:"version,omitempty"` - Workflows []string `json:"workflows,omitempty"` - Scope []string `json:"scope,omitempty"` - MemoryPaths []string `json:"memory-paths,omitempty"` - MetricsGlob string `json:"metrics-glob,omitempty"` - CursorGlob string `json:"cursor-glob,omitempty"` - Owners []string `json:"owners,omitempty"` - ExecutiveSponsors []string `json:"executive-sponsors,omitempty"` - RiskLevel string `json:"risk-level,omitempty"` - State string `json:"state,omitempty"` - Tags []string `json:"tags,omitempty"` - AllowedSafeOutputs []string `json:"allowed-safe-outputs,omitempty"` - Governance *CampaignGovernancePolicyForValidation `json:"governance,omitempty"` - ApprovalPolicy *CampaignApprovalPolicy `json:"approval-policy,omitempty"` - } - - validationSpec := CampaignSpecForValidation{ - ID: spec.ID, - Name: spec.Name, - Description: spec.Description, - ProjectURL: spec.ProjectURL, - Version: spec.Version, - Workflows: spec.Workflows, - Scope: spec.Scope, - MemoryPaths: spec.MemoryPaths, - MetricsGlob: spec.MetricsGlob, - CursorGlob: spec.CursorGlob, - Owners: spec.Owners, - ExecutiveSponsors: spec.ExecutiveSponsors, - RiskLevel: spec.RiskLevel, - State: spec.State, - Tags: spec.Tags, - AllowedSafeOutputs: spec.AllowedSafeOutputs, - Governance: func() *CampaignGovernancePolicyForValidation { - if spec.Governance == nil { - return nil - } - return &CampaignGovernancePolicyForValidation{ - MaxNewItemsPerRun: spec.Governance.MaxNewItemsPerRun, - MaxDiscoveryItemsPerRun: spec.Governance.MaxDiscoveryItemsPerRun, - MaxDiscoveryPagesPerRun: spec.Governance.MaxDiscoveryPagesPerRun, - OptOutLabels: spec.Governance.OptOutLabels, - DoNotDowngradeDoneItems: spec.Governance.DoNotDowngradeDoneItems, - MaxProjectUpdatesPerRun: spec.Governance.MaxProjectUpdatesPerRun, - MaxCommentsPerRun: spec.Governance.MaxCommentsPerRun, - } - }(), - ApprovalPolicy: spec.ApprovalPolicy, - } - - // Marshal spec to JSON then unmarshal to any for validation - // This is necessary because the jsonschema library validates against the JSON representation - specJSON, err := json.Marshal(validationSpec) - if err != nil { - return []string{fmt.Sprintf("failed to marshal spec to JSON: %v", err)} - } - - var specData any - if err := json.Unmarshal(specJSON, &specData); err != nil { - return []string{fmt.Sprintf("failed to unmarshal spec data: %v", err)} - } - - // Validate the spec against the schema - if err := schema.Validate(specData); err != nil { - return formatValidationErrors(err) - } - - return nil -} - -// formatValidationErrors converts jsonschema validation errors to a list of human-readable messages -func formatValidationErrors(err error) []string { - var problems []string - - ve, ok := err.(*jsonschema.ValidationError) - if !ok { - // Not a validation error, return as-is - return []string{err.Error()} - } - - // Process the main error and all causes - var collectErrors func(*jsonschema.ValidationError) - collectErrors = func(e *jsonschema.ValidationError) { - // Skip collecting if there are causes - we'll collect those instead - // to avoid duplicate/redundant messages - if len(e.Causes) > 0 { - for _, cause := range e.Causes { - collectErrors(cause) - } - return - } - - // Format the error message with field path - field := "root" - if len(e.InstanceLocation) > 0 { - field = strings.Join(e.InstanceLocation, ".") - } - - // Use the error's Error() method to get the message - msg := e.Error() - - problems = append(problems, fmt.Sprintf("%s: %s", field, msg)) - } - - collectErrors(ve) - return problems -} - -// ValidateSpecFromFile validates a campaign spec file by loading and validating it. -// This is useful for validation commands that operate on files directly. -func ValidateSpecFromFile(filePath string) (*CampaignSpec, []string, error) { - validationLog.Printf("Validating campaign spec from file: %s", filePath) - - // Read the campaign spec file content first, then extract frontmatter - content, err := os.ReadFile(filePath) - if err != nil { - validationLog.Printf("Failed to read campaign spec file: %s", err) - return nil, nil, fmt.Errorf("failed to read campaign spec file: %w", err) - } - - data, err := parser.ExtractFrontmatterFromContent(string(content)) - if err != nil { - return nil, nil, fmt.Errorf("failed to parse frontmatter: %w", err) - } - - if len(data.Frontmatter) == 0 { - return nil, nil, fmt.Errorf("no frontmatter found in campaign spec file") - } - - // Convert frontmatter map into YAML, then unmarshal into CampaignSpec using - // YAML tags so kebab-case keys (e.g. project-url) map correctly. - yamlBytes, err := yaml.Marshal(data.Frontmatter) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal frontmatter to YAML: %w", err) - } - - var spec CampaignSpec - if err := yaml.Unmarshal(yamlBytes, &spec); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal spec from YAML: %w", err) - } - - problems := ValidateSpec(&spec) - return &spec, problems, nil -} - -// ValidateWorkflowsExist checks that all workflows referenced in a campaign spec -// actually exist in the .github/workflows directory. -// Returns a list of problems for workflows that don't exist. -func ValidateWorkflowsExist(spec *CampaignSpec, workflowsDir string) []string { - validationLog.Printf("Validating workflow existence for campaign '%s': checking %d workflows in %s", - spec.ID, len(spec.Workflows), workflowsDir) - var problems []string - - for _, workflowID := range spec.Workflows { - // Check for both .md and .lock.yml versions - mdPath := filepath.Join(workflowsDir, workflowID+".md") - lockPath := filepath.Join(workflowsDir, workflowID+".lock.yml") - - mdExists := false - lockExists := false - - if _, err := os.Stat(mdPath); err == nil { - mdExists = true - } - if _, err := os.Stat(lockPath); err == nil { - lockExists = true - } - - if !mdExists && !lockExists { - problems = append(problems, fmt.Sprintf("workflow '%s' not found", workflowID)) - } else if !mdExists { - problems = append(problems, fmt.Sprintf("workflow '%s' missing source .md file", workflowID)) - } - } - - return problems -} diff --git a/pkg/campaign/validation_test.go b/pkg/campaign/validation_test.go deleted file mode 100644 index 04021fd79f2..00000000000 --- a/pkg/campaign/validation_test.go +++ /dev/null @@ -1,566 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "strings" - "testing" -) - -func TestValidateSpec_ValidSpec(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Version: "v1", - State: "active", - Workflows: []string{"workflow1", "workflow2"}, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Errorf("Expected no validation problems, got: %v", problems) - } -} - -func TestValidateSpec_MissingID(t *testing.T) { - spec := &CampaignSpec{ - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for missing ID") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "id is required") { - found = true - break - } - } - if !found { - t.Errorf("Expected ID validation problem, got: %v", problems) - } -} - -func TestValidateSpec_InvalidIDCharacters(t *testing.T) { - spec := &CampaignSpec{ - ID: "Test_Campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for invalid ID characters") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "lowercase letters, digits, and hyphens") { - found = true - break - } - } - if !found { - t.Errorf("Expected ID character validation problem, got: %v", problems) - } -} - -func TestValidateSpec_MissingName(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for missing name") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "name should be provided") { - found = true - break - } - } - if !found { - t.Errorf("Expected name validation problem, got: %v", problems) - } -} - -func TestValidateSpec_MissingWorkflows(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for missing workflows") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "workflows should list at least one workflow") { - found = true - break - } - } - if !found { - t.Errorf("Expected workflows validation problem, got: %v", problems) - } -} - -func TestValidateSpec_InvalidState(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - State: "invalid-state", - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for invalid state") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "state must be one of") { - found = true - break - } - } - if !found { - t.Errorf("Expected state validation problem, got: %v", problems) - } -} - -func TestValidateSpec_ValidStates(t *testing.T) { - validStates := []string{"planned", "active", "paused", "completed", "archived"} - - for _, state := range validStates { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - State: state, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Errorf("Expected no validation problems for state '%s', got: %v", state, problems) - } - } -} - -func TestValidateSpec_VersionDefault(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - } - - _ = ValidateSpec(spec) - - if spec.Version != "v1" { - t.Errorf("Expected version to default to 'v1', got '%s'", spec.Version) - } -} - -func TestValidateSpec_RiskLevel(t *testing.T) { - validRiskLevels := []string{"low", "medium", "high"} - - for _, riskLevel := range validRiskLevels { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - RiskLevel: riskLevel, - } - - problems := ValidateSpec(spec) - // Risk level validation is currently not enforced beyond schema - // This test ensures the field is accepted - if len(problems) != 0 { - t.Errorf("Expected no validation problems for risk level '%s', got: %v", riskLevel, problems) - } - } -} - -func TestValidateSpec_WithApprovalPolicy(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - ApprovalPolicy: &CampaignApprovalPolicy{ - RequiredApprovals: 2, - RequiredRoles: []string{"admin", "security"}, - ChangeControl: true, - }, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Errorf("Expected no validation problems with approval policy, got: %v", problems) - } -} - -func TestValidateSpec_CompleteSpec(t *testing.T) { - spec := &CampaignSpec{ - ID: "complete-campaign", - Name: "Complete Campaign", - Description: "A complete campaign spec for testing", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Version: "v1", - Workflows: []string{"workflow1", "workflow2"}, - MemoryPaths: []string{"memory/campaigns/complete/**"}, - MetricsGlob: "memory/campaigns/complete-*.json", - Owners: []string{"owner1", "owner2"}, - ExecutiveSponsors: []string{"sponsor1"}, - RiskLevel: "medium", - State: "active", - Tags: []string{"security", "compliance"}, - AllowedSafeOutputs: []string{"create-issue", "create-pull-request"}, - ApprovalPolicy: &CampaignApprovalPolicy{ - RequiredApprovals: 3, - RequiredRoles: []string{"admin", "security", "compliance"}, - ChangeControl: true, - }, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Errorf("Expected no validation problems for complete spec, got: %v", problems) - } -} - -func TestValidateSpec_MissingScopeIsValid(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - // No workflows - scope is not required - } - - problems := ValidateSpec(spec) - // Should have one problem: missing workflows - if len(problems) != 1 { - t.Errorf("Expected 1 validation problem (workflows), got: %v", problems) - } -} - -func TestValidateSpec_InvalidScopeRepoFormat(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Workflows: []string{"workflow1"}, - Scope: []string{"invalid-repo-format", "org/repo1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for invalid repo format") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "must be 'owner/repo'") { - found = true - break - } - } - if !found { - t.Errorf("Expected repo format validation problem, got: %v", problems) - } -} - -func TestValidateSpec_EmptyScopeIsValid(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{}, // Empty list, no workflows - } - - problems := ValidateSpec(spec) - // Should have one problem: missing workflows - if len(problems) != 1 { - t.Errorf("Expected 1 validation problem (workflows), got: %v", problems) - } -} - -func TestValidateSpec_ValidScopeWithOrgs(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Workflows: []string{"workflow1"}, - Scope: []string{"org/repo1", "org:github", "org:microsoft"}, - } - - problems := ValidateSpec(spec) - if len(problems) != 0 { - t.Errorf("Expected no validation problems with valid scope orgs, got: %v", problems) - } -} - -func TestValidateSpec_InvalidScopeOrgFormat(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Workflows: []string{"workflow1"}, - Scope: []string{"org/repo1", "org:github/repo"}, // Invalid - contains slash - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for invalid org format") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "must be 'org:'") { - found = true - break - } - } - if !found { - t.Errorf("Expected org format validation problem, got: %v", problems) - } -} - -func TestSuggestValidID(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "uppercase to lowercase", - input: "Security-Q1-2025", - expected: "security-q1-2025", - }, - { - name: "underscores to hyphens", - input: "test_campaign_id", - expected: "test-campaign-id", - }, - { - name: "spaces to hyphens", - input: "my campaign id", - expected: "my-campaign-id", - }, - { - name: "mixed invalid characters", - input: "Test Campaign! @2025", - expected: "test-campaign-2025", - }, - { - name: "multiple hyphens collapsed", - input: "test--campaign---id", - expected: "test-campaign-id", - }, - { - name: "leading and trailing hyphens removed", - input: "-test-campaign-", - expected: "test-campaign", - }, - { - name: "already valid id", - input: "valid-campaign-123", - expected: "valid-campaign-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := suggestValidID(tt.input) - if result != tt.expected { - t.Errorf("suggestValidID(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -func TestValidateSpec_InvalidIDWithSuggestion(t *testing.T) { - spec := &CampaignSpec{ - ID: "Test Campaign 2025", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for invalid ID") - } - - // Check that the error message includes a suggestion - found := false - for _, p := range problems { - if strings.Contains(p, "test-campaign-2025") { - found = true - break - } - } - if !found { - t.Errorf("Expected ID validation problem with suggestion, got: %v", problems) - } -} - -func TestValidateSpec_ScopeRepoWildcard(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo*"}, // Invalid - wildcards not allowed in repo selectors - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for wildcard in scope") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "cannot contain wildcards") { - found = true - break - } - } - if !found { - t.Errorf("Expected wildcard validation problem, got: %v", problems) - } -} - -func TestValidateSpec_ScopeOrgWildcard(t *testing.T) { - spec := &CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org:github*"}, // Invalid - wildcards not allowed in org selectors - Workflows: []string{"workflow1"}, - } - - problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for wildcard in scope") - } - - found := false - for _, p := range problems { - if strings.Contains(p, "cannot contain wildcards") { - found = true - break - } - } - if !found { - t.Errorf("Expected wildcard validation problem, got: %v", problems) - } -} - -func TestValidateSpec_TrackerLabelFormat(t *testing.T) { - tests := []struct { - name string - campaignID string - trackerLabel string - expectError bool - errorContains string - }{ - { - name: "valid tracker label with z_campaign_ prefix", - campaignID: "security-alert-burndown", - trackerLabel: "z_campaign_security-alert-burndown", - expectError: false, - }, - { - name: "invalid tracker label with old campaign: prefix", - campaignID: "security-alert-burndown", - trackerLabel: "campaign:security-alert-burndown", - expectError: true, - errorContains: "tracker-label should start with 'z_campaign_' prefix", - }, - { - name: "invalid tracker label with wrong format", - campaignID: "test-campaign", - trackerLabel: "wrong-label-format", - expectError: true, - errorContains: "tracker-label should start with 'z_campaign_' prefix", - }, - { - name: "empty tracker label is valid (optional field)", - campaignID: "test-campaign", - trackerLabel: "", - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - spec := &CampaignSpec{ - ID: tt.campaignID, - Name: "Test Campaign", - ProjectURL: "https://github.com/orgs/org/projects/1", - Scope: []string{"org/repo1"}, - Workflows: []string{"workflow1"}, - TrackerLabel: tt.trackerLabel, - } - - problems := ValidateSpec(spec) - - if tt.expectError { - if len(problems) == 0 { - t.Errorf("Expected validation error but got none") - return - } - found := false - for _, p := range problems { - if strings.Contains(p, tt.errorContains) { - found = true - break - } - } - if !found { - t.Errorf("Expected error containing %q, got: %v", tt.errorContains, problems) - } - } else { - // Filter out problems that are not related to tracker-label - for _, p := range problems { - if strings.Contains(p, "tracker-label") { - t.Errorf("Unexpected tracker-label validation error: %s", p) - } - } - } - }) - } -} diff --git a/pkg/campaign/workflow_discovery.go b/pkg/campaign/workflow_discovery.go deleted file mode 100644 index 06bc319c506..00000000000 --- a/pkg/campaign/workflow_discovery.go +++ /dev/null @@ -1,170 +0,0 @@ -package campaign - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/githubnext/gh-aw/pkg/logger" - "github.com/githubnext/gh-aw/pkg/parser" -) - -var workflowDiscoveryLog = logger.New("campaign:workflow_discovery") - -// WorkflowMatch represents a discovered workflow that matches campaign goals -type WorkflowMatch struct { - ID string // Workflow ID (basename without .md) - FilePath string // Relative path to workflow file - Name string // Workflow name from frontmatter - Description string // Workflow description - Keywords []string // Matching keywords - Score int // Match score (higher is better) -} - -// CampaignGoalKeywords maps campaign types to relevant keywords -var CampaignGoalKeywords = map[string][]string{ - "security": { - "security", "vulnerability", "vulnerabilities", "scan", "scanning", - "cve", "audit", "compliance", "threat", "detection", - }, - "dependencies": { - "dependency", "dependencies", "upgrade", "update", "npm", "pip", - "package", "packages", "version", "outdated", - }, - "documentation": { - "doc", "docs", "documentation", "guide", "guides", "readme", - "wiki", "reference", "tutorial", - }, - "quality": { - "quality", "test", "testing", "lint", "linting", "coverage", - "code-quality", "static-analysis", "sonar", - }, - "cicd": { - "ci", "cd", "build", "deploy", "deployment", "release", - "pipeline", "automation", "continuous", - }, -} - -// DiscoverWorkflows scans the repository for existing workflows that match campaign goals -func DiscoverWorkflows(rootDir string, campaignGoals []string) ([]WorkflowMatch, error) { - workflowDiscoveryLog.Printf("Discovering workflows for goals: %v", campaignGoals) - - workflowsDir := filepath.Join(rootDir, ".github", "workflows") - if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { - workflowDiscoveryLog.Print("Workflows directory does not exist") - return []WorkflowMatch{}, nil - } - - // Scan for .md files (agentic workflows) - entries, err := os.ReadDir(workflowsDir) - if err != nil { - return nil, fmt.Errorf("failed to read workflows directory: %w", err) - } - - var matches []WorkflowMatch - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { - continue - } - - // Skip campaign files and generated files - if strings.HasSuffix(entry.Name(), ".campaign.md") || strings.HasSuffix(entry.Name(), ".g.md") { - continue - } - - filePath := filepath.Join(workflowsDir, entry.Name()) - match, err := matchWorkflow(filePath, campaignGoals) - if err != nil { - workflowDiscoveryLog.Printf("Failed to match workflow %s: %v", entry.Name(), err) - continue - } - - if match != nil { - matches = append(matches, *match) - workflowDiscoveryLog.Printf("Found matching workflow: %s (score: %d)", match.ID, match.Score) - } - } - - // Sort by score (highest first) - sortWorkflowMatches(matches) - - workflowDiscoveryLog.Printf("Discovered %d matching workflows", len(matches)) - return matches, nil -} - -// matchWorkflow checks if a workflow matches the campaign goals -func matchWorkflow(filePath string, campaignGoals []string) (*WorkflowMatch, error) { - // Read workflow file - content, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read workflow file: %w", err) - } - - // Extract frontmatter - result, err := parser.ExtractFrontmatterFromContent(string(content)) - if err != nil { - return nil, fmt.Errorf("failed to extract frontmatter: %w", err) - } - - // Get workflow name and description - name := getStringField(result.Frontmatter, "name") - description := getStringField(result.Frontmatter, "description") - - // Build searchable text (lowercase) - searchText := strings.ToLower(name + " " + description) - - // Calculate match score - score := 0 - matchedKeywords := []string{} - - for _, goal := range campaignGoals { - keywords := CampaignGoalKeywords[strings.ToLower(goal)] - for _, keyword := range keywords { - if strings.Contains(searchText, keyword) { - score += 10 - matchedKeywords = append(matchedKeywords, keyword) - } - } - } - - // No match if score is 0 - if score == 0 { - return nil, nil - } - - // Extract workflow ID from filename - filename := filepath.Base(filePath) - workflowID := strings.TrimSuffix(filename, ".md") - - return &WorkflowMatch{ - ID: workflowID, - FilePath: filePath, - Name: name, - Description: description, - Keywords: matchedKeywords, - Score: score, - }, nil -} - -// sortWorkflowMatches sorts workflow matches by score (descending) -func sortWorkflowMatches(matches []WorkflowMatch) { - // Simple bubble sort (good enough for small lists) - for i := 0; i < len(matches); i++ { - for j := i + 1; j < len(matches); j++ { - if matches[j].Score > matches[i].Score { - matches[i], matches[j] = matches[j], matches[i] - } - } - } -} - -// getStringField safely extracts a string field from frontmatter -func getStringField(frontmatter map[string]any, field string) string { - if val, ok := frontmatter[field]; ok { - if str, ok := val.(string); ok { - return str - } - } - return "" -} diff --git a/pkg/campaign/workflow_discovery_test.go b/pkg/campaign/workflow_discovery_test.go deleted file mode 100644 index 0d36c221cf7..00000000000 --- a/pkg/campaign/workflow_discovery_test.go +++ /dev/null @@ -1,199 +0,0 @@ -//go:build !integration - -package campaign - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestDiscoverWorkflows(t *testing.T) { - tests := []struct { - name string - workflows map[string]string // filename -> content - goals []string - expectedCount int - expectedIDs []string - }{ - { - name: "discover security workflow", - workflows: map[string]string{ - "security-scanner.md": `--- -name: Security Scanner -description: Scan for vulnerabilities ---- -# Security Scanner -Scan repositories for security vulnerabilities`, - }, - goals: []string{"security"}, - expectedCount: 1, - expectedIDs: []string{"security-scanner"}, - }, - { - name: "discover multiple matching workflows", - workflows: map[string]string{ - "dependency-updater.md": `--- -name: Dependency Updater -description: Update npm packages ---- -# Dependency Updater`, - "package-scanner.md": `--- -name: Package Scanner -description: Scan for outdated dependencies ---- -# Package Scanner`, - }, - goals: []string{"dependencies"}, - expectedCount: 2, - expectedIDs: []string{"dependency-updater", "package-scanner"}, - }, - { - name: "skip campaign files", - workflows: map[string]string{ - "my-campaign.campaign.md": `--- -name: My Campaign ---- -# Campaign`, - "security-scanner.md": `--- -name: Security Scanner -description: Scan for vulnerabilities ---- -# Scanner`, - }, - goals: []string{"security"}, - expectedCount: 1, - expectedIDs: []string{"security-scanner"}, - }, - { - name: "no matching workflows", - workflows: map[string]string{ - "random-workflow.md": `--- -name: Random Workflow -description: Does something random ---- -# Random`, - }, - goals: []string{"security"}, - expectedCount: 0, - expectedIDs: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temporary directory - tmpDir := t.TempDir() - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - require.NoError(t, os.MkdirAll(workflowsDir, 0755)) - - // Create workflow files - for filename, content := range tt.workflows { - filePath := filepath.Join(workflowsDir, filename) - require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) - } - - // Discover workflows - matches, err := DiscoverWorkflows(tmpDir, tt.goals) - require.NoError(t, err) - - // Verify results - assert.Len(t, matches, tt.expectedCount, "Expected %d matches, got %d", tt.expectedCount, len(matches)) - - // Verify IDs - actualIDs := make([]string, len(matches)) - for i, match := range matches { - actualIDs[i] = match.ID - } - - if tt.expectedCount > 0 { - for _, expectedID := range tt.expectedIDs { - assert.Contains(t, actualIDs, expectedID, "Expected workflow ID %s not found", expectedID) - } - } - }) - } -} - -func TestMatchWorkflow(t *testing.T) { - tests := []struct { - name string - content string - goals []string - expectedMatch bool - expectedScore int - minKeywordCount int - }{ - { - name: "security workflow matches security goal", - content: `--- -name: Security Scanner -description: Scan for security vulnerabilities ---- -# Security Scanner`, - goals: []string{"security"}, - expectedMatch: true, - expectedScore: 20, // "security" and "vulnerabilities" - minKeywordCount: 2, - }, - { - name: "dependency workflow matches dependency goal", - content: `--- -name: Dependency Updater -description: Update npm dependencies and packages ---- -# Updater`, - goals: []string{"dependencies"}, - expectedMatch: true, - expectedScore: 20, - minKeywordCount: 2, - }, - { - name: "no match for unrelated workflow", - content: `--- -name: Random Workflow -description: Does something random ---- -# Random`, - goals: []string{"security"}, - expectedMatch: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temporary file - tmpFile := filepath.Join(t.TempDir(), "test.md") - require.NoError(t, os.WriteFile(tmpFile, []byte(tt.content), 0644)) - - // Match workflow - match, err := matchWorkflow(tmpFile, tt.goals) - require.NoError(t, err) - - if tt.expectedMatch { - require.NotNil(t, match, "Expected a match but got nil") - assert.GreaterOrEqual(t, match.Score, tt.expectedScore, "Expected score >= %d, got %d", tt.expectedScore, match.Score) - assert.GreaterOrEqual(t, len(match.Keywords), tt.minKeywordCount, "Expected at least %d keywords", tt.minKeywordCount) - } else { - assert.Nil(t, match, "Expected no match but got one") - } - }) - } -} - -func TestSortWorkflowMatches(t *testing.T) { - matches := []WorkflowMatch{ - {ID: "low", Score: 10}, - {ID: "high", Score: 50}, - {ID: "medium", Score: 30}, - } - - sortWorkflowMatches(matches) - - assert.Equal(t, "high", matches[0].ID) - assert.Equal(t, "medium", matches[1].ID) - assert.Equal(t, "low", matches[2].ID) -} diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index fc4c2ce07f6..ed766b846a1 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -49,21 +49,6 @@ var debugWorkflowPromptTemplate string //go:embed templates/upgrade-agentic-workflows.md var upgradeAgenticWorkflowsPromptTemplate string -//go:embed templates/generate-agentic-campaign.md -var campaignGeneratorInstructionsTemplate string - -//go:embed templates/orchestrate-agentic-campaign.md -var campaignOrchestratorInstructionsTemplate string - -//go:embed templates/update-agentic-campaign-project.md -var campaignProjectUpdateInstructionsTemplate string - -//go:embed templates/execute-agentic-campaign-workflow.md -var campaignWorkflowExecutionTemplate string - -//go:embed templates/close-agentic-campaign.md -var campaignClosingInstructionsTemplate string - // SetVersionInfo sets the version information for the CLI and workflow package func SetVersionInfo(v string) { version = v diff --git a/pkg/cli/compile_campaign.go b/pkg/cli/compile_campaign.go deleted file mode 100644 index 3c8ba255b95..00000000000 --- a/pkg/cli/compile_campaign.go +++ /dev/null @@ -1,167 +0,0 @@ -// This file provides campaign workflow compilation and validation. -// -// This file handles validation of campaign spec files and their referenced workflows. -// Campaign workflows are special workflows that orchestrate multiple sub-workflows -// across a set of target repositories. -// -// # Organization Rationale -// -// The validateCampaigns function is co-located with campaign compilation logic because: -// - It's domain-specific to campaign workflows -// - It's only called during compile operations -// - It's tightly coupled to the campaign compilation process -// - The file is small (98 lines) and focused -// -// This follows the principle that domain-specific validation belongs in domain files. -// See skills/developer/SKILL.md for validation architecture patterns. -// -// # Validation Functions -// -// Campaign Validation: -// - validateCampaigns() - Validates campaign specs and referenced workflows -// -// This validation ensures that: -// - Campaign spec files are syntactically valid -// - Referenced workflow files exist in the workflows directory -// - Campaign configuration is complete and correct - -package cli - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/githubnext/gh-aw/pkg/campaign" - "github.com/githubnext/gh-aw/pkg/console" - "github.com/githubnext/gh-aw/pkg/logger" -) - -var compileCampaignLog = logger.New("cli:compile_campaign") - -// validateCampaigns validates campaign spec files and their referenced workflows. -// If campaignFiles is provided (non-nil), only those specific campaign files are validated. -// If campaignFiles is nil, all campaign specs are validated. -// Returns an error if any campaign specs are invalid or reference missing workflows. -func validateCampaigns(workflowDir string, verbose bool, campaignFiles []string) error { - compileCampaignLog.Printf("Validating campaigns with workflow directory: %s", workflowDir) - - // Get absolute path to workflows directory - absWorkflowDir := workflowDir - if !filepath.IsAbs(absWorkflowDir) { - gitRoot, err := findGitRoot() - if err != nil { - compileCampaignLog.Print("Not in a git repository, using current directory") - // If not in a git repo, use current directory - cwd, cwdErr := os.Getwd() - if cwdErr != nil { - return nil // Silently skip if we can't determine the directory - } - absWorkflowDir = filepath.Join(cwd, workflowDir) - } else { - absWorkflowDir = filepath.Join(gitRoot, workflowDir) - } - } - compileCampaignLog.Printf("Using absolute workflow directory: %s", absWorkflowDir) - - // Load campaign specs - gitRoot, err := findGitRoot() - if err != nil { - compileCampaignLog.Print("Cannot validate campaigns: not in a git repository") - // Not in a git repo, can't validate campaigns - return nil - } - - specs, err := campaign.LoadSpecs(gitRoot) - if err != nil { - compileCampaignLog.Printf("Failed to load campaign specs: %v", err) - return fmt.Errorf("failed to load campaign specs: %w", err) - } - - if len(specs) == 0 { - compileCampaignLog.Print("No campaign specs found to validate") - // No campaign specs to validate - return nil - } - - // Filter specs if specific campaign files were provided - var specsToValidate []campaign.CampaignSpec - if len(campaignFiles) > 0 { - compileCampaignLog.Printf("Filtering to validate only %d specific campaign file(s)", len(campaignFiles)) - // Create a map of absolute paths for quick lookup - campaignFileMap := make(map[string]bool) - for _, cf := range campaignFiles { - absPath, err := filepath.Abs(cf) - if err == nil { - campaignFileMap[absPath] = true - } - } - - for _, spec := range specs { - // Get absolute path of the spec's config file - specPath := spec.ConfigPath - if !filepath.IsAbs(specPath) { - specPath = filepath.Join(gitRoot, specPath) - } - absSpecPath, err := filepath.Abs(specPath) - if err == nil && campaignFileMap[absSpecPath] { - specsToValidate = append(specsToValidate, spec) - } - } - compileCampaignLog.Printf("Filtered to %d campaign spec(s) for validation", len(specsToValidate)) - } else { - // Validate all specs - specsToValidate = specs - compileCampaignLog.Printf("Loaded %d campaign specs for validation", len(specs)) - } - - if len(specsToValidate) == 0 { - compileCampaignLog.Print("No matching campaign specs found to validate") - return nil - } - - if verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Validating %d campaign spec(s)...", len(specsToValidate)))) - } - - var allProblems []string - hasErrors := false - - for _, spec := range specsToValidate { - // Validate the spec itself - problems := campaign.ValidateSpec(&spec) - - // Validate that referenced workflows exist in the same directory as the campaign spec - // Use the directory of the campaign spec file, not a global workflow directory - campaignDir := filepath.Dir(spec.ConfigPath) - if !filepath.IsAbs(campaignDir) { - campaignDir = filepath.Join(gitRoot, campaignDir) - } - workflowProblems := campaign.ValidateWorkflowsExist(&spec, campaignDir) - problems = append(problems, workflowProblems...) - - if len(problems) > 0 { - hasErrors = true - // Extract just the filename from the config path for more concise messages - configFile := filepath.Base(spec.ConfigPath) - for _, problem := range problems { - msg := fmt.Sprintf("%s: %s", configFile, problem) - allProblems = append(allProblems, msg) - // Always display problems, not just in verbose mode - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(msg)) - } - } - } - - if hasErrors { - compileCampaignLog.Printf("Campaign validation completed with %d problems", len(allProblems)) - return fmt.Errorf("found %d problem(s) in campaign specs", len(allProblems)) - } - - compileCampaignLog.Printf("All %d campaign specs validated successfully", len(specsToValidate)) - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("All %d campaign spec(s) validated successfully", len(specsToValidate)))) - } - - return nil -} diff --git a/pkg/cli/compile_campaign_orchestrator_test.go b/pkg/cli/compile_campaign_orchestrator_test.go deleted file mode 100644 index 61a298b526d..00000000000 --- a/pkg/cli/compile_campaign_orchestrator_test.go +++ /dev/null @@ -1,268 +0,0 @@ -//go:build !integration - -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/githubnext/gh-aw/pkg/campaign" - "github.com/githubnext/gh-aw/pkg/workflow" -) - -func TestGenerateAndCompileCampaignOrchestrator(t *testing.T) { - tmpDir := t.TempDir() - - campaignSpecPath := filepath.Join(tmpDir, "test-campaign.campaign.md") - - spec := &campaign.CampaignSpec{ - ID: "test-campaign", - Name: "Test Campaign", - Description: "A test campaign", - Workflows: []string{"example-workflow"}, - MemoryPaths: []string{"memory/campaigns/test-campaign/**"}, - } - - // Compiler with auto-detected version and action mode - compiler := workflow.NewCompiler( - workflow.WithSkipValidation(true), - workflow.WithNoEmit(false), - workflow.WithStrictMode(false), - ) - - orchestratorPath, err := generateAndCompileCampaignOrchestrator(GenerateCampaignOrchestratorOptions{ - Compiler: compiler, - Spec: spec, - CampaignSpecPath: campaignSpecPath, - Verbose: false, - NoEmit: false, - RunZizmorPerFile: false, - RunPoutinePerFile: false, - RunActionlintPerFile: false, - Strict: false, - ValidateActionSHAs: false, - }) - if err != nil { - t.Fatalf("generateAndCompileCampaignOrchestrator() error: %v", err) - } - - expectedPath := strings.TrimSuffix(campaignSpecPath, ".campaign.md") + ".campaign.g.md" - if orchestratorPath != expectedPath { - t.Fatalf("unexpected orchestrator path: got %q, want %q", orchestratorPath, expectedPath) - } - - if _, statErr := os.Stat(orchestratorPath); statErr != nil { - t.Fatalf("expected orchestrator markdown to exist, stat error: %v", statErr) - } - - // For campaign orchestrators (*.campaign.g.md), the lock file should be *.campaign.g.lock.yml - lockPath := strings.TrimSuffix(orchestratorPath, ".md") + ".lock.yml" - if _, statErr := os.Stat(lockPath); statErr != nil { - t.Fatalf("expected orchestrator lock file to exist at %s, stat error: %v", lockPath, statErr) - } - - // Verify that the generated orchestrator has the required permissions - lockContent, readErr := os.ReadFile(lockPath) - if readErr != nil { - t.Fatalf("failed to read lock file: %v", readErr) - } - lockStr := string(lockContent) - - if !strings.Contains(lockStr, "engine_id: \"claude\"") { - t.Errorf("expected lock file to use claude engine, got: %s", lockPath) - } - - requiredPermissions := []string{ - "contents: read", - "issues: read", - } - - for _, perm := range requiredPermissions { - if !strings.Contains(lockStr, perm) { - t.Errorf("expected lock file to contain permission %q", perm) - } - } - - // Note: Issue/project write operations are handled via safe-outputs which mint - // app tokens with appropriate permissions, not direct workflow permissions. - - // Read the generated markdown file to verify the Source comment contains a relative path - mdContent, readErr := os.ReadFile(orchestratorPath) - if readErr != nil { - t.Fatalf("failed to read generated markdown file: %v", readErr) - } - mdStr := string(mdContent) - - if !strings.Contains(mdStr, "engine: claude") { - t.Errorf("expected generated markdown to set engine: claude") - } - - // Verify dispatch-workflow safe output is rendered (used for orchestration) - if !strings.Contains(mdStr, "dispatch-workflow:") { - t.Errorf("expected generated markdown to include dispatch-workflow safe output") - } - if !strings.Contains(mdStr, "example-workflow") { - t.Errorf("expected generated markdown to include allowlisted workflow 'example-workflow'") - } - - // Verify that the Source comment exists and contains a relative path (not absolute) - if !strings.Contains(mdStr, "") - if endIdx > 0 { - sourceComment := mdStr[startIdx : startIdx+endIdx] - // Verify it's not an absolute path (no leading / or drive letter) - if strings.Contains(sourceComment, "" - - startIdx := strings.Index(content, startMarker) - if startIdx == -1 { - t.Fatalf("source comment not found in content") - } - - startIdx += len(startMarker) - endIdx := strings.Index(content[startIdx:], endMarker) - if endIdx == -1 { - t.Fatalf("source comment end marker not found") - } - - return strings.TrimSpace(content[startIdx : startIdx+endIdx]) -} diff --git a/pkg/cli/compile_campaign_validation_test.go b/pkg/cli/compile_campaign_validation_test.go deleted file mode 100644 index 742d109d822..00000000000 --- a/pkg/cli/compile_campaign_validation_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build !integration - -package cli - -import ( - "os" - "path/filepath" - "testing" - - "github.com/githubnext/gh-aw/pkg/campaign" -) - -// TestValidateCampaignsUsesCorrectDirectory tests that validateCampaigns uses -// the campaign spec file's directory to validate referenced workflows, not -// a global workflow directory parameter. -func TestValidateCampaignsUsesCorrectDirectory(t *testing.T) { - tmpDir := t.TempDir() - - // Create a campaign spec file - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - if err := os.MkdirAll(workflowsDir, 0755); err != nil { - t.Fatal(err) - } - - // Create referenced workflow files - workflowFile := filepath.Join(workflowsDir, "example-workflow.md") - workflowContent := `--- -engine: copilot ---- - -# Test workflow -Test workflow content -` - if err := os.WriteFile(workflowFile, []byte(workflowContent), 0644); err != nil { - t.Fatal(err) - } - - // Create campaign spec file - campaignFile := filepath.Join(workflowsDir, "test-campaign.campaign.md") - campaignContent := `--- -id: test-campaign -name: Test Campaign -description: A test campaign -version: v1 -project-url: https://github.com/orgs/test/projects/1 -workflows: - - example-workflow -state: active ---- - -# Campaign Test -Test campaign -` - if err := os.WriteFile(campaignFile, []byte(campaignContent), 0644); err != nil { - t.Fatal(err) - } - - // Change to tmpDir so it becomes the git root for the test - origDir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - defer os.Chdir(origDir) - - if err := os.Chdir(tmpDir); err != nil { - t.Fatal(err) - } - - // Initialize a git repo - if err := os.WriteFile(".git", []byte("fake git"), 0644); err != nil { - t.Fatal(err) - } - - // Load the campaign spec - specs, err := campaign.LoadSpecs(tmpDir) - if err != nil { - t.Fatalf("LoadSpecs failed: %v", err) - } - - if len(specs) == 0 { - t.Fatal("Expected to load 1 campaign spec, got 0") - } - - // Validate campaigns with an incorrect workflow directory - // This should still work because validateCampaigns should use each spec's directory - err = validateCampaigns(".github/workflows", false, []string{campaignFile}) - if err != nil { - t.Fatalf("validateCampaigns failed: %v", err) - } -} diff --git a/pkg/cli/compile_helpers.go b/pkg/cli/compile_helpers.go index e9fe1065ac8..00ff0244ec9 100644 --- a/pkg/cli/compile_helpers.go +++ b/pkg/cli/compile_helpers.go @@ -48,6 +48,36 @@ import ( var compileHelpersLog = logger.New("cli:compile_helpers") +// getRepositoryRelativePath converts an absolute file path to a repository-relative path +// This ensures stable workflow identifiers regardless of where the repository is cloned +func getRepositoryRelativePath(absPath string) (string, error) { + // Get the repository root for the specific file + repoRoot, err := findGitRootForPath(absPath) + if err != nil { + // If we can't get the repo root, just use the basename as fallback + compileHelpersLog.Printf("Warning: could not get repository root for %s: %v, using basename", absPath, err) + return filepath.Base(absPath), nil + } + + // Convert both paths to absolute to ensure they can be compared + absPath, err = filepath.Abs(absPath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + // Get the relative path from repo root + relPath, err := filepath.Rel(repoRoot, absPath) + if err != nil { + return "", fmt.Errorf("failed to get relative path: %w", err) + } + + // Normalize path separators to forward slashes for consistency across platforms + // This ensures the same hash value on Windows, Linux, and macOS + relPath = filepath.ToSlash(relPath) + + return relPath, nil +} + // compileSingleFile compiles a single markdown workflow file and updates compilation statistics // If checkExists is true, the function will check if the file exists before compiling // Returns true if compilation was attempted (file exists or checkExists is false), false otherwise diff --git a/pkg/cli/compile_orchestration.go b/pkg/cli/compile_orchestration.go index 55a08c80091..3866db2b998 100644 --- a/pkg/cli/compile_orchestration.go +++ b/pkg/cli/compile_orchestration.go @@ -26,7 +26,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "github.com/githubnext/gh-aw/pkg/stringutil" @@ -59,7 +58,6 @@ func compileSpecificFiles( var errorMessages []string var lockFilesForActionlint []string var lockFilesForZizmor []string - var campaignFiles []string // Compile each specified file for _, markdownFile := range config.MarkdownFiles { @@ -97,31 +95,6 @@ func compileSpecificFiles( // Update result with resolved file name result.Workflow = filepath.Base(resolvedFile) - // Handle campaign spec files separately - if strings.HasSuffix(resolvedFile, ".campaign.md") { - campaignFiles = append(campaignFiles, resolvedFile) - campaignResult, success := processCampaignSpec(ProcessCampaignSpecOptions{ - Compiler: compiler, - ResolvedFile: resolvedFile, - Verbose: config.Verbose, - JSONOutput: config.JSONOutput, - NoEmit: config.NoEmit, - Zizmor: false, - Poutine: false, - Actionlint: false, - Strict: config.Strict, - Validate: shouldValidate, - }) - if !success { - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, resolvedFile, len(campaignResult.Errors)) - errorMessages = append(errorMessages, campaignResult.Errors[0].Message) - } - *validationResults = append(*validationResults, campaignResult) - continue - } - // Compile regular workflow file (disable per-file security tools) fileResult := compileWorkflowFile( compiler, resolvedFile, config.Verbose, config.JSONOutput, @@ -190,7 +163,7 @@ func compileSpecificFiles( displayScheduleWarnings(compiler, config.JSONOutput) // Post-processing - if err := runPostProcessing(compiler, workflowDataList, config, compiledCount, campaignFiles); err != nil { + if err := runPostProcessing(compiler, workflowDataList, config, compiledCount); err != nil { return workflowDataList, err } @@ -277,29 +250,6 @@ func compileAllFilesInDirectory( for _, file := range mdFiles { stats.Total++ - // Handle campaign spec files - if strings.HasSuffix(file, ".campaign.md") { - campaignResult, success := processCampaignSpec(ProcessCampaignSpecOptions{ - Compiler: compiler, - ResolvedFile: file, - Verbose: config.Verbose, - JSONOutput: config.JSONOutput, - NoEmit: config.NoEmit, - Zizmor: false, - Poutine: false, - Actionlint: false, - Strict: config.Strict, - Validate: shouldValidate, - }) - if !success { - errorCount++ - stats.Errors++ - trackWorkflowFailure(stats, file, len(campaignResult.Errors)) - } - *validationResults = append(*validationResults, campaignResult) - continue - } - // Compile regular workflow file (disable per-file security tools) fileResult := compileWorkflowFile( compiler, file, config.Verbose, config.JSONOutput, @@ -447,7 +397,6 @@ func runPostProcessing( workflowDataList []*workflow.WorkflowData, config CompileConfig, successCount int, - campaignFiles []string, ) error { // Get action cache actionCache := compiler.GetSharedActionCache() @@ -475,17 +424,6 @@ func runPostProcessing( // to check for expires fields, so we skip it when compiling specific files to avoid // unnecessary parsing and warnings from unrelated workflows - // Validate campaigns only if we're compiling campaign files - // When compiling specific non-campaign workflows, skip campaign validation - // When compiling specific campaign files, validate only those campaign files - if len(campaignFiles) > 0 { - if err := validateCampaignsWrapper(config.WorkflowDir, config.Verbose, config.Strict, campaignFiles); err != nil { - if config.Strict { - return err - } - } - } - // Save action cache (errors are logged but non-fatal) _ = saveActionCache(actionCache, config.Verbose) @@ -528,13 +466,6 @@ func runPostProcessingForDirectory( } } - // Validate campaigns - if err := validateCampaignsWrapper(config.WorkflowDir, config.Verbose, config.Strict, nil); err != nil { - if config.Strict { - return err - } - } - // Save action cache (errors are logged but non-fatal) _ = saveActionCache(actionCache, config.Verbose) diff --git a/pkg/cli/compile_orchestrator.go b/pkg/cli/compile_orchestrator.go index 522426ea658..97ac2a41020 100644 --- a/pkg/cli/compile_orchestrator.go +++ b/pkg/cli/compile_orchestrator.go @@ -5,231 +5,14 @@ import ( "fmt" "os" "path/filepath" - "strings" - "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/workflow" - "github.com/goccy/go-yaml" ) var compileOrchestratorLog = logger.New("cli:compile_orchestrator") -// getRepositoryRelativePath converts an absolute file path to a repository-relative path -// This ensures stable workflow identifiers regardless of where the repository is cloned -func getRepositoryRelativePath(absPath string) (string, error) { - // Get the repository root for the specific file - repoRoot, err := findGitRootForPath(absPath) - if err != nil { - // If we can't get the repo root, just use the basename as fallback - compileOrchestratorLog.Printf("Warning: could not get repository root for %s: %v, using basename", absPath, err) - return filepath.Base(absPath), nil - } - - // Convert both paths to absolute to ensure they can be compared - absPath, err = filepath.Abs(absPath) - if err != nil { - return "", fmt.Errorf("failed to get absolute path: %w", err) - } - - // Get the relative path from repo root - relPath, err := filepath.Rel(repoRoot, absPath) - if err != nil { - return "", fmt.Errorf("failed to get relative path: %w", err) - } - - // Normalize path separators to forward slashes for consistency across platforms - // This ensures the same hash value on Windows, Linux, and macOS - relPath = filepath.ToSlash(relPath) - - return relPath, nil -} - -func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, sourceCampaignPath string) string { - // Produce a conventional gh-aw workflow markdown file so users can review - // the generated orchestrator and recompile it like any other workflow. - // - // NOTE: The generated .campaign.g.md file is a debug artifact that is NOT - // committed to git (it's in .gitignore). Users can review it locally to - // understand the generated workflow structure. Only the source .campaign.md - // and the compiled .campaign.lock.yml files are committed. - b := &strings.Builder{} - b.WriteString("---\n") - if strings.TrimSpace(data.Name) != "" { - fmt.Fprintf(b, "name: %q\n", data.Name) - } - if strings.TrimSpace(data.Description) != "" { - fmt.Fprintf(b, "description: %q\n", data.Description) - } - if strings.TrimSpace(data.On) != "" { - b.WriteString(strings.TrimSuffix(data.On, "\n")) - b.WriteString("\n") - } - if strings.TrimSpace(data.Concurrency) != "" { - b.WriteString(strings.TrimSuffix(data.Concurrency, "\n")) - b.WriteString("\n") - } - - // Make the orchestrator runnable by default. - // Use engine from EngineConfig if available, otherwise default to claude. - engineID := "claude" - if data.EngineConfig != nil && data.EngineConfig.ID != "" { - engineID = data.EngineConfig.ID - } - fmt.Fprintf(b, "engine: %s\n", engineID) - - // Render safe-outputs if configured by the campaign orchestrator generator. - // Campaign orchestrators support dispatch-workflow, update-project, and create-project-status-update. - if data.SafeOutputs != nil { - // NOTE: We must emit the public frontmatter keys (e.g. "add-comment") rather - // than the internal struct YAML tags (e.g. "add-comments"). - outputs := map[string]any{} - if data.SafeOutputs.DispatchWorkflow != nil { - dispatchWorkflowConfig := map[string]any{ - "max": data.SafeOutputs.DispatchWorkflow.Max, - } - if len(data.SafeOutputs.DispatchWorkflow.Workflows) > 0 { - dispatchWorkflowConfig["workflows"] = data.SafeOutputs.DispatchWorkflow.Workflows - } - outputs["dispatch-workflow"] = dispatchWorkflowConfig - } - if data.SafeOutputs.UpdateProjects != nil { - updateProjectConfig := map[string]any{ - "max": data.SafeOutputs.UpdateProjects.Max, - } - if data.SafeOutputs.UpdateProjects.GitHubToken != "" { - updateProjectConfig["github-token"] = data.SafeOutputs.UpdateProjects.GitHubToken - } - outputs["update-project"] = updateProjectConfig - } - if data.SafeOutputs.CreateProjectStatusUpdates != nil { - createStatusUpdateConfig := map[string]any{ - "max": data.SafeOutputs.CreateProjectStatusUpdates.Max, - } - if data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken != "" { - createStatusUpdateConfig["github-token"] = data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken - } - outputs["create-project-status-update"] = createStatusUpdateConfig - } - if len(outputs) > 0 { - payload := map[string]any{"safe-outputs": outputs} - if out, err := yaml.Marshal(payload); err == nil { - b.WriteString(string(out)) - } else { - compileOrchestratorLog.Printf("Failed to render safe-outputs for generated campaign orchestrator: %v", err) - } - } - } - - // Intentionally omit permissions from generated campaign orchestrator frontmatter. - // Workflow/job permissions are handled during compilation. - if strings.TrimSpace(data.RunsOn) != "" { - b.WriteString(strings.TrimSuffix(data.RunsOn, "\n")) - b.WriteString("\n") - } - if len(data.Roles) > 0 { - b.WriteString("roles:\n") - for _, role := range data.Roles { - if strings.TrimSpace(role) == "" { - continue - } - fmt.Fprintf(b, " - %q\n", role) - } - } - // Render tools configuration if present - if len(data.Tools) > 0 { - payload := map[string]any{"tools": data.Tools} - if out, err := yaml.Marshal(payload); err == nil { - b.WriteString(string(out)) - } else { - compileOrchestratorLog.Printf("Failed to render tools for generated campaign orchestrator: %v", err) - } - } - // Render custom steps if present (e.g., discovery precomputation) - if strings.TrimSpace(data.CustomSteps) != "" { - // CustomSteps is already YAML-formatted, just write it as is - b.WriteString("steps:\n") - b.WriteString(data.CustomSteps) - } - b.WriteString("---\n\n") - // Include version for released builds only (not "dev", "dirty", or "test") - version := workflow.GetVersion() - if workflow.IsReleasedVersion(version) { - fmt.Fprintf(b, "\n", version) - } else { - b.WriteString("\n") - } - if strings.TrimSpace(sourceCampaignPath) != "" { - // Normalize path to be relative to git root (where .github folder exists) - // This ensures stable paths regardless of current working directory - relativePath := ToGitRootRelativePath(sourceCampaignPath) - fmt.Fprintf(b, "\n", relativePath) - } - b.WriteString("\n") - b.WriteString(strings.TrimSpace(data.MarkdownContent)) - b.WriteString("\n") - return b.String() -} - -// GenerateCampaignOrchestratorOptions holds the options for generateAndCompileCampaignOrchestrator -type GenerateCampaignOrchestratorOptions struct { - Compiler *workflow.Compiler - Spec *campaign.CampaignSpec - CampaignSpecPath string - Verbose bool - NoEmit bool - RunZizmorPerFile bool - RunPoutinePerFile bool - RunActionlintPerFile bool - Strict bool - ValidateActionSHAs bool -} - -func generateAndCompileCampaignOrchestrator(opts GenerateCampaignOrchestratorOptions) (string, error) { - data, orchestratorPath := campaign.BuildOrchestrator(opts.Spec, opts.CampaignSpecPath) - if data == nil || orchestratorPath == "" { - return "", nil - } - - // Ensure we pick a real engine in the YAML compiler path. - // Campaign orchestrators should default to claude (matching the orchestrator generator). - if strings.TrimSpace(data.AI) == "" { - engineID := "claude" - if data.EngineConfig != nil && strings.TrimSpace(data.EngineConfig.ID) != "" { - engineID = strings.TrimSpace(data.EngineConfig.ID) - } - data.AI = engineID - } - - if !opts.NoEmit { - content := renderGeneratedCampaignOrchestratorMarkdown(data, opts.CampaignSpecPath) - // Write with restrictive permissions (0600) to follow security best practices - if err := os.WriteFile(orchestratorPath, []byte(content), 0600); err != nil { - return "", fmt.Errorf("failed to write generated campaign orchestrator %s: %w", orchestratorPath, err) - } - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Generated campaign orchestrator %s", filepath.Base(orchestratorPath)))) - } - } - - // Prefer compiling from the generated markdown so defaults and validation behavior - // match normal workflows (including computed permissions). - if !opts.NoEmit { - if err := CompileWorkflowWithValidation(opts.Compiler, orchestratorPath, opts.Verbose, opts.RunZizmorPerFile, opts.RunPoutinePerFile, opts.RunActionlintPerFile, opts.Strict, opts.ValidateActionSHAs); err != nil { - return orchestratorPath, err - } - return orchestratorPath, nil - } - - // No-emit mode: compile from the in-memory WorkflowData. - if err := CompileWorkflowDataWithValidation(opts.Compiler, data, orchestratorPath, opts.Verbose, opts.RunZizmorPerFile, opts.RunPoutinePerFile, opts.RunActionlintPerFile, opts.Strict, opts.ValidateActionSHAs); err != nil { - return orchestratorPath, err - } - - return orchestratorPath, nil -} - // CompileWorkflows compiles workflows based on the provided configuration func CompileWorkflows(ctx context.Context, config CompileConfig) ([]*workflow.WorkflowData, error) { compileOrchestratorLog.Printf("Starting workflow compilation: files=%d, validate=%v, watch=%v, noEmit=%v", diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go index dd6f53df5c5..ff13dfdc7fb 100644 --- a/pkg/cli/compile_post_processing.go +++ b/pkg/cli/compile_post_processing.go @@ -1,7 +1,7 @@ // This file provides post-processing operations for workflow compilation. // // This file contains functions that perform post-compilation operations such as -// generating Dependabot manifests, maintenance workflows, and validating campaigns. +// generating Dependabot manifests and maintenance workflows. // // # Organization Rationale // @@ -17,9 +17,6 @@ // - generateDependabotManifestsWrapper() - Generate Dependabot manifests // - generateMaintenanceWorkflowWrapper() - Generate maintenance workflow // -// Validation: -// - validateCampaignsWrapper() - Validate campaign specs -// // Statistics: // - collectWorkflowStatisticsWrapper() - Collect workflow statistics // @@ -83,21 +80,6 @@ func generateMaintenanceWorkflowWrapper( return nil } -// validateCampaignsWrapper validates campaign specs if they exist -func validateCampaignsWrapper(workflowDir string, verbose bool, strict bool, campaignFiles []string) error { - compilePostProcessingLog.Print("Validating campaign specs") - - if err := validateCampaigns(workflowDir, verbose, campaignFiles); err != nil { - if strict { - return fmt.Errorf("campaign validation failed: %w", err) - } - // Non-strict mode: just report as warning - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Campaign validation: %v", err))) - } - - return nil -} - // collectWorkflowStatisticsWrapper collects and returns workflow statistics func collectWorkflowStatisticsWrapper(markdownFiles []string) []*WorkflowStats { compilePostProcessingLog.Printf("Collecting workflow statistics for %d files", len(markdownFiles)) diff --git a/pkg/cli/compile_workflow_processor.go b/pkg/cli/compile_workflow_processor.go index db4465b425f..8c07cfdc84e 100644 --- a/pkg/cli/compile_workflow_processor.go +++ b/pkg/cli/compile_workflow_processor.go @@ -1,13 +1,12 @@ // This file provides workflow file processing functions for compilation. // -// This file contains functions that process individual workflow files and -// campaign specs, handling both regular workflows and campaign orchestrators. +// This file contains functions that process individual workflow files. // // # Organization Rationale // // These workflow processing functions are grouped here because they: // - Handle per-file processing logic -// - Process both regular workflows and campaign specs +// - Process workflow files with compilation and validation // - Have a clear domain focus (workflow file processing) // - Keep the main orchestrator focused on batch operations // @@ -15,7 +14,6 @@ // // Workflow Processing: // - processWorkflowFile() - Process a single workflow markdown file -// - processCampaignSpec() - Process a campaign spec file // - collectLockFilesForLinting() - Collect lock files for batch linting // // These functions abstract per-file processing, allowing the main compile @@ -28,7 +26,6 @@ import ( "os" "path/filepath" - "github.com/githubnext/gh-aw/pkg/campaign" "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/stringutil" @@ -129,21 +126,6 @@ func compileWorkflowFile( } result.workflowData = workflowData - // Inject campaign orchestrator features if project field has campaign configuration - // This transforms the workflow into a campaign orchestrator in-place - if err := campaign.InjectOrchestratorFeatures(workflowData); err != nil { - errMsg := fmt.Sprintf("failed to inject campaign orchestrator features: %v", err) - if !jsonOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - result.validationResult.Valid = false - result.validationResult.Errors = append(result.validationResult.Errors, CompileValidationError{ - Type: "campaign_injection_error", - Message: err.Error(), - }) - return result - } - compileWorkflowProcessorLog.Printf("Starting compilation of %s", resolvedFile) // Compile the workflow @@ -165,93 +147,3 @@ func compileWorkflowFile( compileWorkflowProcessorLog.Printf("Successfully processed workflow file: %s", resolvedFile) return result } - -// ProcessCampaignSpecOptions holds the options for processCampaignSpec -type ProcessCampaignSpecOptions struct { - Compiler *workflow.Compiler - ResolvedFile string - Verbose bool - JSONOutput bool - NoEmit bool - Zizmor bool - Poutine bool - Actionlint bool - Strict bool - Validate bool -} - -// processCampaignSpec processes a campaign spec file -// Returns the validation result and success status -func processCampaignSpec(opts ProcessCampaignSpecOptions) (ValidationResult, bool) { - compileWorkflowProcessorLog.Printf("Processing campaign spec file: %s", opts.ResolvedFile) - - result := ValidationResult{ - Workflow: filepath.Base(opts.ResolvedFile), - Valid: true, - Errors: []CompileValidationError{}, - Warnings: []CompileValidationError{}, - } - - // Validate the campaign spec file and referenced workflows - spec, problems, vErr := campaign.ValidateSpecFromFile(opts.ResolvedFile) - if vErr != nil { - errMsg := fmt.Sprintf("failed to validate campaign spec %s: %v", opts.ResolvedFile, vErr) - if !opts.JSONOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - result.Valid = false - result.Errors = append(result.Errors, CompileValidationError{ - Type: "campaign_validation_error", - Message: vErr.Error(), - }) - return result, false - } - - // Also ensure that workflows referenced by the campaign spec exist - workflowsDir := filepath.Dir(opts.ResolvedFile) - workflowProblems := campaign.ValidateWorkflowsExist(spec, workflowsDir) - problems = append(problems, workflowProblems...) - - if len(problems) > 0 { - for _, p := range problems { - if !opts.JSONOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(p)) - } - result.Valid = false - result.Errors = append(result.Errors, CompileValidationError{ - Type: "campaign_validation_error", - Message: p, - }) - } - return result, false - } - - if opts.Verbose && !opts.JSONOutput { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Validated campaign spec %s", filepath.Base(opts.ResolvedFile)))) - } - - // Generate and compile the campaign orchestrator - if _, genErr := generateAndCompileCampaignOrchestrator(GenerateCampaignOrchestratorOptions{ - Compiler: opts.Compiler, - Spec: spec, - CampaignSpecPath: opts.ResolvedFile, - Verbose: opts.Verbose && !opts.JSONOutput, - NoEmit: opts.NoEmit, - RunZizmorPerFile: opts.Zizmor && !opts.NoEmit, - RunPoutinePerFile: opts.Poutine && !opts.NoEmit, - RunActionlintPerFile: opts.Actionlint && !opts.NoEmit, - Strict: opts.Strict, - ValidateActionSHAs: opts.Validate && !opts.NoEmit, - }); genErr != nil { - errMsg := fmt.Sprintf("failed to compile campaign orchestrator for %s: %v", filepath.Base(opts.ResolvedFile), genErr) - if !opts.JSONOutput { - fmt.Fprintln(os.Stderr, console.FormatErrorMessage(errMsg)) - } - result.Valid = false - result.Errors = append(result.Errors, CompileValidationError{Type: "campaign_orchestrator_error", Message: errMsg}) - return result, false - } - - compileWorkflowProcessorLog.Printf("Successfully processed campaign spec: %s", opts.ResolvedFile) - return result, true -} diff --git a/pkg/cli/copilot-agents.go b/pkg/cli/copilot-agents.go index bd639f0a103..c8d4bf4e10b 100644 --- a/pkg/cli/copilot-agents.go +++ b/pkg/cli/copilot-agents.go @@ -212,66 +212,6 @@ func ensureUpgradeAgenticWorkflowsPrompt(verbose bool, skipInstructions bool) er ) } -// ensureCampaignOrchestratorInstructions ensures that .github/aw/orchestrate-agentic-campaign.md exists -func ensureCampaignOrchestratorInstructions(verbose bool, skipInstructions bool) error { - return ensureFileMatchesTemplate( - filepath.Join(".github", "aw"), - "orchestrate-agentic-campaign.md", - campaignOrchestratorInstructionsTemplate, - "campaign orchestrator instructions", - verbose, - skipInstructions, - ) -} - -// ensureCampaignProjectUpdateInstructions ensures that .github/aw/update-agentic-campaign-project.md exists -func ensureCampaignProjectUpdateInstructions(verbose bool, skipInstructions bool) error { - return ensureFileMatchesTemplate( - filepath.Join(".github", "aw"), - "update-agentic-campaign-project.md", - campaignProjectUpdateInstructionsTemplate, - "campaign project update instructions", - verbose, - skipInstructions, - ) -} - -// ensureCampaignWorkflowExecution ensures that .github/aw/execute-agentic-campaign-workflow.md exists -func ensureCampaignWorkflowExecution(verbose bool, skipInstructions bool) error { - return ensureFileMatchesTemplate( - filepath.Join(".github", "aw"), - "execute-agentic-campaign-workflow.md", - campaignWorkflowExecutionTemplate, - "campaign workflow execution", - verbose, - skipInstructions, - ) -} - -// ensureCampaignClosingInstructions ensures that .github/aw/close-agentic-campaign.md exists -func ensureCampaignClosingInstructions(verbose bool, skipInstructions bool) error { - return ensureFileMatchesTemplate( - filepath.Join(".github", "aw"), - "close-agentic-campaign.md", - campaignClosingInstructionsTemplate, - "campaign closing instructions", - verbose, - skipInstructions, - ) -} - -// ensureCampaignGeneratorInstructions ensures that .github/aw/generate-agentic-campaign.md exists -func ensureCampaignGeneratorInstructions(verbose bool, skipInstructions bool) error { - return ensureFileMatchesTemplate( - filepath.Join(".github", "aw"), - "generate-agentic-campaign.md", - campaignGeneratorInstructionsTemplate, - "campaign generator instructions", - verbose, - skipInstructions, - ) -} - // deleteSetupAgenticWorkflowsAgent deletes the setup-agentic-workflows.agent.md file if it exists func deleteSetupAgenticWorkflowsAgent(verbose bool) error { gitRoot, err := findGitRoot() diff --git a/pkg/cli/git.go b/pkg/cli/git.go index ae2375f2894..54586dbfd30 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -203,16 +203,6 @@ func ensureGitAttributes() error { } } - // Remove old campaign.g.md entries if they exist (they're now in .gitignore) - for i := len(lines) - 1; i >= 0; i-- { - trimmedLine := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmedLine, ".github/workflows/*.campaign.g.md") { - gitLog.Print("Removing obsolete .campaign.g.md .gitattributes entry") - lines = append(lines[:i], lines[i+1:]...) - modified = true - } - } - if !modified { gitLog.Print(".gitattributes already contains required entries") return nil diff --git a/pkg/cli/gitattributes_test.go b/pkg/cli/gitattributes_test.go index fd70814529c..7c764dee429 100644 --- a/pkg/cli/gitattributes_test.go +++ b/pkg/cli/gitattributes_test.go @@ -68,11 +68,6 @@ func TestEnsureGitAttributes(t *testing.T) { existingContent: "*.md linguist-documentation=true\n.github/workflows/*.lock.yml linguist-generated=true\n*.txt text=auto\n", expectedContent: "*.md linguist-documentation=true\n.github/workflows/*.lock.yml linguist-generated=true merge=ours\n*.txt text=auto", }, - { - name: "removes obsolete campaign.g.md entry", - existingContent: ".github/workflows/*.lock.yml linguist-generated=true merge=ours\n.github/workflows/*.campaign.g.md linguist-generated=true merge=ours\n", - expectedContent: ".github/workflows/*.lock.yml linguist-generated=true merge=ours", - }, } for _, tt := range tests { diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 31d0ff00167..eb459c077ed 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -564,32 +564,6 @@ func InitRepository(verbose bool, mcp bool, campaign bool, tokens bool, engine s fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created upgrade workflows prompt")) } - // Write campaign dispatcher agent if requested - if campaign { - // Write campaign instruction files - initLog.Print("Writing campaign instruction files") - campaignEnsureFuncs := []struct { - fn func(bool, bool) error - name string - }{ - {ensureCampaignGeneratorInstructions, "campaign generator instructions"}, - {ensureCampaignOrchestratorInstructions, "campaign orchestrator instructions"}, - {ensureCampaignProjectUpdateInstructions, "campaign project update instructions"}, - {ensureCampaignWorkflowExecution, "campaign workflow execution"}, - {ensureCampaignClosingInstructions, "campaign closing instructions"}, - } - - for _, item := range campaignEnsureFuncs { - if err := item.fn(verbose, false); err != nil { - initLog.Printf("Failed to write %s: %v", item.name, err) - return fmt.Errorf("failed to write %s: %w", item.name, err) - } - } - if verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created campaign instruction files")) - } - } - // Configure MCP if requested if mcp { initLog.Print("Configuring GitHub Copilot Agent MCP integration") @@ -832,11 +806,6 @@ func ensureMaintenanceWorkflow(verbose bool) error { // Parse all workflows to collect WorkflowData var workflowDataList []*workflow.WorkflowData for _, file := range files { - // Skip campaign specs and generated files - if strings.HasSuffix(file, ".campaign.md") || strings.HasSuffix(file, ".campaign.g.md") { - continue - } - initLog.Printf("Parsing workflow: %s", file) workflowData, err := compiler.ParseWorkflowFile(file) if err != nil { diff --git a/pkg/cli/run_workflow_validation.go b/pkg/cli/run_workflow_validation.go index 00169f31e8f..7a523cc17ab 100644 --- a/pkg/cli/run_workflow_validation.go +++ b/pkg/cli/run_workflow_validation.go @@ -22,14 +22,6 @@ var validationLog = logger.New("cli:run_workflow_validation") // getLockFilePath converts a markdown workflow path to its compiled lock file path // Example: "/path/to/workflow.md" -> "/path/to/workflow.lock.yml" func getLockFilePath(markdownPath string) string { - // Handle campaign orchestrator files - if strings.HasSuffix(markdownPath, ".campaign.g.md") { - return strings.TrimSuffix(markdownPath, ".campaign.g.md") + ".campaign.lock.yml" - } - // Handle regular campaign files - if strings.HasSuffix(markdownPath, ".campaign.md") { - return strings.TrimSuffix(markdownPath, ".campaign.md") + ".campaign.lock.yml" - } // Handle regular workflow files return strings.TrimSuffix(markdownPath, ".md") + ".lock.yml" } diff --git a/pkg/cli/run_workflow_validation_test.go b/pkg/cli/run_workflow_validation_test.go index 1231f47e1a2..8a34825fbd4 100644 --- a/pkg/cli/run_workflow_validation_test.go +++ b/pkg/cli/run_workflow_validation_test.go @@ -23,16 +23,6 @@ func TestGetLockFilePath(t *testing.T) { markdownPath: "/path/to/workflow.md", expected: "/path/to/workflow.lock.yml", }, - { - name: "campaign workflow", - markdownPath: "/path/to/campaign.campaign.md", - expected: "/path/to/campaign.campaign.lock.yml", - }, - { - name: "campaign orchestrator", - markdownPath: "/path/to/campaign.campaign.g.md", - expected: "/path/to/campaign.campaign.lock.yml", - }, { name: "workflow in nested directory", markdownPath: "/path/to/workflows/nested/workflow.md", diff --git a/pkg/cli/templates/close-agentic-campaign.md b/pkg/cli/templates/close-agentic-campaign.md deleted file mode 100644 index f08b2179660..00000000000 --- a/pkg/cli/templates/close-agentic-campaign.md +++ /dev/null @@ -1,20 +0,0 @@ -# Closing Instructions (Highest Priority) - -Execute all four steps in strict order: - -1. Read State (no writes) -2. Make Decisions (no writes) -3. Apply Updates (writes) -4. Report - -The following rules are mandatory and override inferred behavior: - -- The GitHub Project board is the single source of truth. -- All project writes MUST comply with the Project Update Instructions. -- State reads and state writes MUST NOT be interleaved. -- Do NOT infer missing data or invent values. -- Do NOT reorganize hierarchy. -- Do NOT overwrite fields except as explicitly allowed. -- Workers are immutable and campaign-agnostic. - -If any instruction conflicts, the Project Update Instructions take precedence for all writes. diff --git a/pkg/cli/templates/execute-agentic-campaign-workflow.md b/pkg/cli/templates/execute-agentic-campaign-workflow.md deleted file mode 100644 index 921a6120647..00000000000 --- a/pkg/cli/templates/execute-agentic-campaign-workflow.md +++ /dev/null @@ -1,284 +0,0 @@ -# Workflow Execution - -This campaign references the following campaign workers. These workers follow the first-class worker pattern: they are dispatch-only workflows with standardized input contracts. - -**IMPORTANT: Workers are orchestrated, not autonomous. They accept `campaign_id` and `payload` inputs via workflow_dispatch.** - ---- - -## Campaign Workers - -{{ if .Workflows }} -The following campaign workers are referenced by this campaign: -{{ range $idx, $workflow := .Workflows }} -{{ add1 $idx }}. `{{ $workflow }}` -{{ end }} -{{ end }} - -**Worker Pattern**: All workers MUST: -- Use `workflow_dispatch` as the ONLY trigger (no schedule/push/pull_request) -- Accept `campaign_id` (string) and `payload` (string; JSON) inputs -- Implement idempotency via deterministic work item keys -- Label all created items with `z_campaign_{{ .CampaignID }}` - ---- - -## Workflow Creation Guardrails - -### Before Creating Any Worker Workflow, Ask: - -1. **Does this workflow already exist?** - Check `.github/workflows/` thoroughly -2. **Can an existing workflow be adapted?** - Even if not perfect, existing is safer -3. **Is the requirement clear?** - Can you articulate exactly what it should do? -4. **Is it testable?** - Can you verify it works with test inputs? -5. **Is it reusable?** - Could other campaigns benefit from this worker? - -### Only Create New Workers When: - -✅ **All these conditions are met:** -- No existing workflow does the required task -- The campaign objective explicitly requires this capability -- You have a clear, specific design for the worker -- The worker has a focused, single-purpose scope -- You can test it independently before campaign use - -❌ **Never create workers when:** -- You're unsure about requirements -- An existing workflow "mostly" works -- The worker would be complex or multi-purpose -- You haven't verified it doesn't already exist -- You can't clearly explain what it does in one sentence - ---- - -## Worker Creation Template - -If you must create a new worker (only after checking ALL guardrails above), use this template: - -**Create the workflow file at `.github/workflows/.md`:** - -```yaml ---- -name: -description: - -on: - workflow_dispatch: - inputs: - campaign_id: - description: 'Campaign identifier' - required: true - type: string - payload: - description: 'JSON payload with work item details' - required: true - type: string - -tracker-id: - -tools: - github: - toolsets: [default] - # Add minimal additional tools as needed - -safe-outputs: - create-pull-request: - max: 1 # Start conservative - add-comment: - max: 2 ---- - -# - -You are a campaign worker that processes work items. - -## Input Contract - -Parse inputs: -```javascript -const campaignId = context.payload.inputs.campaign_id; -const payload = JSON.parse(context.payload.inputs.payload); -``` - -Expected payload structure: -```json -{ - "repository": "owner/repo", - "work_item_id": "unique-id", - "target_ref": "main", - // Additional context... -} -``` - -## Idempotency Requirements - -1. **Generate deterministic key**: - ``` - const workKey = `campaign-${campaignId}-${payload.repository}-${payload.work_item_id}`; - ``` - -2. **Check for existing work**: - - Search for PRs/issues with `workKey` in title - - Filter by label: `z_campaign_${campaignId}` - - If found: Skip or update - - If not: Create new - -3. **Label all created items**: - - Apply `z_campaign_${campaignId}` label - - This enables discovery by orchestrator - -## Task - - - -## Output - -Report: -- Link to created/updated PR or issue -- Whether work was skipped (exists) or completed -- Any errors or blockers -``` - -**After creating:** -- Compile: `gh aw compile .md` -- **CRITICAL: Test with sample inputs** (see testing requirements below) - ---- - -## Worker Testing (MANDATORY) - -**Why test?** - Untested workers may fail during campaign execution. Test with sample inputs first to catch issues early. - -**Testing steps:** - -1. **Prepare test payload**: - ```json - { - "repository": "test-org/test-repo", - "work_item_id": "test-1", - "target_ref": "main" - } - ``` - -2. **Trigger test run**: - ```bash - gh workflow run .yml \ - -f campaign_id={{ .CampaignID }} \ - -f payload='{"repository":"test-org/test-repo","work_item_id":"test-1"}' - ``` - - Or via GitHub MCP: - ```javascript - mcp__github__run_workflow( - workflow_id: "", - ref: "main", - inputs: { - campaign_id: "{{ .CampaignID }}", - payload: JSON.stringify({repository: "test-org/test-repo", work_item_id: "test-1"}) - } - ) - ``` - -3. **Wait for completion**: Poll until status is "completed" - -4. **Verify success**: - - Check that workflow succeeded - - Verify idempotency: Run again with same inputs, should skip/update - - Review created items have correct labels - - Confirm deterministic keys are used - -5. **Test failure actions**: - - DO NOT use the worker if testing fails - - Analyze failure logs - - Make corrections - - Recompile and retest - - If unfixable after 2 attempts, report in status and skip - -**Note**: Workflows that accept `workflow_dispatch` inputs can receive parameters from the orchestrator. This enables the orchestrator to provide context, priorities, or targets based on its decisions. See [DispatchOps documentation](https://githubnext.github.io/gh-aw/guides/dispatchops/#with-input-parameters) for input parameter examples. - ---- - -## Orchestration Guidelines - -**Execution pattern:** -- Workers are **orchestrated, not autonomous** -- Orchestrator discovers work items via discovery manifest -- Orchestrator decides which workers to run and with what inputs -- Workers receive `campaign_id` and `payload` via workflow_dispatch -- Sequential vs parallel execution is orchestrator's decision - -**Worker dispatch:** -- Parse discovery manifest (`./.gh-aw/campaign.discovery.json`) -- For each work item needing processing: - 1. Determine appropriate worker for this item type - 2. Construct payload with work item details - 3. Dispatch worker via workflow_dispatch with campaign_id and payload - 4. Track dispatch status - -**Input construction:** -```javascript -// Example: Dispatching security-fix worker -const workItem = discoveryManifest.items[0]; -const payload = { - repository: workItem.repo, - work_item_id: `alert-${workItem.number}`, - target_ref: "main", - alert_type: "sql-injection", - file_path: "src/db.go", - line_number: 42 -}; - -await github.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: "security-fix-worker.yml", - ref: "main", - inputs: { - campaign_id: "{{ .CampaignID }}", - payload: JSON.stringify(payload) - } -}); -``` - -**Idempotency by design:** -- Workers implement their own idempotency checks -- Orchestrator doesn't need to track what's been processed -- Can safely re-dispatch work items across runs -- Workers will skip or update existing items - -**Failure handling:** -- If a worker dispatch fails, note it but continue -- Worker failures don't block entire campaign -- Report all failures in status update with context -- Humans can intervene if needed - ---- - -## After Worker Orchestration - -Once workers have been dispatched (or new workers created and tested), proceed with normal orchestrator steps: - -1. **Discovery** - Read state from discovery manifest and project board -2. **Planning** - Determine what needs updating on project board -3. **Project Updates** - Write state changes to project board -4. **Status Reporting** - Report progress, worker dispatches, failures, next steps - ---- - -## Key Differences from Fusion Approach - -**Old fusion approach (REMOVED)**: -- Workers had mixed triggers (schedule + workflow_dispatch) -- Fusion dynamically added workflow_dispatch to existing workflows -- Workers stored in campaign-specific folders -- Ambiguous ownership and trigger precedence - -**New first-class worker approach**: -- Workers are dispatch-only (on: workflow_dispatch) -- Standardized input contract (campaign_id, payload) -- Explicit idempotency via deterministic keys -- Clear ownership: workers are orchestrated, not autonomous -- Workers stored with regular workflows (not campaign-specific folders) -- Orchestration policy kept explicit in orchestrator - -This eliminates duplicate execution problems and makes orchestration concerns explicit. diff --git a/pkg/cli/templates/generate-agentic-campaign.md b/pkg/cli/templates/generate-agentic-campaign.md deleted file mode 100644 index 56b7cd9e2e8..00000000000 --- a/pkg/cli/templates/generate-agentic-campaign.md +++ /dev/null @@ -1,210 +0,0 @@ -# Campaign Generator - -You are a campaign workflow coordinator for GitHub Agentic Workflows. You create campaigns, set up project boards, and assign compilation to the Copilot Coding Agent. - -**Issue Context:** Read the campaign requirements from the issue that triggered this workflow (via the `create-agentic-campaign` label). - -## Using Safe Output Tools - -When creating or modifying GitHub resources, **use MCP tool calls directly** (not markdown or JSON): - -- `create_project` - Create project board -- `update_project` - Create/update project fields, views, and items -- `update_issue` - Update issue details -- `create_agent_session` - Create a Copilot coding agent session (preferred handoff) -- `assign_to_agent` - Assign to agent (optional; use for existing issues/PRs) - -## Workflow - -**Your Responsibilities:** - -1. Create GitHub Project -2. Create views: Roadmap (roadmap), Task Tracker (table), Progress Board (board) -3. Create required campaign project fields (see “Project Fields (Required)”) using `update_project` with `operation: "create_fields"` -4. Parse campaign requirements from the triggering issue (available via GitHub event context) -5. Discover workflows: scan `.github/workflows/*.md` and check [agentics collection](https://github.com/githubnext/agentics) -6. Generate `.campaign.md` spec in `.github/workflows/` -7. Update the triggering issue with a human-readable status + Copilot Coding Agent instructions -8. Create a Copilot coding agent session (preferred) or assign to agent (fallback) - -**Agent Responsibilities:** Compile with `gh aw compile`, commit files, create PR - -## Campaign Spec Format - -```yaml ---- -id: -name: -description: -project-url: -workflows: [, ] -scope: [owner/repo1, owner/repo2, org:org-name] # Optional: defaults to current repository -owners: [@] -risk-level: -state: planned -allowed-safe-outputs: [create-issue, add-comment] ---- - -# - - - -## Workflows - -### - - -## Timeline -- **Start**: -- **Target**: -``` - -## Key Guidelines - -## Project Fields (Required) - -Campaign orchestrators and project-updaters assume these fields exist. Create them up-front with `update_project` using `operation: "create_fields"` and `field_definitions` so single-select options are created correctly (GitHub does not support adding options later). - -Required fields: - -- `status` (single-select): `Todo`, `In Progress`, `Review required`, `Blocked`, `Done` -- `campaign_id` (text) -- `worker_workflow` (text) -- `target_repo` (text, `owner/repo`) -- `priority` (single-select): `High`, `Medium`, `Low` -- `size` (single-select): `Small`, `Medium`, `Large` -- `start_date` (date, `YYYY-MM-DD`) -- `end_date` (date, `YYYY-MM-DD`) - -Create them before adding any items to the project. - -## Copilot Coding Agent Handoff (Required) - -Before creating an agent session, update the triggering issue (via `update_issue`) to include a clear, human-friendly status update. - -The issue update MUST be easy to follow for someone unfamiliar with campaigns. Include: - -- What you did (Project created, fields/views created, spec generated) -- What you are about to do next (handoff to agent) -- What the human should do next (review PR, merge, run orchestrator) -- Links to documentation - -Use `update_issue` with `operation: "append"` so you **do not overwrite** the original issue text. - -Docs to link: -- Getting started: https://githubnext.github.io/gh-aw/guides/campaigns/getting-started/ -- Flow & lifecycle: https://githubnext.github.io/gh-aw/guides/campaigns/flow/ -- Campaign specs: https://githubnext.github.io/gh-aw/guides/campaigns/scratchpad/ - -### Required structure for the issue update - -Add a section like this (fill in real values): - -```markdown -## Campaign setup status - -**Status:** Ready for PR review - -### What just happened -- Created Project: -- Created standard fields + views (Roadmap, Task Tracker, Progress Board) -- Generated campaign spec: `.github/workflows/.campaign.md` -- Selected workflows: ``, `` - -### What happens next -1. Copilot Coding Agent will open a pull request with the generated files (via agent session). -2. You review the PR and merge it. -3. After merge, run the orchestrator workflow from the Actions tab. - -### Copilot Coding Agent handoff -- **Campaign ID:** `` -- **Project URL:** -- **Workflows:** ``, `` -- **Agent session:** - -Run: -```bash -gh aw compile -``` - -Commit + include in the PR: -- `.github/workflows/.campaign.md` -- `.github/workflows/.campaign.g.md` -- `.github/workflows/.campaign.lock.yml` - -Acceptance checklist: -- `gh aw compile` succeeds -- Orchestrator lock file updated -- PR opened and linked back to this issue - -Docs: -- https://githubnext.github.io/gh-aw/guides/campaigns/getting-started/ -- https://githubnext.github.io/gh-aw/guides/campaigns/flow/ -``` - -### Minimum handoff requirements - -In addition to the structure above, include these exact items: - -- The generated `campaign-id` and `project-url` -- The list of selected workflow IDs -- Exact commands for the agent to run (at minimum): `gh aw compile` -- What files must be committed (the new `.github/workflows/.campaign.md`, generated `.campaign.g.md`, and compiled `.campaign.lock.yml`) -- A short acceptance checklist (e.g., “`gh aw compile` succeeds; lock file updated; PR opened”) - -**Campaign ID:** Convert names to kebab-case (e.g., "Security Q1 2025" → "security-q1-2025"). Check for conflicts in `.github/workflows/`. - -**Allowed Repos/Orgs (Required):** - -- `scope`: **Optional** - Scope selectors for repos and orgs this campaign can discover and operate on (defaults to current repo) -- Defines campaign scope as a reviewable contract for security and governance - -**Workflow Discovery:** - -- Scan existing: `.github/workflows/*.md` (agentic), `*.yml` (regular) -- Match by keywords: security, dependency, documentation, quality, CI/CD -- Select 2-4 workflows (prioritize existing, identify AI enhancement candidates) - -**Safe Outputs (Least Privilege):** - -- For this campaign generator workflow, use `update-issue` for status updates (this workflow does not enable `add-comment`). -- Project-based: `create-project`, `update-project`, `update-issue`, `create-agent-session` (preferred) - -**Operation Order for Project Setup:** - -1. `create-project` (creates project + views) -2. `update-project` (adds items/fields) -3. `update-issue` (updates metadata, optional) -4. `create-agent-session` (preferred) or `assign-to-agent` (fallback) - -**Example Safe Outputs Configuration for Project-Based Campaigns:** - -```yaml -safe-outputs: - create-project: - max: 1 - github-token: "" # Provide via workflow secret/env; avoid secrets expressions in runtime-import files - target-owner: "${{ github.repository_owner }}" - views: # Views are created automatically when project is created - - name: "Campaign Roadmap" - layout: "roadmap" - filter: "is:issue is:pr" - - name: "Task Tracker" - layout: "table" - filter: "is:issue is:pr" - - name: "Progress Board" - layout: "board" - filter: "is:issue is:pr" - update-project: - max: 10 - github-token: "" # Provide via workflow secret/env; avoid secrets expressions in runtime-import files - update-issue: - create-agent-session: - base: "${{ github.ref_name }}" # Prefer main/default branch when appropriate -``` - -**Risk Levels:** - -- High: Sensitive/multi-repo/breaking → 2 approvals + sponsor -- Medium: Cross-repo/automated → 1 approval -- Low: Read-only/single repo → No approval diff --git a/pkg/cli/templates/orchestrate-agentic-campaign.md b/pkg/cli/templates/orchestrate-agentic-campaign.md deleted file mode 100644 index 9f758036159..00000000000 --- a/pkg/cli/templates/orchestrate-agentic-campaign.md +++ /dev/null @@ -1,165 +0,0 @@ -# Orchestrator Instructions - -This orchestrator coordinates a single campaign by discovering worker outputs and making deterministic decisions. - -**Scope:** orchestration + project sync + reporting (discovery, planning, pacing, writing, reporting). -**Actuation model:** **hybrid** — the orchestrator may update campaign state directly (Projects and status updates) and may also dispatch allowlisted worker workflows. -**Write authority:** the orchestrator may write GitHub state when explicitly allowlisted via safe outputs; delegate repo/code changes (e.g., PRs) to workers unless this campaign explicitly defines otherwise. - ---- - -## Traffic and Rate Limits (Required) - -- Minimize API calls; avoid full rescans when possible. -- Prefer incremental discovery with deterministic ordering (e.g., by `updatedAt`, tie-break by ID). -- Enforce strict pagination budgets; if a query requires many pages, stop early and continue next run. -- Use a durable cursor/checkpoint so the next run continues without rescanning. -- On throttling (HTTP 429 / rate-limit 403), do not retry aggressively; back off and end the run after reporting what remains. - -{{ if .CursorGlob }} -**Cursor file (repo-memory)**: `{{ .CursorGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/cursor.json` -- If it exists: read first and continue from its boundary. -- If it does not exist: create it by end of run. -- Always write the updated cursor back to the same path. -{{ end }} - -{{ if .MetricsGlob }} -**Metrics snapshots (repo-memory)**: `{{ .MetricsGlob }}` -**File system path**: `/tmp/gh-aw/repo-memory/campaigns/{{.CampaignID}}/metrics/*.json` -- Persist one append-only JSON metrics snapshot per run (new file per run; do not rewrite history). -- Use UTC date (`YYYY-MM-DD`) in the filename (example: `metrics/2025-12-22.json`). -{{ end }} - -{{ if gt .MaxDiscoveryItemsPerRun 0 }} -**Read budget**: max discovery items per run: {{ .MaxDiscoveryItemsPerRun }} -{{ end }} -{{ if gt .MaxDiscoveryPagesPerRun 0 }} -**Read budget**: max discovery pages per run: {{ .MaxDiscoveryPagesPerRun }} -{{ end }} - ---- - -## Core Principles - -1. Workers are immutable and campaign-agnostic -2. The GitHub Project board is the authoritative campaign state -3. Correlation is explicit (tracker-id AND labels) -4. Reads and writes are separate steps (never interleave) -5. Idempotent operation is mandatory (safe to re-run) -6. Orchestrator writes must be deterministic and minimal - ---- - -## Execution Steps (Required Order) - -### Step 1 — Read State (Discovery) [NO WRITES] - -**IMPORTANT**: Discovery has been precomputed. Read the discovery manifest instead of performing GitHub-wide searches. - -1) Read the precomputed discovery manifest: `./.gh-aw/campaign.discovery.json` - -2) Parse discovered items from the manifest: - - Each item has: url, content_type (issue/pull_request/discussion), number, repo, created_at, updated_at, state - - Closed items have: closed_at (for issues) or merged_at (for PRs) - - Items are pre-sorted by updated_at for deterministic processing - -3) Check the manifest summary for work counts. - -4) Discovery cursor is maintained automatically in repo-memory; do not modify it manually. - -### Step 2 — Make Decisions (Planning) [NO WRITES] - -5) Determine desired `status` strictly from explicit GitHub state: -- Open → `Todo` (or `In Progress` only if explicitly indicated elsewhere) -- Closed (issue/discussion) → `Done` -- Merged (PR) → `Done` - -6) Calculate required date fields (for workers that sync Projects): -- `start_date`: format `created_at` as `YYYY-MM-DD` -- `end_date`: - - if closed/merged → format `closed_at`/`merged_at` as `YYYY-MM-DD` - - if open → **today's date** formatted `YYYY-MM-DD` - -7) Reads and writes are separate steps (never interleave). - -### Step 3 — Apply Updates (Execution) [WRITES] - -8) Apply required GitHub state updates in a single write phase. - -Allowed writes (when allowlisted via safe outputs): -- Update the campaign Project board (add/update items and fields) -- Post status updates (e.g., update an issue or add a comment) -- Create Copilot agent sessions for repo-side work (use when you need code changes) - -Constraints: -- Use only allowlisted safe outputs. -- Keep within configured max counts and API budgets. -- Do not interleave reads and writes. - -### Step 4 — Dispatch Workers (Optional) [DISPATCH] - -9) For repo-side actions (e.g., code changes), dispatch allowlisted worker workflows using `dispatch-workflow`. - -Constraints: -- Only dispatch allowlisted workflows. -- Keep within the dispatch-workflow max for this run. - -### Step 5 — Report - -10) Summarize what you updated and/or dispatched, what remains, and what should run next. - - **Discovered:** 25 items (15 issues, 10 PRs) - **Processed:** 10 items added to project, 5 updated - **Completion:** 60% (30/50 total tasks) - - ## Most Important Findings - - 1. **Critical accessibility gaps identified**: 3 high-severity accessibility issues discovered in mobile navigation, requiring immediate attention - 2. **Documentation coverage acceleration**: Achieved 5% improvement in one week (best velocity so far) - 3. **Worker efficiency improving**: daily-doc-updater now processing 40% more items per run - - ## What Was Learned - - - Multi-device testing reveals issues that desktop-only testing misses - should be prioritized - - Documentation updates tied to code changes have higher accuracy and completeness - - Users report fewer issues when examples include error handling patterns - - ## Campaign Progress - - **Documentation Coverage** (Primary Metric): - - Baseline: 85% → Current: 88% → Target: 95% - - Direction: ↑ Increasing (+3% this week, +1% velocity/week) - - Status: ON TRACK - At current velocity, will reach 95% in 7 weeks - - **Accessibility Score** (Supporting Metric): - - Baseline: 90% → Current: 91% → Target: 98% - - Direction: ↑ Increasing (+1% this month) - - Status: AT RISK - Slower progress than expected, may need dedicated focus - - **User-Reported Issues** (Supporting Metric): - - Baseline: 15/month → Current: 12/month → Target: 5/month - - Direction: ↓ Decreasing (-3 this month, -20% velocity) - - Status: ON TRACK - Trending toward target - - ## Next Steps - - 1. Address 3 critical accessibility issues identified this run (high priority) - 2. Continue processing remaining 15 discovered items - 3. Focus on accessibility improvements to accelerate supporting KPI - 4. Maintain current documentation coverage velocity -``` - -12) Report: -- counts discovered (by type) -- counts processed this run (by action: add/status_update/backfill/noop/failed) -- counts deferred due to budgets -- failures (with reasons) -- completion state (work items only) -- cursor advanced / remaining backlog estimate - ---- - -## Authority - -If any instruction in this file conflicts with **Project Update Instructions**, the Project Update Instructions win for all project writes. diff --git a/pkg/cli/templates/update-agentic-campaign-project.md b/pkg/cli/templates/update-agentic-campaign-project.md deleted file mode 100644 index 0b35d0272a6..00000000000 --- a/pkg/cli/templates/update-agentic-campaign-project.md +++ /dev/null @@ -1,247 +0,0 @@ -{{if .ProjectURL}} -# Project Update Instructions (Authoritative Write Contract) - -## Project Board Integration - -This file defines the ONLY allowed rules for writing to the GitHub Project board. -If any other instructions conflict with this file, THIS FILE TAKES PRECEDENCE for all project writes. - ---- - -## 0) Hard Requirements (Do Not Deviate) - -- Any workflow performing project writes (orchestrators or workers) MUST use only the `update-project` safe-output. -- All writes MUST target exactly: - - **Project URL**: `{{.ProjectURL}}` -- Every item MUST include: - - `campaign_id: "{{.CampaignID}}"` - -## Campaign ID - -All campaign tracking MUST key off `campaign_id: "{{.CampaignID}}"`. - ---- - -## 1) Required Project Fields (Must Already Exist) - -| Field | Type | Allowed / Notes | -|---|---|---| -| `status` | single-select | `Todo` / `In Progress` / `Review required` / `Blocked` / `Done` | -| `campaign_id` | text | Must equal `{{.CampaignID}}` | -| `worker_workflow` | text | workflow ID or `"unknown"` | -| `target_repo` | text | `owner/repo` | -| `priority` | single-select | `High` / `Medium` / `Low` | -| `size` | single-select | `Small` / `Medium` / `Large` | -| `start_date` | date | `YYYY-MM-DD` | -| `end_date` | date | `YYYY-MM-DD` | - -Field names are case-sensitive. - ---- - -## 2) Content Identification (Mandatory) - -Use **content number** (integer), never the URL as an identifier. - -- Issue URL: `.../issues/123` → `content_type: "issue"`, `content_number: 123` -- PR URL: `.../pull/456` → `content_type: "pull_request"`, `content_number: 456` - ---- - -## 3) Deterministic Field Rules (No Inference) - -These rules apply to any time you write fields: - -- `campaign_id`: always `{{.CampaignID}}` -- `worker_workflow`: workflow ID if known, else `"unknown"` -- `target_repo`: extract `owner/repo` from the issue/PR URL -- `priority`: default `Medium` unless explicitly known -- `size`: default `Medium` unless explicitly known -- `start_date`: issue/PR `created_at` formatted `YYYY-MM-DD` -- `end_date`: - - if closed/merged → `closed_at` / `merged_at` formatted `YYYY-MM-DD` - - if open → **today’s date** formatted `YYYY-MM-DD` (**required for roadmap view; do not leave blank**) - -For open items, `end_date` is a UI-required placeholder and does NOT represent actual completion. - ---- - -## 4) Read-Write Separation (Prevents Read/Write Mixing) - -1. **READ STEP (no writes)** — validate existence and gather metadata -2. **WRITE STEP (writes only)** — execute `update-project` - -Never interleave reads and writes. - ---- - -## 5) Adding an Issue or PR (First Write) - -### Adding New Issues - -When first adding an item to the project, you MUST write ALL required fields. - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Todo" # "Done" if already closed/merged - campaign_id: "{{.CampaignID}}" - worker_workflow: "unknown" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-03" -``` - ---- - -## 6) Updating an Existing Item (Minimal Writes) - -### Updating Existing Items - -Preferred behavior is minimal, idempotent writes: - -- If item exists and `status` is unchanged → **No-op** -- If item exists and `status` differs → **Update `status` only** -- If any required field is missing/empty/invalid → **One-time full backfill** (repair only) - -### Status-only Update (Default) - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" -``` - -### Full Backfill (Repair Only) - -```yaml -update-project: - project: "{{.ProjectURL}}" - campaign_id: "{{.CampaignID}}" - content_type: "issue" # or "pull_request" - content_number: 123 - fields: - status: "Done" - campaign_id: "{{.CampaignID}}" - worker_workflow: "WORKFLOW_ID" - target_repo: "owner/repo" - priority: "Medium" - size: "Medium" - start_date: "2025-12-15" - end_date: "2026-01-02" -``` - ---- - -## 7) Idempotency Rules - -- Matching status already set → **No-op** -- Different status → **Status-only update** -- Invalid/deleted/inaccessible URL → **Record failure and continue** - -## Write Operation Rules - -All writes MUST conform to this file and use `update-project` only. - ---- - -## 8) Logging + Failure Handling (Mandatory) - -For every attempted item, record: - -- `content_type`, `content_number`, `target_repo` -- action taken: `noop | add | status_update | backfill | failed` -- error details if failed - -Failures must not stop processing remaining items. - ---- - -## 9) Worker Workflow Policy - -- Workers are campaign-agnostic. -- Orchestrator populates `worker_workflow`. -- If `worker_workflow` cannot be determined, it MUST remain `"unknown"` unless explicitly reclassified by the orchestrator. - ---- - -## 10) Parent / Sub-Issue Rules (Campaign Hierarchy) - -- Each project board MUST have exactly **one Epic issue** representing the campaign. -- The Epic issue MUST: - - Be added to the project board - - Use the same `campaign_id` - - Use `worker_workflow: "unknown"` - -- All campaign work issues (non-epic) MUST be created as **sub-issues of the Epic**. -- Issues MUST NOT be re-parented based on worker assignment. - -- Pull requests cannot be sub-issues: - - PRs MUST reference their related issue via standard GitHub linking (e.g. “Closes #123”). - -- Worker grouping MUST be done via the `worker_workflow` project field, not via parent issues. - -- The Epic issue is narrative only. -- The project board is the sole authoritative source of campaign state. - ---- - -## Appendix — Machine Check Checklist (Optional) - -This checklist is designed to validate outputs before executing project writes. - -### A) Output Structure Checks - -- [ ] All writes use `update-project:` blocks (no other write mechanism). -- [ ] Each `update-project` block includes: - - [ ] `project: "{{.ProjectURL}}"` - - [ ] `campaign_id: "{{.CampaignID}}"` (top-level) - - [ ] `content_type` ∈ {`issue`, `pull_request`} - - [ ] `content_number` is an integer - - [ ] `fields` object is present - -### B) Field Validity Checks - -- [ ] `fields.status` ∈ {`Todo`, `In Progress`, `Review required`, `Blocked`, `Done`} -- [ ] `fields.campaign_id` is present on first-add/backfill and equals `{{.CampaignID}}` -- [ ] `fields.worker_workflow` is present on first-add/backfill and is either a known workflow ID or `"unknown"` -- [ ] `fields.target_repo` matches `owner/repo` -- [ ] `fields.priority` ∈ {`High`, `Medium`, `Low`} -- [ ] `fields.size` ∈ {`Small`, `Medium`, `Large`} -- [ ] `fields.start_date` matches `YYYY-MM-DD` -- [ ] `fields.end_date` matches `YYYY-MM-DD` - -### C) Update Semantics Checks - -- [ ] For existing items, payload is **status-only** unless explicitly doing a backfill repair. -- [ ] Backfill is used only when required fields are missing/empty/invalid. -- [ ] No payload overwrites `priority`/`size`/`worker_workflow` with defaults during a normal status update. - -### D) Read-Write Separation Checks - -- [ ] All reads occur before any writes (no read/write interleaving). -- [ ] Writes are batched separately from discovery. - -### E) Epic/Hierarchy Checks (Policy-Level) - -- [ ] Exactly one Epic exists for the campaign board. -- [ ] Epic is on the board and uses `worker_workflow: "unknown"`. -- [ ] All campaign work issues are sub-issues of the Epic (if supported by environment/tooling). -- [ ] PRs are linked to issues via GitHub linking (e.g. “Closes #123”). - -### F) Failure Handling Checks - -- [ ] Invalid/deleted/inaccessible items are logged as failures and processing continues. -- [ ] Idempotency is delegated to the `update-project` tool; no pre-filtering by board presence. - -{{end}} diff --git a/pkg/stringutil/identifiers.go b/pkg/stringutil/identifiers.go index 306f015b48b..92099832125 100644 --- a/pkg/stringutil/identifiers.go +++ b/pkg/stringutil/identifiers.go @@ -103,43 +103,27 @@ func LockFileToMarkdown(lockPath string) string { } // IsAgenticWorkflow returns true if the file path is an agentic workflow file. -// Agentic workflows end with .md but exclude campaign spec files (.campaign.md) -// and campaign orchestrator files (.campaign.g.md). +// Agentic workflows end with .md. // // Examples: // // IsAgenticWorkflow("test.md") // returns true // IsAgenticWorkflow("weekly-research.md") // returns true // IsAgenticWorkflow(".github/workflows/workflow.md") // returns true -// IsAgenticWorkflow("test.campaign.md") // returns false (campaign spec) -// IsAgenticWorkflow("test.campaign.g.md") // returns false (campaign orchestrator) // IsAgenticWorkflow("test.lock.yml") // returns false func IsAgenticWorkflow(path string) bool { // Must end with .md - if !strings.HasSuffix(path, ".md") { - return false - } - // Exclude campaign spec files (.campaign.md) - if strings.HasSuffix(path, ".campaign.md") { - return false - } - // Exclude campaign orchestrator files (.campaign.g.md) - if strings.HasSuffix(path, ".campaign.g.md") { - return false - } - return true + return strings.HasSuffix(path, ".md") } // IsLockFile returns true if the file path is a compiled lock file. -// Lock files end with .lock.yml and can be compiled from agentic workflows or campaign orchestrators. +// Lock files end with .lock.yml and are compiled from agentic workflows. // // Examples: // // IsLockFile("test.lock.yml") // returns true -// IsLockFile("test.campaign.lock.yml") // returns true // IsLockFile(".github/workflows/workflow.lock.yml") // returns true // IsLockFile("test.md") // returns false -// IsLockFile("test.campaign.md") // returns false func IsLockFile(path string) bool { return strings.HasSuffix(path, ".lock.yml") } diff --git a/pkg/stringutil/identifiers_test.go b/pkg/stringutil/identifiers_test.go index d8ab5ce7c1c..279e1046d68 100644 --- a/pkg/stringutil/identifiers_test.go +++ b/pkg/stringutil/identifiers_test.go @@ -305,26 +305,11 @@ func TestIsAgenticWorkflow(t *testing.T) { path: "my.workflow.test.md", expected: true, }, - { - name: "campaign spec", - path: "test.campaign.md", - expected: false, - }, - { - name: "campaign orchestrator", - path: "test.campaign.g.md", - expected: false, - }, { name: "lock file", path: "test.lock.yml", expected: false, }, - { - name: "campaign lock file", - path: "test.campaign.lock.yml", - expected: false, - }, { name: "no extension", path: "test", @@ -358,26 +343,11 @@ func TestIsLockFile(t *testing.T) { path: ".github/workflows/test.lock.yml", expected: true, }, - { - name: "campaign lock file", - path: "test.campaign.lock.yml", - expected: true, - }, { name: "workflow file", path: "test.md", expected: false, }, - { - name: "campaign spec", - path: "test.campaign.md", - expected: false, - }, - { - name: "campaign orchestrator", - path: "test.campaign.g.md", - expected: false, - }, { name: "yaml file", path: "test.yml", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index eef82a083f0..325f3b8fb5b 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -213,15 +213,6 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath c.IncrementWarningCount() } - // Emit experimental warning for campaigns feature - // Campaign workflows (.campaign.md) are compiled by the campaign system in pkg/campaign/ - // This warning is part of the general workflow compilation pipeline and simply - // detects campaign files to inform users about the experimental status. - if strings.HasSuffix(markdownPath, ".campaign.md") { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: campaigns - This is a preview feature for multi-workflow orchestration. The campaign spec format, CLI commands, and repo-memory conventions may change in future releases. Workflows may break or require migration when the feature stabilizes.")) - c.IncrementWarningCount() - } - // Validate workflow_run triggers have branch restrictions log.Printf("Validating workflow_run triggers for branch restrictions") if err := c.validateWorkflowRunBranches(workflowData, markdownPath); err != nil { diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index fbe23f91f0a..7c7ac662eb2 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -169,66 +169,18 @@ func (c *Compiler) applyDefaults(data *WorkflowData, markdownPath string) error // When no permissions are specified, set default to contents: read. // This provides minimal access needed for most workflows while following // the principle of least privilege. - // - // CAMPAIGN-SPECIFIC HANDLING: - // Campaign orchestrator workflows (.campaign.g.md files) are auto-generated - // by the BuildOrchestrator function in pkg/campaign/orchestrator.go. - // These generated workflows intentionally omit explicit permissions in their - // frontmatter, so we compute minimal read permissions here at compile time. - // - // This is part of the campaign orchestrator generation pattern where: - // 1. Campaign specs (.campaign.md) define high-level campaign configuration - // 2. BuildOrchestrator generates orchestrator workflows (.campaign.g.md) - // 3. The compiler applies default permissions to the generated workflow - // - // This separation allows campaign configuration to remain declarative while - // ensuring generated orchestrators have appropriate permissions. // ============================================================================ - if strings.HasSuffix(markdownPath, ".campaign.g.md") { - perms := NewPermissions() - // Campaign orchestrators always need to read repository contents and tracker issues. - perms.Set(PermissionContents, PermissionRead) - perms.Set(PermissionIssues, PermissionRead) - - // If GitHub MCP toolsets are configured (including defaults), add any additional - // required permissions to avoid validation warnings. - var githubTool any - if data.Tools != nil { - githubTool = data.Tools["github"] + perms := NewPermissionsContentsRead() + yaml := perms.RenderToYAML() + // RenderToYAML uses job-friendly indentation (6 spaces). WorkflowData.Permissions + // is stored in workflow-level indentation (2 spaces) and later re-indented for jobs. + lines := strings.Split(yaml, "\n") + for i := 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], " ") { + lines[i] = " " + lines[i][6:] } - if githubTool != nil { - toolsetsStr := getGitHubToolsets(githubTool) - readOnly := getGitHubReadOnly(githubTool) - required := collectRequiredPermissions(ParseGitHubToolsets(toolsetsStr), readOnly) - for scope, level := range required { - perms.Set(scope, level) - } - } - - yaml := perms.RenderToYAML() - // RenderToYAML uses job-friendly indentation (6 spaces). WorkflowData.Permissions - // is stored in workflow-level indentation (2 spaces) and later re-indented for jobs. - lines := strings.Split(yaml, "\n") - for i := 1; i < len(lines); i++ { - if strings.HasPrefix(lines[i], " ") { - lines[i] = " " + lines[i][6:] - } - } - data.Permissions = strings.Join(lines, "\n") - } else { - // For non-campaign workflows, set default to contents: read - perms := NewPermissionsContentsRead() - yaml := perms.RenderToYAML() - // RenderToYAML uses job-friendly indentation (6 spaces). WorkflowData.Permissions - // is stored in workflow-level indentation (2 spaces) and later re-indented for jobs. - lines := strings.Split(yaml, "\n") - for i := 1; i < len(lines); i++ { - if strings.HasPrefix(lines[i], " ") { - lines[i] = " " + lines[i][6:] - } - } - data.Permissions = strings.Join(lines, "\n") } + data.Permissions = strings.Join(lines, "\n") } return nil }