From f5350098aad8f6d1a7804fa68f9a88af7df749b6 Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 27 Jan 2026 21:27:25 +0100 Subject: [PATCH 1/4] chore: simplify remove campaign discovery steps --- .../security-alert-burndown.lock.yml | 721 +------------ .github/workflows/security-alert-burndown.md | 25 +- actions/setup/js/campaign_discovery.cjs | 463 --------- actions/setup/js/campaign_discovery.test.cjs | 955 ------------------ pkg/campaign/orchestrator.go | 132 +-- pkg/campaign/validation.go | 8 +- 6 files changed, 35 insertions(+), 2269 deletions(-) delete mode 100644 actions/setup/js/campaign_discovery.cjs delete mode 100644 actions/setup/js/campaign_discovery.test.cjs diff --git a/.github/workflows/security-alert-burndown.lock.yml b/.github/workflows/security-alert-burndown.lock.yml index e0cfaa6abcd..3d3b3a99398 100644 --- a/.github/workflows/security-alert-burndown.lock.yml +++ b/.github/workflows/security-alert-burndown.lock.yml @@ -69,6 +69,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" env: @@ -154,7 +156,7 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /opt/gh-aw/safeoutputs/config.json << 'EOF' - {"create_project_status_update":{"max":1},"update_project":{"max":10}} + {"create_project_status_update":{"max":1},"update_project":{"max":100}} EOF cat > /opt/gh-aw/safeoutputs/tools.json << 'EOF' [ @@ -627,20 +629,13 @@ jobs: ## Discovery Strategy - The orchestrator will: + Discover Dependabot pull requests with labels: `dependencies`, `javascript`. - 1. **Discover** pull requests opened by the `dependabot` bot - 2. **Filter** to PRs with labels `dependencies` and `javascript` - 3. **Assign** discovered PRs to the Copilot coding agent using `assign-to-agent` - 4. **Track** progress in the project board + Prioritize open PRs by age (oldest first). Skip items already marked "Done" on the project board. ## Campaign Execution - Each run: - - Discovers up to 100 Dependabot PRs with specified labels - - Processes up to 5 pages of results - - Assigns up to 3 new items to Copilot - - Updates project board with up to 10 status changes + Each run discovers and processes Dependabot PRs, updating the project board with current status. ## Success Criteria @@ -648,708 +643,6 @@ jobs: - Copilot agent reviews and processes assigned PRs - Project board reflects current state of dependency updates - - - --- - # 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 only (discovery, planning, pacing, reporting). - **Actuation model:** **dispatch-only** — the orchestrator may only act by dispatching allowlisted worker workflows. - **Write authority:** all GitHub writes (Projects, issues/PRs, comments, status updates) must happen in worker workflows. - - --- - - ## 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. - - - - - - - **Read budget**: max discovery items per run: 100 - - - **Read budget**: max discovery pages per run: 5 - - - --- - - ## 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. Orchestrators do not write GitHub state directly - - --- - - ## 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 — Dispatch Workers (Execution) [DISPATCH ONLY] - - 8) For each selected unit of work, dispatch a worker workflow using `dispatch-workflow`. - - Constraints: - - Only dispatch allowlisted workflows. - - Keep within the dispatch-workflow max for this run. - - ### Step 4 — Report (No Writes) - - 9) Summarize what you dispatched, what remains, and what should run next. - - If a status update is required on the GitHub Project, dispatch a dedicated reporting/sync worker to perform that write. - - **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) - - - Orchestrators are dispatch-only and MUST NOT perform project writes directly. - - Worker workflows performing project writes MUST use only the `update-project` safe-output. - - All writes MUST target exactly: - - **Project URL**: `https://github.com/orgs/githubnext/projects/134` - - 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` | - PROMPT_EOF - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - | `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/134" - 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/134" - 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/134" - 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/134"` - - [ ] `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. Dispatch Workers (dispatch-workflow only) - 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 (in workers). - - 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 @@ -1662,7 +955,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":10}}" + GH_AW_SAFE_OUTPUTS_PROJECT_HANDLER_CONFIG: "{\"create_project_status_update\":{\"max\":1},\"update_project\":{\"max\":100}}" GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} with: github-token: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} diff --git a/.github/workflows/security-alert-burndown.md b/.github/workflows/security-alert-burndown.md index 49e22d92326..6084af3c47b 100644 --- a/.github/workflows/security-alert-burndown.md +++ b/.github/workflows/security-alert-burndown.md @@ -5,16 +5,12 @@ on: schedule: - cron: "0 * * * *" workflow_dispatch: +permissions: + issues: read + pull-requests: read + contents: read project: url: https://github.com/orgs/githubnext/projects/134 - scope: - - githubnext/gh-aw - id: security-alert-burndown - governance: - max-new-items-per-run: 3 - max-discovery-items-per-run: 100 - max-discovery-pages-per-run: 5 - max-project-updates-per-run: 10 --- # Security Alert Burndown Campaign @@ -27,20 +23,13 @@ Systematically process Dependabot dependency update PRs to keep JavaScript depen ## Discovery Strategy -The orchestrator will: +Discover Dependabot pull requests with labels: `dependencies`, `javascript`. -1. **Discover** pull requests opened by the `dependabot` bot -2. **Filter** to PRs with labels `dependencies` and `javascript` -3. **Assign** discovered PRs to the Copilot coding agent using `assign-to-agent` -4. **Track** progress in the project board +Prioritize open PRs by age (oldest first). Skip items already marked "Done" on the project board. ## Campaign Execution -Each run: -- Discovers up to 100 Dependabot PRs with specified labels -- Processes up to 5 pages of results -- Assigns up to 3 new items to Copilot -- Updates project board with up to 10 status changes +Each run discovers and processes Dependabot PRs, updating the project board with current status. ## Success Criteria diff --git a/actions/setup/js/campaign_discovery.cjs b/actions/setup/js/campaign_discovery.cjs deleted file mode 100644 index 79132d52a46..00000000000 --- a/actions/setup/js/campaign_discovery.cjs +++ /dev/null @@ -1,463 +0,0 @@ -// @ts-check -/// - -/** - * Campaign Discovery Precomputation - * - * Discovers campaign items (worker-created issues/PRs/discussions) by scanning - * a predefined list of repos using tracker-id markers and/or tracker labels. - * - * This script runs deterministically before the agent, eliminating the need for - * agents to perform GitHub-wide discovery during Phase 1. - * - * Outputs: - * - Manifest file: ./.gh-aw/campaign.discovery.json - * - Cursor file: in repo-memory for continuation across runs - * - * Features: - * - Strict pagination budgets - * - Durable cursor for incremental discovery - * - Stable sorting for deterministic output - * - Discovery via tracker-id and/or tracker-label - */ - -const fs = require("fs"); -const path = require("path"); - -/** - * Manifest schema version - */ -const MANIFEST_VERSION = "v1"; - -/** - * Default discovery budgets - */ -const DEFAULT_MAX_ITEMS = 100; -const DEFAULT_MAX_PAGES = 10; - -/** - * Parse cursor from repo-memory - * @param {string} cursorPath - Path to cursor file in repo-memory - * @returns {any} Parsed cursor object or null - */ -function loadCursor(cursorPath) { - try { - if (fs.existsSync(cursorPath)) { - const content = fs.readFileSync(cursorPath, "utf8"); - const cursor = JSON.parse(content); - core.info(`Loaded cursor from ${cursorPath}`); - return cursor; - } - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - core.warning(`Failed to load cursor from ${cursorPath}: ${err.message}`); - } - return null; -} - -/** - * Save cursor to repo-memory - * @param {string} cursorPath - Path to cursor file in repo-memory - * @param {any} cursor - Cursor object to save - */ -function saveCursor(cursorPath, cursor) { - try { - const dir = path.dirname(cursorPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(cursorPath, JSON.stringify(cursor, null, 2)); - core.info(`Saved cursor to ${cursorPath}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - core.error(`Failed to save cursor to ${cursorPath}: ${err.message}`); - throw err; - } -} - -/** - * Normalize a discovered item to standard format - * @param {any} item - Raw GitHub item (issue, PR, or discussion) - * @param {string} contentType - Type: "issue", "pull_request", or "discussion" - * @returns {any} Normalized item - */ -function normalizeItem(item, contentType) { - const normalized = { - url: item.html_url || item.url, - content_type: contentType, - number: item.number, - repo: item.repository?.full_name || item.repo?.full_name || "", - created_at: item.created_at, - updated_at: item.updated_at, - state: item.state, - title: item.title, - }; - - // Add closed/merged dates - if (item.closed_at) { - normalized.closed_at = item.closed_at; - } - if (item.merged_at) { - normalized.merged_at = item.merged_at; - } - - return normalized; -} - -/** - * Build scope query parts for GitHub search - * @param {string[]} repos - List of repositories to search (owner/repo format) - * @param {string[]} orgs - List of organizations to search - * @returns {string[]} Array of scope parts (e.g., ["repo:owner/repo", "org:orgname"]) - */ -function buildScopeParts(repos, orgs) { - return [...(repos?.length ? repos.map(r => `repo:${r}`) : []), ...(orgs?.length ? orgs.map(o => `org:${o}`) : [])]; -} - -/** - * Generic search helper for issues and PRs - * @param {any} octokit - GitHub API client - * @param {string} searchQuery - GitHub search query - * @param {string} searchLabel - Label for logging (e.g., "tracker-id: workflow-1" or "label: bug") - * @param {number} maxItems - Maximum items to discover - * @param {number} maxPages - Maximum pages to fetch - * @param {any} cursor - Cursor for pagination - * @param {any} cursorData - Additional data to store in cursor - * @returns {Promise<{items: any[], cursor: any, itemsScanned: number, pagesScanned: number}>} - */ -async function searchItems(octokit, searchQuery, searchLabel, maxItems, maxPages, cursor, cursorData) { - const items = []; - let itemsScanned = 0; - let pagesScanned = 0; - let page = cursor?.page || 1; - - while (pagesScanned < maxPages && itemsScanned < maxItems) { - core.info(`Fetching page ${page} for ${searchLabel}`); - - const response = await octokit.rest.search.issuesAndPullRequests({ - q: searchQuery, - per_page: 100, - page, - sort: "updated", - order: "asc", - }); - - pagesScanned++; - - if (response.data.items.length === 0) { - core.info(`No more items found for ${searchLabel}`); - break; - } - - for (const item of response.data.items) { - if (itemsScanned >= maxItems) break; - itemsScanned++; - const contentType = item.pull_request ? "pull_request" : "issue"; - items.push(normalizeItem(item, contentType)); - } - - if (response.data.items.length < 100) break; - page++; - } - - return { items, cursor: { page, ...cursorData }, itemsScanned, pagesScanned }; -} - -/** - * Search for items by tracker-id across issues and PRs - * @param {any} octokit - GitHub API client - * @param {string} trackerId - Tracker ID to search for - * @param {string[]} repos - List of repositories to search (owner/repo format) - * @param {string[]} orgs - List of organizations to search - * @param {number} maxItems - Maximum items to discover - * @param {number} maxPages - Maximum pages to fetch - * @param {any} cursor - Cursor for pagination - * @returns {Promise<{items: any[], cursor: any, itemsScanned: number, pagesScanned: number}>} - */ -async function searchByTrackerId(octokit, trackerId, repos, orgs, maxItems, maxPages, cursor) { - core.info(`Searching for tracker-id: ${trackerId} in ${repos.length} repo(s) and ${orgs.length} org(s)`); - - let searchQuery = `"gh-aw-tracker-id: ${trackerId}" type:issue`; - const scopeParts = buildScopeParts(repos, orgs); - - if (scopeParts.length > 0) { - const scopeQuery = scopeParts.join(" "); - if (searchQuery.length + scopeQuery.length + 1 > 1000) { - core.warning(`Search query length (${searchQuery.length + scopeQuery.length + 1}) approaches GitHub's ~1024 character limit. Some repos/orgs may be omitted.`); - } - searchQuery = `${searchQuery} ${scopeQuery}`; - core.info(`Scoped search to: ${scopeParts.join(", ")}`); - } - - return searchItems(octokit, searchQuery, `tracker-id: ${trackerId}`, maxItems, maxPages, cursor, { trackerId }); -} - -/** - * Search for items by tracker label - * @param {any} octokit - GitHub API client - * @param {string} label - Label to search for - * @param {string[]} repos - List of repositories to search (owner/repo format) - * @param {string[]} orgs - List of organizations to search - * @param {number} maxItems - Maximum items to discover - * @param {number} maxPages - Maximum pages to fetch - * @param {any} cursor - Cursor for pagination - * @returns {Promise<{items: any[], cursor: any, itemsScanned: number, pagesScanned: number}>} - */ -async function searchByLabel(octokit, label, repos, orgs, maxItems, maxPages, cursor) { - core.info(`Searching for label: ${label} in ${repos.length} repo(s) and ${orgs.length} org(s)`); - - let searchQuery = `label:"${label}"`; - const scopeParts = buildScopeParts(repos, orgs); - - if (scopeParts.length > 0) { - const scopeQuery = scopeParts.join(" "); - if (searchQuery.length + scopeQuery.length + 1 > 1000) { - core.warning(`Search query length (${searchQuery.length + scopeQuery.length + 1}) approaches GitHub's ~1024 character limit. Some repos/orgs may be omitted.`); - } - searchQuery = `${searchQuery} ${scopeQuery}`; - core.info(`Scoped search to: ${scopeParts.join(", ")}`); - } - - return searchItems(octokit, searchQuery, `label: ${label}`, maxItems, maxPages, cursor, { label }); -} - -/** - * Main discovery function - * @param {any} config - Configuration object - * @returns {Promise} Discovery manifest - */ -async function discover(config) { - const { campaignId, workflows = [], trackerLabel = null, repos = [], orgs = [], maxDiscoveryItems = DEFAULT_MAX_ITEMS, maxDiscoveryPages = DEFAULT_MAX_PAGES, cursorPath = null, projectUrl = null } = config; - - core.info(`Starting campaign discovery for: ${campaignId}`); - core.info(`Workflows: ${workflows.join(", ")}`); - core.info(`Tracker label: ${trackerLabel || "none"}`); - core.info(`Repos: ${repos.join(", ")}`); - core.info(`Orgs: ${orgs.join(", ")}`); - core.info(`Max items: ${maxDiscoveryItems}, Max pages: ${maxDiscoveryPages}`); - - // Load cursor if available - let cursor = cursorPath ? loadCursor(cursorPath) : null; - - const octokit = github; - const allItems = []; - let totalItemsScanned = 0; - let totalPagesScanned = 0; - - // Generate campaign-specific label - const campaignLabel = `z_campaign_${campaignId.toLowerCase().replace(/[_\s]+/g, "-")}`; - - // Primary discovery: Search by campaign-specific label (most reliable) - core.info(`Primary discovery: Searching by campaign-specific label: ${campaignLabel}`); - const labelResult = await searchByLabel(octokit, campaignLabel, repos, orgs, maxDiscoveryItems, maxDiscoveryPages, cursor).catch(err => { - core.warning(`Campaign-specific label discovery failed: ${err instanceof Error ? err.message : String(err)}`); - return { items: [], itemsScanned: 0, pagesScanned: 0, cursor }; - }); - - allItems.push(...labelResult.items); - totalItemsScanned += labelResult.itemsScanned; - totalPagesScanned += labelResult.pagesScanned; - cursor = labelResult.cursor; - core.info(`Campaign-specific label discovery found ${labelResult.items.length} item(s)`); - - // Secondary discovery: Search by generic "agentic-campaign" label - if (allItems.length === 0 || totalItemsScanned < maxDiscoveryItems) { - core.info(`Secondary discovery: Searching by generic agentic-campaign label...`); - const remainingItems = maxDiscoveryItems - totalItemsScanned; - const remainingPages = maxDiscoveryPages - totalPagesScanned; - - const genericResult = await searchByLabel(octokit, "agentic-campaign", repos, orgs, remainingItems, remainingPages, cursor).catch(err => { - core.warning(`Generic label discovery failed: ${err instanceof Error ? err.message : String(err)}`); - return { items: [], itemsScanned: 0, pagesScanned: 0, cursor }; - }); - - // Merge items (deduplicate by URL) - const existingUrls = new Set(allItems.map(i => i.url)); - const newItems = genericResult.items.filter(item => !existingUrls.has(item.url)); - allItems.push(...newItems); - - totalItemsScanned += genericResult.itemsScanned; - totalPagesScanned += genericResult.pagesScanned; - cursor = genericResult.cursor; - core.info(`Generic label discovery found ${newItems.length} item(s)`); - } - - // Fallback: Search GitHub API by tracker-id (if still no items) - if (allItems.length === 0 && workflows?.length && totalItemsScanned < maxDiscoveryItems && totalPagesScanned < maxDiscoveryPages) { - core.info(`No items found via labels. Searching GitHub API by tracker-id...`); - - for (const workflow of workflows) { - if (totalItemsScanned >= maxDiscoveryItems || totalPagesScanned >= maxDiscoveryPages) { - core.warning(`Reached discovery budget limits. Stopping discovery.`); - break; - } - - const result = await searchByTrackerId(octokit, workflow, repos, orgs, maxDiscoveryItems - totalItemsScanned, maxDiscoveryPages - totalPagesScanned, cursor); - - allItems.push(...result.items); - totalItemsScanned += result.itemsScanned; - totalPagesScanned += result.pagesScanned; - cursor = result.cursor; - } - } - - // Legacy discovery by tracker label (if provided and still needed) - if (trackerLabel && (allItems.length === 0 || totalItemsScanned < maxDiscoveryItems)) { - if (totalItemsScanned < maxDiscoveryItems && totalPagesScanned < maxDiscoveryPages) { - const result = await searchByLabel(octokit, trackerLabel, repos, orgs, maxDiscoveryItems - totalItemsScanned, maxDiscoveryPages - totalPagesScanned, cursor); - - // Merge items (deduplicate by URL) - const existingUrls = new Set(allItems.map(i => i.url)); - const newItems = result.items.filter(item => !existingUrls.has(item.url)); - allItems.push(...newItems); - - totalItemsScanned += result.itemsScanned; - totalPagesScanned += result.pagesScanned; - cursor = result.cursor; - } - } - - // Sort items for stable ordering (by updated_at, then by number) - allItems.sort((a, b) => a.updated_at.localeCompare(b.updated_at) || a.number - b.number); - - // Calculate summary counts - const openItems = allItems.filter(i => i.state === "open"); - const closedItems = allItems.filter(i => i.state === "closed" && !i.merged_at); - const mergedItems = allItems.filter(i => i.merged_at); - const needsAddCount = openItems.length; - const needsUpdateCount = closedItems.length + mergedItems.length; - - // Determine if budget was exhausted - const itemsBudgetExhausted = totalItemsScanned >= maxDiscoveryItems; - const pagesBudgetExhausted = totalPagesScanned >= maxDiscoveryPages; - const budgetExhausted = itemsBudgetExhausted || pagesBudgetExhausted; - const exhaustedReason = budgetExhausted ? (itemsBudgetExhausted ? "max_items_reached" : "max_pages_reached") : null; - - // Build manifest - const manifest = { - schema_version: MANIFEST_VERSION, - campaign_id: campaignId, - generated_at: new Date().toISOString(), - project_url: projectUrl, - discovery: { - total_items: allItems.length, - items_scanned: totalItemsScanned, - pages_scanned: totalPagesScanned, - max_items_budget: maxDiscoveryItems, - max_pages_budget: maxDiscoveryPages, - budget_exhausted: budgetExhausted, - exhausted_reason: exhaustedReason, - cursor: cursor, - }, - summary: { - needs_add_count: needsAddCount, - needs_update_count: needsUpdateCount, - open_count: openItems.length, - closed_count: closedItems.length, - merged_count: mergedItems.length, - }, - items: allItems, - }; - - // Save cursor if provided - if (cursorPath) { - saveCursor(cursorPath, cursor); - } - - core.info(`Discovery complete: ${allItems.length} items found`); - core.info(`Budget utilization: ${totalItemsScanned}/${maxDiscoveryItems} items, ${totalPagesScanned}/${maxDiscoveryPages} pages`); - - if (budgetExhausted) { - const message = allItems.length === 0 ? `Discovery budget exhausted with 0 items found. Consider increasing budget limits in governance configuration.` : `Discovery stopped at budget limit. Use cursor for continuation in next run.`; - allItems.length === 0 ? core.warning(message) : core.info(message); - } - - core.info(`Summary: ${needsAddCount} to add, ${needsUpdateCount} to update`); - - return manifest; -} - -/** - * Main entry point - */ -async function main() { - try { - // Read configuration from environment variables - const config = { - campaignId: process.env.GH_AW_CAMPAIGN_ID || core.getInput("campaign-id", { required: true }), - workflows: (process.env.GH_AW_WORKFLOWS || core.getInput("workflows") || "") - .split(",") - .map(w => w.trim()) - .filter(w => w.length > 0), - trackerLabel: process.env.GH_AW_TRACKER_LABEL || core.getInput("tracker-label") || null, - repos: (process.env.GH_AW_DISCOVERY_REPOS || core.getInput("repos") || "") - .split(",") - .map(r => r.trim()) - .filter(r => r.length > 0), - orgs: (process.env.GH_AW_DISCOVERY_ORGS || core.getInput("orgs") || "") - .split(",") - .map(o => o.trim()) - .filter(o => o.length > 0), - maxDiscoveryItems: parseInt(process.env.GH_AW_MAX_DISCOVERY_ITEMS || core.getInput("max-discovery-items") || DEFAULT_MAX_ITEMS.toString(), 10), - maxDiscoveryPages: parseInt(process.env.GH_AW_MAX_DISCOVERY_PAGES || core.getInput("max-discovery-pages") || DEFAULT_MAX_PAGES.toString(), 10), - cursorPath: process.env.GH_AW_CURSOR_PATH || core.getInput("cursor-path") || null, - projectUrl: process.env.GH_AW_PROJECT_URL || core.getInput("project-url") || null, - }; - - // Validate configuration - if (!config.campaignId) { - throw new Error("campaign-id is required"); - } - - // RUNTIME GUARD: Campaigns MUST be scoped - if (!config.repos?.length && !config.orgs?.length) { - throw new Error("campaigns MUST be scoped: GH_AW_DISCOVERY_REPOS or GH_AW_DISCOVERY_ORGS is required. Configure scope in the campaign spec."); - } - - if (!config.workflows?.length && !config.trackerLabel) { - throw new Error("Either workflows or tracker-label must be provided"); - } - - // Run discovery - const manifest = await discover(config); - - // Write manifest to output file - const outputDir = "./.gh-aw"; - const outputPath = path.join(outputDir, "campaign.discovery.json"); - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - fs.writeFileSync(outputPath, JSON.stringify(manifest, null, 2)); - core.info(`Manifest written to ${outputPath}`); - - // Set output for GitHub Actions - core.setOutput("manifest-path", outputPath); - core.setOutput("needs-add-count", manifest.summary.needs_add_count); - core.setOutput("needs-update-count", manifest.summary.needs_update_count); - core.setOutput("total-items", manifest.discovery.total_items); - - // Log summary - core.info(`✓ Discovery complete`); - core.info(` Total items: ${manifest.discovery.total_items}`); - core.info(` Needs add: ${manifest.summary.needs_add_count}`); - core.info(` Needs update: ${manifest.summary.needs_update_count}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - core.setFailed(`Discovery failed: ${err.message}`); - throw err; - } -} - -module.exports = { - main, - discover, - normalizeItem, - searchByTrackerId, - searchByLabel, - searchItems, - loadCursor, - saveCursor, - buildScopeParts, -}; diff --git a/actions/setup/js/campaign_discovery.test.cjs b/actions/setup/js/campaign_discovery.test.cjs deleted file mode 100644 index 68b524b0d63..00000000000 --- a/actions/setup/js/campaign_discovery.test.cjs +++ /dev/null @@ -1,955 +0,0 @@ -// @ts-check -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { normalizeItem, loadCursor, saveCursor, searchByTrackerId, searchByLabel, searchItems, buildScopeParts, discover } from "./campaign_discovery.cjs"; -import fs from "fs"; -import path from "path"; - -// Mock fs -vi.mock("fs"); - -// Mock core and github -global.core = { - info: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - getInput: vi.fn(), - setOutput: vi.fn(), -}; - -global.github = {}; - -describe("campaign_discovery", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("normalizeItem", () => { - it("should normalize an issue", () => { - const issue = { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }; - - const normalized = normalizeItem(issue, "issue"); - - expect(normalized).toEqual({ - url: "https://github.com/owner/repo/issues/1", - content_type: "issue", - number: 1, - repo: "owner/repo", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }); - }); - - it("should normalize a pull request with merged_at", () => { - const pr = { - html_url: "https://github.com/owner/repo/pull/2", - number: 2, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "closed", - title: "Test PR", - merged_at: "2025-01-03T00:00:00Z", - }; - - const normalized = normalizeItem(pr, "pull_request"); - - expect(normalized).toEqual({ - url: "https://github.com/owner/repo/pull/2", - content_type: "pull_request", - number: 2, - repo: "owner/repo", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "closed", - title: "Test PR", - merged_at: "2025-01-03T00:00:00Z", - }); - }); - - it("should normalize a closed issue with closed_at", () => { - const issue = { - html_url: "https://github.com/owner/repo/issues/3", - number: 3, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "closed", - title: "Closed Issue", - closed_at: "2025-01-03T00:00:00Z", - }; - - const normalized = normalizeItem(issue, "issue"); - - expect(normalized).toEqual({ - url: "https://github.com/owner/repo/issues/3", - content_type: "issue", - number: 3, - repo: "owner/repo", - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "closed", - title: "Closed Issue", - closed_at: "2025-01-03T00:00:00Z", - }); - }); - }); - - describe("loadCursor", () => { - it("should handle missing cursor file gracefully", () => { - // Since we can't easily mock fs in vitest with CommonJS, - // we'll just test that the function doesn't throw - const cursor = loadCursor("/nonexistent/path.json"); - expect(cursor).toBeNull(); - }); - }); - - describe("saveCursor", () => { - it("should be defined as a function", () => { - expect(typeof saveCursor).toBe("function"); - }); - }); - - describe("searchByTrackerId", () => { - it("should search for items by tracker-id", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }, - ], - }, - }), - }, - }, - }; - - const result = await searchByTrackerId(octokit, "workflow-1", ["owner/repo"], [], 100, 10, null); - - expect(result.items).toHaveLength(1); - expect(result.items[0].content_type).toBe("issue"); - expect(result.items[0].number).toBe(1); - expect(result.itemsScanned).toBe(1); - expect(result.pagesScanned).toBe(1); - }); - - it("should respect max items budget", async () => { - const items = Array.from({ length: 10 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })); - - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items }, - }), - }, - }, - }; - - const result = await searchByTrackerId( - octokit, - "workflow-1", - ["owner/repo"], - [], - 5, // max 5 items - 10, - null - ); - - expect(result.items).toHaveLength(5); - expect(result.itemsScanned).toBe(5); - }); - - it("should handle pagination", async () => { - const page1Items = Array.from({ length: 100 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })); - - const page2Items = Array.from({ length: 50 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 101}`, - number: i + 101, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 101}`, - })); - - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi - .fn() - .mockResolvedValueOnce({ data: { items: page1Items } }) - .mockResolvedValueOnce({ data: { items: page2Items } }), - }, - }, - }; - - const result = await searchByTrackerId(octokit, "workflow-1", ["owner/repo"], [], 150, 10, null); - - expect(result.items).toHaveLength(150); - expect(result.pagesScanned).toBe(2); - }); - - it("should build query with orgs when provided", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - await searchByTrackerId(octokit, "workflow-1", [], ["myorg"], 100, 10, null); - - const call = octokit.rest.search.issuesAndPullRequests.mock.calls[0][0]; - expect(call.q).toContain('"gh-aw-tracker-id: workflow-1"'); - expect(call.q).toContain("org:myorg"); - expect(call.q).not.toContain("("); - expect(call.q).not.toContain(")"); - }); - - it("should build query with both repos and orgs", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - await searchByTrackerId(octokit, "workflow-1", ["owner/repo1"], ["myorg"], 100, 10, null); - - const call = octokit.rest.search.issuesAndPullRequests.mock.calls[0][0]; - expect(call.q).toContain('"gh-aw-tracker-id: workflow-1"'); - expect(call.q).toContain("repo:owner/repo1"); - expect(call.q).toContain("org:myorg"); - expect(call.q).not.toContain("("); - expect(call.q).not.toContain(")"); - }); - }); - - describe("searchByLabel", () => { - it("should search for items by label", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }, - ], - }, - }), - }, - }, - }; - - const result = await searchByLabel(octokit, "z_campaign_test", ["owner/repo"], [], 100, 10, null); - - expect(result.items).toHaveLength(1); - expect(result.items[0].content_type).toBe("issue"); - }); - - it("should build repo-specific query when repos provided", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - await searchByLabel(octokit, "z_campaign_test", ["owner/repo1", "owner/repo2"], [], 100, 10, null); - - const call = octokit.rest.search.issuesAndPullRequests.mock.calls[0][0]; - expect(call.q).toContain('label:"z_campaign_test"'); - expect(call.q).toContain("repo:owner/repo1"); - expect(call.q).toContain("repo:owner/repo2"); - expect(call.q).not.toContain("("); - expect(call.q).not.toContain(")"); - }); - - it("should build org-specific query when orgs provided", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - await searchByLabel(octokit, "z_campaign_test", [], ["myorg", "anotherorg"], 100, 10, null); - - const call = octokit.rest.search.issuesAndPullRequests.mock.calls[0][0]; - expect(call.q).toContain('label:"z_campaign_test"'); - expect(call.q).toContain("org:myorg"); - expect(call.q).toContain("org:anotherorg"); - expect(call.q).not.toContain("("); - expect(call.q).not.toContain(")"); - }); - - it("should build combined query when both repos and orgs provided", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - await searchByLabel(octokit, "z_campaign_test", ["owner/repo1"], ["myorg"], 100, 10, null); - - const call = octokit.rest.search.issuesAndPullRequests.mock.calls[0][0]; - expect(call.q).toContain('label:"z_campaign_test"'); - expect(call.q).toContain("repo:owner/repo1"); - expect(call.q).toContain("org:myorg"); - expect(call.q).not.toContain("("); - expect(call.q).not.toContain(")"); - }); - }); - - describe("discover", () => { - it("should discover items and generate manifest", async () => { - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }, - ], - }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 100, - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - expect(manifest.schema_version).toBe("v1"); - expect(manifest.campaign_id).toBe("test-campaign"); - expect(manifest.discovery.total_items).toBe(1); - expect(manifest.summary.needs_add_count).toBe(1); - expect(manifest.items).toHaveLength(1); - }); - - it("should sort items deterministically", async () => { - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/3", - number: 3, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - state: "open", - title: "Issue 3", - }, - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-01T00:00:00Z", - state: "open", - title: "Issue 1", - }, - { - html_url: "https://github.com/owner/repo/issues/2", - number: 2, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Issue 2", - }, - ], - }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 100, - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - expect(manifest.items).toHaveLength(3); - expect(manifest.items[0].number).toBe(1); - expect(manifest.items[1].number).toBe(2); - expect(manifest.items[2].number).toBe(3); - }); - - it("should calculate summary counts correctly", async () => { - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Open Issue", - }, - { - html_url: "https://github.com/owner/repo/issues/2", - number: 2, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - state: "closed", - title: "Closed Issue", - closed_at: "2025-01-03T00:00:00Z", - }, - { - html_url: "https://github.com/owner/repo/pull/3", - number: 3, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-04T00:00:00Z", - state: "closed", - title: "Merged PR", - pull_request: {}, - merged_at: "2025-01-04T00:00:00Z", - }, - ], - }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 100, - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - expect(manifest.summary.open_count).toBe(1); - expect(manifest.summary.closed_count).toBe(1); - expect(manifest.summary.merged_count).toBe(1); - expect(manifest.summary.needs_add_count).toBe(1); // open items - expect(manifest.summary.needs_update_count).toBe(2); // closed + merged - }); - - it("should handle budget exhaustion with itemsBudgetExhausted reason", async () => { - const items = Array.from({ length: 150 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })); - - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: items.slice(0, 100) }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 50, // Set low budget - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - expect(manifest.discovery.budget_exhausted).toBe(true); - expect(manifest.discovery.exhausted_reason).toBe("max_items_reached"); - expect(manifest.discovery.items_scanned).toBe(50); - }); - - it("should handle budget exhaustion with pagesBudgetExhausted reason", async () => { - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: Array.from({ length: 100 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })), - }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 1000, - maxDiscoveryPages: 2, // Set low page budget - }; - - const manifest = await discover(config); - - expect(manifest.discovery.budget_exhausted).toBe(true); - expect(manifest.discovery.exhausted_reason).toBe("max_pages_reached"); - expect(manifest.discovery.pages_scanned).toBe(2); - }); - - it("should deduplicate items found across multiple searches", async () => { - const duplicateItem = { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Duplicate Issue", - }; - - const uniqueItem = { - html_url: "https://github.com/owner/repo/issues/2", - number: 2, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - state: "open", - title: "Unique Issue", - }; - - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi - .fn() - .mockResolvedValueOnce({ data: { items: [duplicateItem] } }) // Campaign-specific label - .mockResolvedValueOnce({ data: { items: [duplicateItem, uniqueItem] } }), // Generic label - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: ["workflow-1"], - maxDiscoveryItems: 100, - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - // Should only have 2 items (deduplicated) - expect(manifest.discovery.total_items).toBe(2); - expect(manifest.items).toHaveLength(2); - }); - - it("should use tracker label as fallback when provided", async () => { - const mockOctokit = { - rest: { - search: { - issuesAndPullRequests: vi - .fn() - .mockResolvedValueOnce({ data: { items: [] } }) // Campaign-specific label - empty - .mockResolvedValueOnce({ data: { items: [] } }) // Generic label - empty - .mockResolvedValueOnce({ - // Tracker label - has items - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Tracker Label Issue", - }, - ], - }, - }), - }, - }, - }; - - global.github = mockOctokit; - - const config = { - campaignId: "test-campaign", - workflows: [], - trackerLabel: "custom-tracker", - maxDiscoveryItems: 100, - maxDiscoveryPages: 10, - }; - - const manifest = await discover(config); - - expect(manifest.discovery.total_items).toBe(1); - expect(manifest.items[0].title).toBe("Tracker Label Issue"); - }); - }); - - describe("buildScopeParts", () => { - it("should build scope parts with repos only", () => { - const repos = ["owner1/repo1", "owner2/repo2"]; - const orgs = []; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual(["repo:owner1/repo1", "repo:owner2/repo2"]); - }); - - it("should build scope parts with orgs only", () => { - const repos = []; - const orgs = ["org1", "org2"]; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual(["org:org1", "org:org2"]); - }); - - it("should build scope parts with both repos and orgs", () => { - const repos = ["owner/repo1"]; - const orgs = ["org1", "org2"]; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual(["repo:owner/repo1", "org:org1", "org:org2"]); - }); - - it("should return empty array when both repos and orgs are empty", () => { - const repos = []; - const orgs = []; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual([]); - }); - - it("should handle null or undefined repos gracefully", () => { - const repos = null; - const orgs = ["org1"]; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual(["org:org1"]); - }); - - it("should handle null or undefined orgs gracefully", () => { - const repos = ["owner/repo"]; - const orgs = null; - - const result = buildScopeParts(repos, orgs); - - expect(result).toEqual(["repo:owner/repo"]); - }); - }); - - describe("searchItems", () => { - it("should search items with basic query", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }, - ], - }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 100, 10, null, { test: "data" }); - - expect(result.items).toHaveLength(1); - expect(result.itemsScanned).toBe(1); - expect(result.pagesScanned).toBe(1); - expect(result.cursor).toEqual({ page: 1, test: "data" }); - }); - - it("should respect maxItems limit", async () => { - const items = Array.from({ length: 10 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })); - - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 5, 10, null, {}); - - expect(result.items).toHaveLength(5); - expect(result.itemsScanned).toBe(5); - }); - - it("should respect maxPages limit", async () => { - const pageItems = Array.from({ length: 100 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })); - - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: pageItems }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 1000, 2, null, {}); - - expect(result.pagesScanned).toBe(2); - expect(result.items).toHaveLength(200); // 2 pages * 100 items - }); - - it("should handle empty results", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { items: [] }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 100, 10, null, {}); - - expect(result.items).toHaveLength(0); - expect(result.itemsScanned).toBe(0); - expect(result.pagesScanned).toBe(1); - }); - - it("should resume from cursor page", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Test Issue", - }, - ], - }, - }), - }, - }, - }; - - const cursor = { page: 5 }; - const result = await searchItems(octokit, "test query", "test label", 100, 10, cursor, {}); - - expect(result.cursor.page).toBe(5); - expect(octokit.rest.search.issuesAndPullRequests).toHaveBeenCalledWith( - expect.objectContaining({ - page: 5, - }) - ); - }); - - it("should distinguish between issues and pull requests", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: [ - { - html_url: "https://github.com/owner/repo/issues/1", - number: 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: "Issue", - }, - { - html_url: "https://github.com/owner/repo/pull/2", - number: 2, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-03T00:00:00Z", - state: "open", - title: "PR", - pull_request: {}, - }, - ], - }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 100, 10, null, {}); - - expect(result.items).toHaveLength(2); - expect(result.items[0].content_type).toBe("issue"); - expect(result.items[1].content_type).toBe("pull_request"); - }); - - it("should stop pagination when fewer than 100 items returned", async () => { - const octokit = { - rest: { - search: { - issuesAndPullRequests: vi.fn().mockResolvedValue({ - data: { - items: Array.from({ length: 50 }, (_, i) => ({ - html_url: `https://github.com/owner/repo/issues/${i + 1}`, - number: i + 1, - repository: { full_name: "owner/repo" }, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - state: "open", - title: `Issue ${i + 1}`, - })), - }, - }), - }, - }, - }; - - const result = await searchItems(octokit, "test query", "test label", 1000, 10, null, {}); - - expect(result.pagesScanned).toBe(1); // Should stop after first page - expect(result.items).toHaveLength(50); - }); - }); -}); diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 7febe519f81..71e09cf677f 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -72,110 +72,7 @@ func extractFileGlobPatterns(spec *CampaignSpec) []string { return []string{fallbackPattern} } -// buildDiscoverySteps creates GitHub Actions steps for campaign discovery precomputation -func buildDiscoverySteps(spec *CampaignSpec) []map[string]any { - // Only add discovery steps if we have workflows or a tracker label - if len(spec.Workflows) == 0 && spec.TrackerLabel == "" { - orchestratorLog.Printf("Skipping discovery steps: no workflows or tracker label configured") - return nil - } - - orchestratorLog.Printf("Building discovery steps for campaign: %s", spec.ID) - - // Build environment variables for discovery - envVars := map[string]any{ - "GH_AW_CAMPAIGN_ID": spec.ID, - "GH_AW_WORKFLOWS": strings.Join(spec.Workflows, ","), - "GH_AW_TRACKER_LABEL": spec.TrackerLabel, - "GH_AW_PROJECT_URL": spec.ProjectURL, - "GH_AW_MAX_DISCOVERY_ITEMS": fmt.Sprintf("%d", getMaxDiscoveryItems(spec)), - "GH_AW_MAX_DISCOVERY_PAGES": fmt.Sprintf("%d", getMaxDiscoveryPages(spec)), - "GH_AW_CURSOR_PATH": getCursorPath(spec), - } - - // Campaign scope uses scope selectors as the single source of truth. - // We export discovery scope to the action via GH_AW_DISCOVERY_*. - parsedScope, scopeProblems := parseScopeSelectors(spec.Scope) - if len(scopeProblems) > 0 { - orchestratorLog.Printf("Warning: invalid scope selectors for campaign '%s': %v", spec.ID, scopeProblems) - } - - if len(parsedScope.Repos) > 0 { - envVars["GH_AW_DISCOVERY_REPOS"] = strings.Join(parsedScope.Repos, ",") - orchestratorLog.Printf("Setting GH_AW_DISCOVERY_REPOS from scope: %v", parsedScope.Repos) - } - - if len(parsedScope.Orgs) > 0 { - envVars["GH_AW_DISCOVERY_ORGS"] = strings.Join(parsedScope.Orgs, ",") - orchestratorLog.Printf("Setting GH_AW_DISCOVERY_ORGS from scope: %v", parsedScope.Orgs) - } - - steps := []map[string]any{ - { - "name": "Create workspace directory", - "run": "mkdir -p ./.gh-aw", - }, - { - "name": "Run campaign discovery precomputation", - "id": "discovery", - "uses": "actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd", // v8.0.0 - "env": envVars, - "with": map[string]any{ - "github-token": "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN || secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}", - "script": ` -const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); -setupGlobals(core, github, context, exec, io); -const { main } = require('/opt/gh-aw/actions/campaign_discovery.cjs'); -await main(); -`, - }, - }, - } - - return steps -} - -// getMaxDiscoveryItems returns the max discovery items budget from governance or default -func getMaxDiscoveryItems(spec *CampaignSpec) int { - if spec.Governance != nil && spec.Governance.MaxDiscoveryItemsPerRun > 0 { - return spec.Governance.MaxDiscoveryItemsPerRun - } - return 100 // default -} - -// getMaxDiscoveryPages returns the max discovery pages budget from governance or default -func getMaxDiscoveryPages(spec *CampaignSpec) int { - if spec.Governance != nil && spec.Governance.MaxDiscoveryPagesPerRun > 0 { - return spec.Governance.MaxDiscoveryPagesPerRun - } - return 10 // default -} - -// getCursorPath returns the cursor path for the campaign or empty if not configured -func getCursorPath(spec *CampaignSpec) string { - if spec.CursorGlob != "" { - // Convert glob to actual path - remove wildcards and use repo-memory path - // For now, use a simple convention: /tmp/gh-aw/repo-memory/campaigns//cursor.json - return fmt.Sprintf("/tmp/gh-aw/repo-memory/campaigns/%s/cursor.json", spec.ID) - } - return "" -} -// renderStepsAsYAML renders a list of steps as YAML string for CustomSteps field -func renderStepsAsYAML(steps []map[string]any) string { - if len(steps) == 0 { - return "" - } - - // Marshal steps to YAML - data, err := yaml.Marshal(steps) - if err != nil { - orchestratorLog.Printf("Failed to marshal discovery steps to YAML: %v", err) - return "" - } - - return string(data) -} // BuildOrchestrator constructs a minimal agentic workflow representation for a // given CampaignSpec. The resulting WorkflowData is compiled via the standard @@ -417,10 +314,6 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W // multiple directory structures (e.g., both dated "campaign-id-*/**" and non-dated "campaign-id/**") fileGlobPatterns := extractFileGlobPatterns(spec) - // Build discovery step configuration - // This runs before the agent to precompute campaign discovery - discoverySteps := buildDiscoverySteps(spec) - // Determine engine to use (default to claude if not specified) engineID := "claude" if spec.Engine != "" { @@ -430,7 +323,23 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W 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", @@ -442,8 +351,8 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W "bash": []any{"*"}, "edit": nil, } - // Deliberately omit GitHub tool access from orchestrators. All writes and GitHub - // API operations should be performed by dispatched worker workflows. + orchestratorLog.Printf("Campaign orchestrator '%s' configured with GitHub MCP (max items: %d, max pages: %d)", + spec.ID, maxDiscoveryItems, maxDiscoveryPages) data := &workflow.WorkflowData{ Name: name, @@ -465,10 +374,5 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W SafeOutputs: safeOutputs, } - // Add discovery steps if configuration is valid - if len(discoverySteps) > 0 { - data.CustomSteps = renderStepsAsYAML(discoverySteps) - } - return data, orchestratorPath } diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go index 93f77ab546c..146031a75c2 100644 --- a/pkg/campaign/validation.go +++ b/pkg/campaign/validation.go @@ -80,13 +80,11 @@ func ValidateSpec(spec *CampaignSpec) []string { } } - parsedScope, scopeProblems := parseScopeSelectors(spec.Scope) + _, scopeProblems := parseScopeSelectors(spec.Scope) problems = append(problems, scopeProblems...) - // Campaigns that do discovery (workflows or tracker-label) must be scoped. - if (len(spec.Workflows) > 0 || strings.TrimSpace(spec.TrackerLabel) != "") && len(parsedScope.Repos) == 0 && len(parsedScope.Orgs) == 0 { - problems = append(problems, "campaigns with workflows must be scoped via scope") - } + // 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'") From 09e2af014416715365d3546cce50073e11a3dacd Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 27 Jan 2026 21:53:01 +0100 Subject: [PATCH 2/4] chore: update project link --- .github/workflows/security-alert-burndown.md | 7 +++---- pkg/workflow/compiler_orchestrator_workflow.go | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security-alert-burndown.md b/.github/workflows/security-alert-burndown.md index 6084af3c47b..f2f5f4e3b9d 100644 --- a/.github/workflows/security-alert-burndown.md +++ b/.github/workflows/security-alert-burndown.md @@ -2,15 +2,14 @@ name: Security Alert Burndown description: Discovers Dependabot PRs and assigns them to Copilot for review on: - schedule: - - cron: "0 * * * *" + #schedule: + # - cron: "0 * * * *" workflow_dispatch: permissions: issues: read pull-requests: read contents: read -project: - url: https://github.com/orgs/githubnext/projects/134 +project: https://github.com/orgs/githubnext/projects/144 --- # Security Alert Burndown Campaign diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 26b17088c90..2b699c75caf 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -337,6 +337,9 @@ func (c *Compiler) extractAdditionalConfigurations( // Use the already extracted output configuration workflowData.SafeOutputs = safeOutputs + // Apply project safe-outputs if project field is present + workflowData.SafeOutputs = c.applyProjectSafeOutputs(frontmatter, workflowData.SafeOutputs) + // Extract safe-inputs configuration workflowData.SafeInputs = c.extractSafeInputsConfig(frontmatter) From 764d389b2ea1960369b1b7363871c5bf4981f4b3 Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 27 Jan 2026 21:53:26 +0100 Subject: [PATCH 3/4] recompile --- .github/workflows/security-alert-burndown.lock.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/security-alert-burndown.lock.yml b/.github/workflows/security-alert-burndown.lock.yml index 3d3b3a99398..d583d52f319 100644 --- a/.github/workflows/security-alert-burndown.lock.yml +++ b/.github/workflows/security-alert-burndown.lock.yml @@ -23,8 +23,6 @@ name: "Security Alert Burndown" "on": - schedule: - - cron: "0 * * * *" workflow_dispatch: permissions: {} From 23e17aa817a69b5080d2977bb5f56e6b35b60b37 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:04:40 +0100 Subject: [PATCH 4/4] fix: update test expectations for GitHub tools in campaign orchestrators (#12107) --- pkg/campaign/orchestrator.go | 2 -- pkg/campaign/orchestrator_test.go | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 71e09cf677f..cf979e10554 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -72,8 +72,6 @@ func extractFileGlobPatterns(spec *CampaignSpec) []string { 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 diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index f94a61baa4b..4b945149c1d 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -172,11 +172,12 @@ func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) { t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs") } - // Orchestrators should not have GitHub tool access to the agent. - if data.Tools != nil { - if _, ok := data.Tools["github"]; ok { - t.Fatalf("expected orchestrator to omit github tools") - } + // 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") } }) }