diff --git a/.github/workflows/campaign-manager.lock.yml b/.github/workflows/campaign-manager.lock.yml index acc611cb862..da00ce48d88 100644 --- a/.github/workflows/campaign-manager.lock.yml +++ b/.github/workflows/campaign-manager.lock.yml @@ -490,9 +490,20 @@ jobs: "type": "string", "enum": [ "issue", - "pull_request" + "pull_request", + "draft_issue" ] }, + "draft_body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "draft_title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, "fields": { "type": "object" }, @@ -510,7 +521,8 @@ jobs: "pull_request": { "optionalPositiveInteger": true } - } + }, + "customValidation": "updateProjectValidTarget" } } EOF diff --git a/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml b/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml index 7d735414d84..183d54fcae5 100644 --- a/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml +++ b/.github/workflows/docs-quality-maintenance-project67.campaign.lock.yml @@ -365,9 +365,20 @@ jobs: "type": "string", "enum": [ "issue", - "pull_request" + "pull_request", + "draft_issue" ] }, + "draft_body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "draft_title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, "fields": { "type": "object" }, @@ -385,7 +396,8 @@ jobs: "pull_request": { "optionalPositiveInteger": true } - } + }, + "customValidation": "updateProjectValidTarget" } } EOF @@ -515,7 +527,7 @@ jobs: This workflow orchestrates the 'Documentation Quality & Maintenance Campaign (Project 67)' campaign. - - Tracker label: `campaign:docs-quality-maintenance-project67` + - Tracker label (optional ingestion): `campaign:docs-quality-maintenance-project67` - Objective: Maintain high-quality, accessible, and consistent documentation following the Diátaxis framework while ensuring all docs are accurate, complete, and user-friendly - KPIs: - Documentation coverage of features (primary): baseline 85 → target 95 over 90 days percent @@ -534,7 +546,7 @@ jobs: ## Campaign Orchestrator Rules - This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. It also maintains the campaign dashboard by ensuring the GitHub Project stays in sync with the campaign's tracker label. + This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. The GitHub Project is the single source of truth for campaign membership and state. ### Traffic and rate limits (required) @@ -592,7 +604,7 @@ jobs: - Use an ISO date (UTC) filename, for example: `metrics/2025-12-22.json`. - Keep snapshots append-only: write a new file per run; do not rewrite historical snapshots. - If a KPI is present, record its computed value and trend (Improving/Flat/Regressing) in the kpi_trends array. - - Count tasks from all sources: tracker-labeled issues, worker-created issues, and project board items. + - Count tasks from all sources: project board items (canonical), plus any newly discovered tracker-labeled or worker-created items that will be added. - Set tasks_total to the total number of unique tasks discovered in this run. - Set tasks_completed to the count of tasks with state "Done" or closed status. @@ -617,7 +629,7 @@ jobs: 10. **Predefined fields only** - Only update explicitly defined project board fields 11. **Explicit outcomes** - Record actual outcomes, never infer status 12. **Idempotent operations** - Re-execution produces the same result without corruption - 13. **Dashboard synchronization** - Keep Project items in sync with tracker-labeled issues/PRs + 13. **Dashboard synchronization** - Keep the Project board in sync with discovered work (Project items are canonical; tracker labels are optional ingestion) ### Objective and KPIs (first-class) @@ -656,13 +668,18 @@ jobs: #### Phase 1: Read State (Discovery) - 1. **Query tracker-labeled items** - Search for issues and PRs matching the campaign's tracker label + 1. **Query current project state (canonical)** - Read the GitHub Project board + - Retrieve all items currently on the project board + - For each item, record: content type, URL (if present), draft title (if present), status field value, and other predefined field values + - Create a snapshot of current board state + + 2. **Query tracker-labeled items (optional ingestion)** - If a tracker label is configured, search for issues and PRs matching it - Search: `repo:OWNER/REPO label:TRACKER_LABEL` for all open and closed items - If governance opt-out labels are configured, exclude items with those labels - Collect all matching issue/PR URLs - Record metadata: number, title, state (open/closed), created date, updated date - 2. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids + 3. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids - Worker workflows: daily-doc-updater, docs-noob-tester, daily-multi-device-docs-tester, unbloat-docs, developer-docs-consolidator, technical-doc-writer - **IMPORTANT**: You MUST perform SEPARATE searches for EACH worker workflow listed above - **IMPORTANT**: Workers may create different types of content (issues, PRs, discussions, comments). Search ALL content types to discover all worker outputs. @@ -702,18 +719,29 @@ jobs: - Combine results from all worker searches into a single list of discovered items - Note: Comments are discovered via their parent issue/PR - the issue/PR is what gets added to the board - 3. **Query current project state** - Read the GitHub Project board - - Retrieve all items currently on the project board - - For each item, record: issue URL, status field value, other predefined field values - - Create a snapshot of current board state - - 4. **Compare and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) - - Items from step 1 or 2 not on board = **new work discovered** (report count) - - Items on board with state mismatch = **status updates needed** (report count) + 4. **Merge and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) + - Items on the board are **in scope** by definition (canonical membership) + - Items from steps 2-3 not on board = **new work discovered** (report count) + - Items on board with state mismatch vs issue/PR state = **status updates needed** (report count) - Items on board with missing custom fields (e.g., worker_workflow) = **fields to populate** (report count) - - Items on board but no longer found = **check if archived/deleted** (report count) + - Items on board but no longer accessible = **check if archived/deleted** (report count) - **CRITICAL**: This comparison is for reporting and planning only. In Phase 3, you MUST send ALL discovered items to update-project regardless of whether they appear to be on the board. The update-project tool handles duplicate detection automatically. + 4.8 **Locate the campaign hub issue (optional but recommended)** + + If you have permission and comment writes are allowed, attempt to locate the campaign hub (“epic”) issue for posting per-run summaries. + + Deterministic matching rules: + - Prefer an issue that has BOTH labels: `campaign-tracker` AND `campaign:docs-quality-maintenance-project67`. + - If none found, do NOT guess. Proceed without an epic issue comment. + - If multiple matches, treat as ambiguous and proceed without commenting (report ambiguity). + + If a single hub issue is found, treat it as an in-scope item for synchronization: + - Include it in the Phase 3 `update-project` operations so it is present on the Project board (idempotent). + - When updating the hub issue item, set (when the fields exist): + - `worker_workflow = "orchestrator"` + - `human_oversight_required = "Yes"` + #### Phase 2: Make Decisions (Planning) 4.5 **Deterministic planner step (required when objective/KPIs are present)** @@ -736,12 +764,13 @@ jobs: } ``` - 5. **Decide processing order (with pacing)** - For items discovered in steps 1-2: - - **CRITICAL**: ALL discovered items (both tracker-labeled from step 1 AND worker-created from step 2) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. + 5. **Decide processing order (with pacing)** - For items discovered in steps 1-3: + - **CRITICAL**: ALL discovered items (project items from step 1, tracker-labeled from step 2, and worker-created from step 3) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. - If `governance.max-new-items-per-run` is set, process at most that many items in this single run (remaining items will be processed in subsequent runs) - When applying the governance limit, prioritize in this order: - 1. Tracker-labeled items (campaign tasks) - process oldest first - 2. Worker-created items (worker outputs) - process oldest first + 1. Project board items (canonical scope) - process oldest first + 2. Tracker-labeled items (optional ingestion) - process oldest first + 3. Worker-created items (worker outputs) - process oldest first - Determine appropriate status field value based on item state: - Open issue/PR/discussion → "Todo" status - Closed issue/discussion → "Done" status @@ -770,12 +799,18 @@ jobs: #### Phase 3: Write State (Execution) - **CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-2, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. + **CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-3, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. 8. **Execute project updates** - Send update-project for ALL discovered items - - Process ALL items from steps 1-2 (both tracker-labeled and worker-created), up to the governance limit if set + - Process ALL items from steps 1-3 (project items, tracker-labeled, and worker-created), up to the governance limit if set - Use `update-project` safe-output for EVERY discovered item - - Include fields from steps 5-6.5: `status`, `worker_workflow`, `priority`, `size`, etc. + PROMPT_EOF + - name: Append prompt (part 2) + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: | + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + - Include fields from steps 5-6.5: `status`, `worker_workflow`, `human_oversight_required`, `priority`, `size`, etc. - **The update-project tool will automatically**: - Skip adding items that are already on the board (idempotent add) - Update fields for items already on the board @@ -793,27 +828,36 @@ jobs: #### Phase 4: Report (Output) 10. **Generate status report** - Summarize execution results: - - Total items discovered via tracker label (by type: issues, PRs) - - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) - PROMPT_EOF - - name: Append prompt (part 2) - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: | - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - Items processed with update-project this run (count and URLs, broken down by: tracker-labeled vs worker-created) + - Total items currently on the project board (canonical) + - Total items discovered via tracker label (optional ingestion, by type: issues, PRs) + - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) + - Items processed with update-project this run (count and URLs, broken down by: project-board vs tracker-labeled vs worker-created) - Items skipped due to governance limits (count, type, and why - noting they will be processed in next run) - Current campaign metrics: open vs closed, progress percentage - Any failures encountered during update-project operations - Campaign completion status + 11. **Post a hub issue comment (if hub issue was found and add-comment is allowed)** + + Post a short comment to the campaign hub issue that includes: + - Link to the Project board + - What was processed this run (counts + a few representative URLs) + - The current “Needs human” queue size (items with `human_oversight_required = Yes` if that field is used) + - If repo-memory metrics are enabled, include the path to the latest metrics snapshot written this run + + Do not paste large JSON blobs. Keep the comment concise and human-scannable. + ### Predefined Project Fields Only these fields may be updated on the project board: - - `status` (required) - Values: "Todo", "In Progress", "Done" + - `status` (required) - Values: "Todo", "In Progress", "Blocked", "Done" + - `worker_workflow` (optional) - String (recommended: the worker workflow ID/name) + - `human_oversight_required` (optional) - Values: "Yes", "No" (powers a dedicated human review queue) - `priority` (optional) - Values: "High", "Medium", "Low" - `size` (optional) - Values: "Small", "Medium", "Large" + - `start_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) + - `end_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) - `campaign_status` (metadata) - Values: "active", "completed" Do NOT update any other fields or create custom fields. @@ -843,9 +887,7 @@ jobs: Execute state writes using the `update-project` safe-output. All writes must target this exact project URL: **Project URL**: https://github.com/orgs/githubnext/projects/67 - - **Campaign ID**: Extract from tracker label `campaign:docs-quality-maintenance-project67` (format: `campaign:CAMPAIGN_ID`) - + **Campaign ID**: `docs-quality-maintenance-project67` (recommended to tag items via the optional `campaign_id` field) #### Adding New Issues/PRs @@ -855,7 +897,7 @@ jobs: project: "https://github.com/orgs/githubnext/projects/67" content_type: "issue" # or "pull_request" content_number: 123 # Extract number from URL like https://github.com/owner/repo/issues/123 - campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label campaign:docs-quality-maintenance-project67 + campaign_id: "docs-quality-maintenance-project67" # Optional: tags items for this campaign fields: status: "Todo" # or "Done" if issue/PR is already closed/merged ``` @@ -879,8 +921,9 @@ jobs: content_number: 123 # Extract from URL fields: status: "Todo" # or "In Progress", "Blocked", "Done" - campaign_id: "CAMPAIGN_ID" # Extract from tracker label campaign:docs-quality-maintenance-project67 + campaign_id: "docs-quality-maintenance-project67" # Optional: tags items for this campaign worker_workflow: "WORKFLOW_ID" # Enables swimlane grouping and filtering + human_oversight_required: "No" # or "Yes" - powers a dedicated human review queue priority: "High" # or "Medium", "Low" - enables priority-based views effort: "Medium" # or "Small", "Large" - enables capacity planning team: "TEAM_NAME" # Optional: for team-based grouping @@ -889,6 +932,7 @@ jobs: **Custom Field Benefits**: - `worker_workflow`: Groups items by workflow in Roadmap swimlanes; enables "Slice by" filtering in Table views (orchestrator populates this by discovering which worker created the item via tracker-id) + - `human_oversight_required`: Creates an explicit human review queue; use a "Needs human" view filtered to "Yes" - `priority`: Enables priority-based filtering and sorting - `effort`: Supports capacity planning and workload distribution - `team`: Enables team-based grouping for multi-team campaigns @@ -906,7 +950,7 @@ jobs: project: "https://github.com/orgs/githubnext/projects/67" content_type: "issue" # or "pull_request" content_number: 123 # Extract from URL - campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label campaign:docs-quality-maintenance-project67 + campaign_id: "docs-quality-maintenance-project67" # Optional: tags items for this campaign fields: status: "Done" # or "In Progress", "Todo" ``` diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.lock.yml index 0314accb8b9..dcf61bce87f 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.lock.yml +++ b/.github/workflows/go-file-size-reduction-project64.campaign.lock.yml @@ -365,9 +365,20 @@ jobs: "type": "string", "enum": [ "issue", - "pull_request" + "pull_request", + "draft_issue" ] }, + "draft_body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "draft_title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, "fields": { "type": "object" }, @@ -385,7 +396,8 @@ jobs: "pull_request": { "optionalPositiveInteger": true } - } + }, + "customValidation": "updateProjectValidTarget" } } EOF @@ -515,7 +527,7 @@ jobs: This workflow orchestrates the 'Go File Size Reduction Campaign (Project 64)' campaign. - - Tracker label: `campaign:go-file-size-reduction-project64` + - Tracker label (optional ingestion): `campaign:go-file-size-reduction-project64` - Objective: Reduce all Go files to ≤800 lines of code while maintaining test coverage and preventing regressions - KPIs: - Files reduced to target size (primary): baseline 0 → target 100 over 90 days percent @@ -533,7 +545,7 @@ jobs: ## Campaign Orchestrator Rules - This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. It also maintains the campaign dashboard by ensuring the GitHub Project stays in sync with the campaign's tracker label. + This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. The GitHub Project is the single source of truth for campaign membership and state. ### Traffic and rate limits (required) @@ -591,7 +603,7 @@ jobs: - Use an ISO date (UTC) filename, for example: `metrics/2025-12-22.json`. - Keep snapshots append-only: write a new file per run; do not rewrite historical snapshots. - If a KPI is present, record its computed value and trend (Improving/Flat/Regressing) in the kpi_trends array. - - Count tasks from all sources: tracker-labeled issues, worker-created issues, and project board items. + - Count tasks from all sources: project board items (canonical), plus any newly discovered tracker-labeled or worker-created items that will be added. - Set tasks_total to the total number of unique tasks discovered in this run. - Set tasks_completed to the count of tasks with state "Done" or closed status. @@ -616,7 +628,7 @@ jobs: 10. **Predefined fields only** - Only update explicitly defined project board fields 11. **Explicit outcomes** - Record actual outcomes, never infer status 12. **Idempotent operations** - Re-execution produces the same result without corruption - 13. **Dashboard synchronization** - Keep Project items in sync with tracker-labeled issues/PRs + 13. **Dashboard synchronization** - Keep the Project board in sync with discovered work (Project items are canonical; tracker labels are optional ingestion) ### Objective and KPIs (first-class) @@ -653,13 +665,18 @@ jobs: #### Phase 1: Read State (Discovery) - 1. **Query tracker-labeled items** - Search for issues and PRs matching the campaign's tracker label + 1. **Query current project state (canonical)** - Read the GitHub Project board + - Retrieve all items currently on the project board + - For each item, record: content type, URL (if present), draft title (if present), status field value, and other predefined field values + - Create a snapshot of current board state + + 2. **Query tracker-labeled items (optional ingestion)** - If a tracker label is configured, search for issues and PRs matching it - Search: `repo:OWNER/REPO label:TRACKER_LABEL` for all open and closed items - If governance opt-out labels are configured, exclude items with those labels - Collect all matching issue/PR URLs - Record metadata: number, title, state (open/closed), created date, updated date - 2. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids + 3. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids - Worker workflows: daily-file-diet - **IMPORTANT**: You MUST perform SEPARATE searches for EACH worker workflow listed above - **IMPORTANT**: Workers may create different types of content (issues, PRs, discussions, comments). Search ALL content types to discover all worker outputs. @@ -674,18 +691,29 @@ jobs: - Combine results from all worker searches into a single list of discovered items - Note: Comments are discovered via their parent issue/PR - the issue/PR is what gets added to the board - 3. **Query current project state** - Read the GitHub Project board - - Retrieve all items currently on the project board - - For each item, record: issue URL, status field value, other predefined field values - - Create a snapshot of current board state - - 4. **Compare and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) - - Items from step 1 or 2 not on board = **new work discovered** (report count) - - Items on board with state mismatch = **status updates needed** (report count) + 4. **Merge and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) + - Items on the board are **in scope** by definition (canonical membership) + - Items from steps 2-3 not on board = **new work discovered** (report count) + - Items on board with state mismatch vs issue/PR state = **status updates needed** (report count) - Items on board with missing custom fields (e.g., worker_workflow) = **fields to populate** (report count) - - Items on board but no longer found = **check if archived/deleted** (report count) + - Items on board but no longer accessible = **check if archived/deleted** (report count) - **CRITICAL**: This comparison is for reporting and planning only. In Phase 3, you MUST send ALL discovered items to update-project regardless of whether they appear to be on the board. The update-project tool handles duplicate detection automatically. + 4.8 **Locate the campaign hub issue (optional but recommended)** + + If you have permission and comment writes are allowed, attempt to locate the campaign hub (“epic”) issue for posting per-run summaries. + + Deterministic matching rules: + - Prefer an issue that has BOTH labels: `campaign-tracker` AND `campaign:go-file-size-reduction-project64`. + - If none found, do NOT guess. Proceed without an epic issue comment. + - If multiple matches, treat as ambiguous and proceed without commenting (report ambiguity). + + If a single hub issue is found, treat it as an in-scope item for synchronization: + - Include it in the Phase 3 `update-project` operations so it is present on the Project board (idempotent). + - When updating the hub issue item, set (when the fields exist): + - `worker_workflow = "orchestrator"` + - `human_oversight_required = "Yes"` + #### Phase 2: Make Decisions (Planning) 4.5 **Deterministic planner step (required when objective/KPIs are present)** @@ -708,12 +736,13 @@ jobs: } ``` - 5. **Decide processing order (with pacing)** - For items discovered in steps 1-2: - - **CRITICAL**: ALL discovered items (both tracker-labeled from step 1 AND worker-created from step 2) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. + 5. **Decide processing order (with pacing)** - For items discovered in steps 1-3: + - **CRITICAL**: ALL discovered items (project items from step 1, tracker-labeled from step 2, and worker-created from step 3) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. - If `governance.max-new-items-per-run` is set, process at most that many items in this single run (remaining items will be processed in subsequent runs) - When applying the governance limit, prioritize in this order: - 1. Tracker-labeled items (campaign tasks) - process oldest first - 2. Worker-created items (worker outputs) - process oldest first + 1. Project board items (canonical scope) - process oldest first + 2. Tracker-labeled items (optional ingestion) - process oldest first + 3. Worker-created items (worker outputs) - process oldest first - Determine appropriate status field value based on item state: - Open issue/PR/discussion → "Todo" status - Closed issue/discussion → "Done" status @@ -742,12 +771,12 @@ jobs: #### Phase 3: Write State (Execution) - **CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-2, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. + **CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-3, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. 8. **Execute project updates** - Send update-project for ALL discovered items - - Process ALL items from steps 1-2 (both tracker-labeled and worker-created), up to the governance limit if set + - Process ALL items from steps 1-3 (project items, tracker-labeled, and worker-created), up to the governance limit if set - Use `update-project` safe-output for EVERY discovered item - - Include fields from steps 5-6.5: `status`, `worker_workflow`, `priority`, `size`, etc. + - Include fields from steps 5-6.5: `status`, `worker_workflow`, `human_oversight_required`, `priority`, `size`, etc. - **The update-project tool will automatically**: - Skip adding items that are already on the board (idempotent add) - Update fields for items already on the board @@ -765,21 +794,42 @@ jobs: #### Phase 4: Report (Output) 10. **Generate status report** - Summarize execution results: - - Total items discovered via tracker label (by type: issues, PRs) - - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) - - Items processed with update-project this run (count and URLs, broken down by: tracker-labeled vs worker-created) + - Total items currently on the project board (canonical) + - Total items discovered via tracker label (optional ingestion, by type: issues, PRs) + - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) + - Items processed with update-project this run (count and URLs, broken down by: project-board vs tracker-labeled vs worker-created) - Items skipped due to governance limits (count, type, and why - noting they will be processed in next run) - Current campaign metrics: open vs closed, progress percentage - Any failures encountered during update-project operations - Campaign completion status + 11. **Post a hub issue comment (if hub issue was found and add-comment is allowed)** + + Post a short comment to the campaign hub issue that includes: + - Link to the Project board + - What was processed this run (counts + a few representative URLs) + - The current “Needs human” queue size (items with `human_oversight_required = Yes` if that field is used) + - If repo-memory metrics are enabled, include the path to the latest metrics snapshot written this run + + Do not paste large JSON blobs. Keep the comment concise and human-scannable. + ### Predefined Project Fields Only these fields may be updated on the project board: - - `status` (required) - Values: "Todo", "In Progress", "Done" + - `status` (required) - Values: "Todo", "In Progress", "Blocked", "Done" + - `worker_workflow` (optional) - String (recommended: the worker workflow ID/name) + - `human_oversight_required` (optional) - Values: "Yes", "No" (powers a dedicated human review queue) - `priority` (optional) - Values: "High", "Medium", "Low" - `size` (optional) - Values: "Small", "Medium", "Large" + - `start_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) + - `end_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) + PROMPT_EOF + - name: Append prompt (part 2) + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: | + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - `campaign_status` (metadata) - Values: "active", "completed" Do NOT update any other fields or create custom fields. @@ -809,9 +859,7 @@ jobs: Execute state writes using the `update-project` safe-output. All writes must target this exact project URL: **Project URL**: https://github.com/orgs/githubnext/projects/64 - - **Campaign ID**: Extract from tracker label `campaign:go-file-size-reduction-project64` (format: `campaign:CAMPAIGN_ID`) - + **Campaign ID**: `go-file-size-reduction-project64` (recommended to tag items via the optional `campaign_id` field) #### Adding New Issues/PRs @@ -821,13 +869,7 @@ jobs: project: "https://github.com/orgs/githubnext/projects/64" content_type: "issue" # or "pull_request" content_number: 123 # Extract number from URL like https://github.com/owner/repo/issues/123 - campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label campaign:go-file-size-reduction-project64 - PROMPT_EOF - - name: Append prompt (part 2) - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: | - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + campaign_id: "go-file-size-reduction-project64" # Optional: tags items for this campaign fields: status: "Todo" # or "Done" if issue/PR is already closed/merged ``` @@ -851,8 +893,9 @@ jobs: content_number: 123 # Extract from URL fields: status: "Todo" # or "In Progress", "Blocked", "Done" - campaign_id: "CAMPAIGN_ID" # Extract from tracker label campaign:go-file-size-reduction-project64 + campaign_id: "go-file-size-reduction-project64" # Optional: tags items for this campaign worker_workflow: "WORKFLOW_ID" # Enables swimlane grouping and filtering + human_oversight_required: "No" # or "Yes" - powers a dedicated human review queue priority: "High" # or "Medium", "Low" - enables priority-based views effort: "Medium" # or "Small", "Large" - enables capacity planning team: "TEAM_NAME" # Optional: for team-based grouping @@ -861,6 +904,7 @@ jobs: **Custom Field Benefits**: - `worker_workflow`: Groups items by workflow in Roadmap swimlanes; enables "Slice by" filtering in Table views (orchestrator populates this by discovering which worker created the item via tracker-id) + - `human_oversight_required`: Creates an explicit human review queue; use a "Needs human" view filtered to "Yes" - `priority`: Enables priority-based filtering and sorting - `effort`: Supports capacity planning and workload distribution - `team`: Enables team-based grouping for multi-team campaigns @@ -878,7 +922,7 @@ jobs: project: "https://github.com/orgs/githubnext/projects/64" content_type: "issue" # or "pull_request" content_number: 123 # Extract from URL - campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label campaign:go-file-size-reduction-project64 + campaign_id: "go-file-size-reduction-project64" # Optional: tags items for this campaign fields: status: "Done" # or "In Progress", "Todo" ``` diff --git a/.github/workflows/playground-org-project-update-issue.lock.yml b/.github/workflows/playground-org-project-update-issue.lock.yml index afd526c4f62..d3eeda374a6 100644 --- a/.github/workflows/playground-org-project-update-issue.lock.yml +++ b/.github/workflows/playground-org-project-update-issue.lock.yml @@ -313,9 +313,20 @@ jobs: "type": "string", "enum": [ "issue", - "pull_request" + "pull_request", + "draft_issue" ] }, + "draft_body": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "draft_title": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, "fields": { "type": "object" }, @@ -333,7 +344,8 @@ jobs: "pull_request": { "optionalPositiveInteger": true } - } + }, + "customValidation": "updateProjectValidTarget" } } EOF diff --git a/actions/setup/js/mark_pull_request_as_ready_for_review.cjs b/actions/setup/js/mark_pull_request_as_ready_for_review.cjs index 97549202792..bf50efb83c8 100644 --- a/actions/setup/js/mark_pull_request_as_ready_for_review.cjs +++ b/actions/setup/js/mark_pull_request_as_ready_for_review.cjs @@ -4,6 +4,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { generateFooter } = require("./generate_footer.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { getErrorMessage } = require("./error_helpers.cjs"); /** * Generate staged preview for mark-pull-request-as-ready-for-review items @@ -69,6 +70,8 @@ async function markPullRequestAsReadyForReview(github, owner, repo, prNumber, re // Add comment with reason const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`; const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; diff --git a/actions/setup/js/package-lock.json b/actions/setup/js/package-lock.json index e4090cb2b2f..a26075db1eb 100644 --- a/actions/setup/js/package-lock.json +++ b/actions/setup/js/package-lock.json @@ -775,7 +775,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1316,7 +1315,6 @@ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1456,7 +1454,6 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -1897,7 +1894,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2091,7 +2087,6 @@ "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -2213,7 +2208,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2289,7 +2283,6 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", diff --git a/actions/setup/js/safe_output_type_validator.cjs b/actions/setup/js/safe_output_type_validator.cjs index 550b4c89397..96e0d31ff6e 100644 --- a/actions/setup/js/safe_output_type_validator.cjs +++ b/actions/setup/js/safe_output_type_validator.cjs @@ -465,6 +465,38 @@ function executeCustomValidation(item, customValidation, lineNum, itemType) { } } + if (customValidation === "updateProjectValidTarget") { + const contentType = item.content_type; + + if (contentType === "draft_issue") { + const draftTitle = typeof item.draft_title === "string" ? item.draft_title.trim() : ""; + if (!draftTitle) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} 'draft_title' is required and must be a non-empty string when 'content_type' is 'draft_issue'`, + }; + } + return null; + } + + const normalize = v => { + if (v === undefined || v === null) return ""; + if (typeof v === "number") return Number.isFinite(v) ? String(v) : ""; + return String(v).trim(); + }; + + const hasContentNumber = normalize(item.content_number) !== ""; + const hasIssue = normalize(item.issue) !== ""; + const hasPullRequest = normalize(item.pull_request) !== ""; + + if (!hasContentNumber && !hasIssue && !hasPullRequest) { + return { + isValid: false, + error: `Line ${lineNum}: ${itemType} requires one of: 'content_number', 'issue', or 'pull_request' (or set 'content_type' to 'draft_issue' with 'draft_title')`, + }; + } + } + return null; } diff --git a/actions/setup/js/safe_output_type_validator.test.cjs b/actions/setup/js/safe_output_type_validator.test.cjs index f984515282e..d0d4bd24e94 100644 --- a/actions/setup/js/safe_output_type_validator.test.cjs +++ b/actions/setup/js/safe_output_type_validator.test.cjs @@ -96,6 +96,28 @@ const SAMPLE_VALIDATION_CONFIG = { }, }, }, + update_project: { + defaultMax: 10, + customValidation: "updateProjectValidTarget", + fields: { + project: { + required: true, + type: "string", + sanitize: true, + maxLength: 512, + pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + patternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)", + }, + campaign_id: { type: "string", sanitize: true, maxLength: 128 }, + content_type: { type: "string", enum: ["issue", "pull_request", "draft_issue"] }, + content_number: { optionalPositiveInteger: true }, + issue: { optionalPositiveInteger: true }, + pull_request: { optionalPositiveInteger: true }, + draft_title: { type: "string", sanitize: true, maxLength: 256 }, + draft_body: { type: "string", sanitize: true, maxLength: 65000 }, + fields: { type: "object" }, + }, + }, }; describe("safe_output_type_validator", () => { @@ -396,6 +418,75 @@ describe("safe_output_type_validator", () => { }); }); + describe("custom validation: updateProjectValidTarget", () => { + it("should fail when content_type is draft_issue and draft_title is missing", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "update_project", + project: "https://github.com/orgs/acme/projects/42", + content_type: "draft_issue", + }, + "update_project", + 1 + ); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("draft_title"); + }); + + it("should pass when content_type is draft_issue and draft_title is provided", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "update_project", + project: "https://github.com/orgs/acme/projects/42", + content_type: "draft_issue", + draft_title: "Investigate flaky CI", + }, + "update_project", + 1 + ); + + expect(result.isValid).toBe(true); + }); + + it("should fail when non-draft item has no target identifiers", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "update_project", + project: "https://github.com/orgs/acme/projects/42", + }, + "update_project", + 1 + ); + + expect(result.isValid).toBe(false); + expect(result.error).toContain("requires one of"); + }); + + it("should pass when non-draft item provides content_number", async () => { + const { validateItem } = await import("./safe_output_type_validator.cjs"); + + const result = validateItem( + { + type: "update_project", + project: "https://github.com/orgs/acme/projects/42", + content_type: "issue", + content_number: 123, + }, + "update_project", + 1 + ); + + expect(result.isValid).toBe(true); + }); + }); + describe("enum validation", () => { it("should validate enum values (case-insensitive)", async () => { const { validateItem } = await import("./safe_output_type_validator.cjs"); diff --git a/actions/setup/js/update_project.cjs b/actions/setup/js/update_project.cjs index 86e160652cb..03f380c7366 100644 --- a/actions/setup/js/update_project.cjs +++ b/actions/setup/js/update_project.cjs @@ -4,6 +4,37 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); +/** + * Normalize a field name for matching against existing Project field names. + * This intentionally treats punctuation (like "/") as whitespace so that + * keys like "worker_workflow" can match field names like "Worker/Workflow". + * + * @param {unknown} name + * @returns {string} + */ +function normalizeFieldNameForComparison(name) { + return String(name || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +/** + * Convert a key-like field name (snake_case, kebab-case, etc) into a display + * name suitable for creating/renaming Project fields. + * + * @param {unknown} fieldKey + * @returns {string} + */ +function toProjectFieldDisplayName(fieldKey) { + return String(fieldKey || "") + .trim() + .split(/[^a-zA-Z0-9]+/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + /** * Log detailed GraphQL error information * @param {Error & { errors?: Array<{ type?: string, message: string, path?: unknown, locations?: unknown }>, request?: unknown, data?: unknown }} error - GraphQL error @@ -379,19 +410,17 @@ async function updateProject(output) { ) ).node.fields.nodes; for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); + const wantedFieldName = normalizeFieldNameForComparison(fieldName); + const displayFieldName = toProjectFieldDisplayName(fieldName); let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + field = projectFields.find(f => normalizeFieldNameForComparison(f.name) === wantedFieldName); if (!field) if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) try { field = ( await github.graphql( "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } + { projectId, name: displayFieldName, dataType: "TEXT" } ) ).createProjectV2Field.projectV2Field; } catch (createError) { @@ -403,7 +432,7 @@ async function updateProject(output) { field = ( await github.graphql( "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + { projectId, name: displayFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } ) ).createProjectV2Field.projectV2Field; } catch (createError) { @@ -501,19 +530,17 @@ async function updateProject(output) { ) ).node.fields.nodes; for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); + const wantedFieldName = normalizeFieldNameForComparison(fieldName); + const displayFieldName = toProjectFieldDisplayName(fieldName); let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + field = projectFields.find(f => normalizeFieldNameForComparison(f.name) === wantedFieldName); if (!field) if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) try { field = ( await github.graphql( "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } + { projectId, name: displayFieldName, dataType: "TEXT" } ) ).createProjectV2Field.projectV2Field; } catch (createError) { @@ -525,7 +552,7 @@ async function updateProject(output) { field = ( await github.graphql( "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + { projectId, name: displayFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } ) ).createProjectV2Field.projectV2Field; } catch (createError) { diff --git a/actions/setup/js/update_project.test.cjs b/actions/setup/js/update_project.test.cjs index 8e6e88be106..74e7f2323ee 100644 --- a/actions/setup/js/update_project.test.cjs +++ b/actions/setup/js/update_project.test.cjs @@ -542,6 +542,47 @@ describe("updateProject", () => { expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to add campaign label")); }); + it("matches existing field names with slashes (Worker/Workflow) when using snake_case keys", async () => { + const projectUrl = "https://github.com/orgs/testowner/projects/60"; + const output = { + type: "update_project", + project: projectUrl, + content_type: "issue", + content_number: 88, + fields: { + worker_workflow: "orchestrator", + }, + }; + + queueResponses([ + repoResponse(), + viewerResponse(), + orgProjectV2Response(projectUrl, 60, "project-worker-workflow"), + issueResponse("issue-id-88"), + existingItemResponse("issue-id-88", "item-worker-workflow"), + fieldsResponse([ + { + id: "field-worker-workflow", + name: "Worker/Workflow", + dataType: "SINGLE_SELECT", + options: [{ id: "opt-orchestrator", name: "orchestrator", color: "GRAY" }], + }, + ]), + updateFieldValueResponse(), + ]); + + await updateProject(output); + + // Should update existing field value, not try to create a new field. + const createdFieldCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("createProjectV2Field")); + expect(createdFieldCall).toBeUndefined(); + + const updateCall = mockGithub.graphql.mock.calls.find(([query]) => query.includes("updateProjectV2ItemFieldValue")); + expect(updateCall).toBeDefined(); + expect(updateCall[1].fieldId).toBe("field-worker-workflow"); + expect(updateCall[1].value).toEqual({ singleSelectOptionId: "opt-orchestrator" }); + }); + it("rejects non-URL project identifier", async () => { const output = { type: "update_project", project: "My Campaign", campaign_id: "my-campaign-123" }; await expect(updateProject(output)).rejects.toThrow(/full GitHub project URL/); diff --git a/docs/src/content/docs/guides/campaigns/getting-started.md b/docs/src/content/docs/guides/campaigns/getting-started.md index 1e42fd7f6e9..85ddafc18a3 100644 --- a/docs/src/content/docs/guides/campaigns/getting-started.md +++ b/docs/src/content/docs/guides/campaigns/getting-started.md @@ -21,10 +21,12 @@ In GitHub: your org → **Projects** → **New project**. - Start with a **Table** view (simplest option) - Add a **Board** view grouped by `Status` for kanban-style tracking - Consider a **Roadmap** view for timeline visualization (requires Start Date/End Date fields) +- Add a dedicated “Needs human” view filtered by `Human Oversight Required = Yes` **Recommended custom fields** (see [Project Management](/gh-aw/guides/campaigns/project-management/) for details): - **Status** (Single select): Todo, In Progress, Blocked, Done - **Worker/Workflow** (Single select): Names of your worker workflows +- **Human Oversight Required** (Single select): Yes, No - **Priority** (Single select): High, Medium, Low - **Start Date** / **End Date** (Date): For roadmap timeline views @@ -77,6 +79,21 @@ Trigger the orchestrator workflow from GitHub Actions. Its job is to keep the da - Updates fields/status - Posts a short report +### Where the epic issue, summaries, and metrics go + +- **Epic issue (campaign hub)**: The issue created from the “🚀 Start an Agentic Campaign” issue form. This is the human-facing command center for decisions and context. +- **Campaign summaries**: Each orchestrator run should produce a short, human-readable summary in the GitHub Actions run summary. If you want the same summary posted to the epic issue, label the epic issue with `campaign:` once you know the campaign id. +- **Metrics**: Durable metrics/KPI snapshots are written to repo-memory (when enabled via metrics globs in the campaign spec). These JSON snapshots are the canonical machine-readable history; the orchestrator can also include a short “latest metrics” excerpt in the run summary and/or epic issue comment. + +**Tip (recommended)**: After the campaign spec exists and you know the `id`, add a label like `campaign:framework-upgrade` to the epic issue. This gives the orchestrator a deterministic way to find the hub issue and post summaries without guessing. + +**Make the epic issue visible on the Project board (Option A)**: Add the epic issue to the Project board (it can live alongside the campaign tasks). If your board has these fields, set: + +- `Worker/Workflow = orchestrator` +- `Human Oversight Required = Yes` + +This keeps the campaign hub in your “Needs human” queue and makes it obvious in Roadmap/Board/Table views. + ## 5) Add work items Apply the tracker label (for example `campaign:framework-upgrade`) to issues/PRs you want tracked. The orchestrator will pick them up on the next run. diff --git a/docs/src/content/docs/guides/campaigns/project-management.md b/docs/src/content/docs/guides/campaigns/project-management.md index 30126e1e34d..7fa0485b1e7 100644 --- a/docs/src/content/docs/guides/campaigns/project-management.md +++ b/docs/src/content/docs/guides/campaigns/project-management.md @@ -5,10 +5,50 @@ description: "Use GitHub Projects with roadmap views and custom date fields for GitHub Projects offers powerful visualization and tracking capabilities for agentic campaigns. This guide covers view configurations, custom fields, and filtering strategies to maximize campaign visibility and control. +## Where summaries and metrics live + +- **Project board**: Canonical state for tasks (membership, status, worker/workflow, oversight flags). Keep it structured and queryable. +- **Epic issue (campaign hub)**: Human decision log and narrative context (created via the campaign issue form, labeled `campaign` + `campaign-tracker`). +- **Orchestrator run summary**: The per-run execution report (what changed, what was discovered, what needs attention). +- **Repo-memory metrics**: The durable, machine-readable history (metrics/KPI snapshots written as JSON files when `metrics-glob` is configured). + +**Recommended wiring**: Once the campaign has an `id`, label the epic issue with `campaign:` (for example `campaign:framework-upgrade`). This enables orchestrators to reliably find the hub issue and post a short per-run comment without guessing. + +## Make the epic issue visible on the board (Option A) + +If you want the campaign hub to show up directly in your Roadmap/Board/Table views, add the epic issue as a Project item. + +Recommended field values for the epic issue item: +- **Worker/Workflow**: `orchestrator` +- **Human Oversight Required**: `Yes` + +This keeps the hub in the human review queue and makes it easy to find from any view. + ## Recommended Custom Fields for Campaigns Before configuring views, set up custom fields that provide valuable filtering and grouping capabilities: +## Recommended Views (high-value defaults) + +After the fields below exist, create a small set of views that each answer one question: + +1. **Roadmap** (Roadmap layout) + - Group by: **Worker/Workflow** + - Date fields: **Start Date** and **End Date** + - Filter: `Status != Done` + +2. **Board** (Board layout) + - Group by: **Status** + - Saved filter: `Human Oversight Required = Yes` (your human review queue) + +3. **Backlog** (Table layout) + - Columns: `Status`, `Human Oversight Required`, `Worker/Workflow`, `Priority` + - Use “Slice by” on **Worker/Workflow** to review one worker at a time + +4. **Exceptions** (Table layout) + - Filter: `Status = Blocked OR Human Oversight Required = Yes` + - Keep this view small and human-operated + ### Essential Campaign Fields **One-time manual setup** (in the GitHub Projects UI): @@ -28,23 +68,28 @@ Before configuring views, set up custom fields that provide valuable filtering a - Purpose: Track work state across the campaign - Default field in most project templates -4. **Start Date** (Date) +4. **Human Oversight Required** (Single select) + - Values: Yes, No + - Purpose: Create an explicit human review queue (triage, approvals, risky changes) + - Use this to power a dedicated “Needs human” view + +5. **Start Date** (Date) - Purpose: When work begins (auto-populated from issue `createdAt`) - Required for Roadmap timeline visualization -5. **End Date** (Date) +6. **End Date** (Date) - Purpose: When work completes (auto-populated from issue `closedAt`) - Required for Roadmap timeline visualization -6. **Effort** (Single select - optional) +7. **Effort** (Single select - optional) - Values: Small (1-3 days), Medium (1 week), Large (2+ weeks) - Purpose: Estimate work size for capacity planning -7. **Team** (Single select - optional) +8. **Team** (Single select - optional) - Values: Frontend, Backend, DevOps, Documentation, etc. - Purpose: Track which team or area owns the work -8. **Repository** (Single select - optional, for cross-repository campaigns) +9. **Repository** (Single select - optional, for cross-repository campaigns) - Values: Repository names (e.g., "gh-aw", "docs-site", "api-server") - Purpose: Track which repository an item belongs to - Enables filtering and grouping by repository in multi-repo campaigns diff --git a/docs/src/content/docs/guides/campaigns/specs.md b/docs/src/content/docs/guides/campaigns/specs.md index 9e7568a67d9..20aa4d03826 100644 --- a/docs/src/content/docs/guides/campaigns/specs.md +++ b/docs/src/content/docs/guides/campaigns/specs.md @@ -1,20 +1,21 @@ --- title: "Campaign Specs" -description: "Define and configure agentic campaigns with spec files, tracker labels, and recommended wiring" +description: "Define and configure agentic campaigns with spec files and GitHub Projects" --- Campaigns are defined as Markdown files under `.github/workflows/` with a `.campaign.md` suffix. The YAML frontmatter is the campaign “contract”; the body can contain optional narrative context. ## What a campaign is (in gh-aw) -In GitHub Agentic Workflows, a campaign is not “a special kind of workflow.” The `.campaign.md` file is a specification: a reviewable contract that wires together agentic workflows around a shared initiative (a tracker label, a GitHub Project dashboard, and optional durable state). +In GitHub Agentic Workflows, a campaign is not “a special kind of workflow.” The `.campaign.md` file is a specification: a reviewable contract that wires together agentic workflows around a shared initiative (a GitHub Project dashboard, optional ingestion signals like tracker labels, and optional durable state). In a typical setup: - Worker workflows do the work. They run an agent and use safe-outputs (for example `create_pull_request`, `add_comment`, or `update_issues`) for write operations. -- A generated orchestrator workflow keeps the campaign coherent over time. It discovers items tagged with your tracker label, updates the Project board, and produces ongoing progress reporting. +- A generated orchestrator workflow keeps the campaign coherent over time. It reads the campaign Project, updates Project fields, and produces ongoing progress reporting. - Repo-memory (optional) makes the campaign repeatable. It lets you store a cursor checkpoint and append-only metrics snapshots so each run can pick up where the last one left off. + ### Mental model ```mermaid @@ -23,7 +24,7 @@ flowchart TB compile["fa:fa-cogs gh aw compile"] debug["fa:fa-file .campaign.g.md
debug artifact
(not tracked)
"] lock["fa:fa-lock .campaign.lock.yml
compiled workflow
(tracked in git)
"] - orchestrator["fa:fa-sitemap Orchestrator workflow
discovers items via tracker-label
updates Project dashboard
reads/writes repo-memory
"] + orchestrator["fa:fa-sitemap Orchestrator workflow
reads Project items
updates Project fields
reads/writes repo-memory
"] worker1["fa:fa-robot Worker workflow
agent + safe-outputs"] worker2["fa:fa-robot Worker workflow
agent + safe-outputs"] project["fa:fa-table GitHub Project board
campaign dashboard"] @@ -35,8 +36,8 @@ flowchart TB lock --> orchestrator orchestrator -->|triggers/coordinates| worker1 orchestrator -->|triggers/coordinates| worker2 - worker1 -->|creates/updates
Issues/PRs with
tracker-label| project - worker2 -->|creates/updates
Issues/PRs with
tracker-label| project + worker1 -->|creates/updates
Issues/PRs
(optional tracker-label)| project + worker2 -->|creates/updates
Issues/PRs
(optional tracker-label)| project orchestrator -.->|reads/writes| memory project -.->|dashboard view| orchestrator @@ -92,11 +93,30 @@ owners: - `id`: stable identifier used for file naming, reporting, and (if used) repo-memory paths. - `project-url`: the GitHub Project that acts as the campaign dashboard. -- `tracker-label`: the label applied to issues and pull requests that belong to the campaign (commonly `campaign:`). This is the key that lets the orchestrator discover work across runs. +- `tracker-label` (optional): a label applied to issues and pull requests (commonly `campaign:`) to help discovery/ingestion. For a robust campaign, Project membership is the source of truth for “in scope”. - `objective`: a single sentence describing what “done” means. - `kpis`: the measures you use to report progress (exactly one should be marked `primary`). - `workflows`: the participating workflow IDs. These refer to workflows in the repo (commonly `.github/workflows/.md`), and they can be scheduled, event-driven, or long-running. + +## Membership contract (one Project per campaign) + +For a robust campaign, keep the membership rule explicit and deterministic: + +- “In scope” = “is a Project item in the campaign Project”. +- Non-trackable work = Project draft item. +- Labels, workflow history, and free-form text are not membership signals (they can still be useful context). + +This keeps campaigns worker-agnostic because membership does not depend on which workflow (or AI engine) created/updated an item. + +## Tracker labels (optional ingestion) + +If you want labels, treat them as a convenience for discovery/ingestion: + +- Worker workflows may add `tracker-label` to issues/PRs they create. +- The orchestrator may use the label as a helper to auto-add items into the Project. +- The Project remains the system of record for membership and progress. + ## KPIs (recommended shape) Keep KPIs small and crisp: @@ -141,7 +161,7 @@ governance: ## Compilation and orchestrators -`gh aw compile` validates campaign specs. When the spec has meaningful details (tracker label, workflows, memory paths, or a metrics glob), it also generates an orchestrator and compiles it to `.campaign.lock.yml`. +`gh aw compile` validates campaign specs. When the spec has meaningful details (project URL, workflows, memory paths, or a metrics glob), it also generates an orchestrator and compiles it to `.campaign.lock.yml`. During compilation, a `.campaign.g.md` file is generated locally as a debug artifact to help developers understand the orchestrator structure, but this file is not committed to git—only the compiled `.campaign.lock.yml` is tracked. diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 75bc066743b..7c823b09696 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -93,7 +93,11 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W description := spec.Description if strings.TrimSpace(description) == "" { - description = fmt.Sprintf("Orchestrator workflow for campaign '%s' (tracker: %s)", spec.ID, spec.TrackerLabel) + if strings.TrimSpace(spec.TrackerLabel) != "" { + description = fmt.Sprintf("Orchestrator workflow for campaign '%s' (tracker: %s)", spec.ID, spec.TrackerLabel) + } else { + description = fmt.Sprintf("Orchestrator workflow for campaign '%s'", spec.ID) + } } // Default triggers: daily schedule plus manual workflow_dispatch. @@ -112,7 +116,7 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W hasDetails := false if spec.TrackerLabel != "" { - fmt.Fprintf(markdownBuilder, "- Tracker label: `%s`\n", spec.TrackerLabel) + fmt.Fprintf(markdownBuilder, "- Tracker label (optional ingestion): `%s`\n", spec.TrackerLabel) hasDetails = true } if strings.TrimSpace(spec.Objective) != "" { diff --git a/pkg/campaign/orchestrator_test.go b/pkg/campaign/orchestrator_test.go index da1eabe2079..9d41a973268 100644 --- a/pkg/campaign/orchestrator_test.go +++ b/pkg/campaign/orchestrator_test.go @@ -51,7 +51,7 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) { t.Fatalf("expected markdown content to mention campaign name, got: %q", data.MarkdownContent) } - if !strings.Contains(data.MarkdownContent, spec.TrackerLabel) { + if spec.TrackerLabel != "" && !strings.Contains(data.MarkdownContent, spec.TrackerLabel) { t.Fatalf("expected markdown content to mention tracker label %q, got: %q", spec.TrackerLabel, data.MarkdownContent) } @@ -62,6 +62,31 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) { } } +func TestBuildOrchestrator_NoTrackerLabelDoesNotMentionTracker(t *testing.T) { + spec := &CampaignSpec{ + ID: "test-campaign", + Name: "Test Campaign", + ProjectURL: "https://github.com/orgs/test/projects/1", + Workflows: []string{"test-workflow"}, + TrackerLabel: "", + } + + mdPath := ".github/workflows/test-campaign.campaign.md" + data, _ := BuildOrchestrator(spec, mdPath) + + if data == nil { + t.Fatalf("expected non-nil WorkflowData") + } + + if strings.Contains(data.MarkdownContent, "- Tracker label") { + t.Fatalf("did not expect tracker label bullet when tracker-label is omitted, got: %q", data.MarkdownContent) + } + + if strings.Contains(data.Description, "tracker:") { + t.Fatalf("did not expect default description to include tracker when tracker-label is omitted, got: %q", data.Description) + } +} + func TestBuildOrchestrator_CompletionInstructions(t *testing.T) { spec := &CampaignSpec{ ID: "test-campaign", diff --git a/pkg/campaign/prompts/orchestrator_instructions.md b/pkg/campaign/prompts/orchestrator_instructions.md index 6f5d38cc446..c59a988def2 100644 --- a/pkg/campaign/prompts/orchestrator_instructions.md +++ b/pkg/campaign/prompts/orchestrator_instructions.md @@ -1,6 +1,6 @@ ## Campaign Orchestrator Rules -This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. It also maintains the campaign dashboard by ensuring the GitHub Project stays in sync with the campaign's tracker label. +This orchestrator follows system-agnostic rules that enforce clean separation between workers and campaign coordination. The GitHub Project is the single source of truth for campaign membership and state. ### Traffic and rate limits (required) @@ -58,7 +58,7 @@ Guidance: - Use an ISO date (UTC) filename, for example: `metrics/2025-12-22.json`. - Keep snapshots append-only: write a new file per run; do not rewrite historical snapshots. - If a KPI is present, record its computed value and trend (Improving/Flat/Regressing) in the kpi_trends array. -- Count tasks from all sources: tracker-labeled issues, worker-created issues, and project board items. +- Count tasks from all sources: project board items (canonical), plus any newly discovered tracker-labeled or worker-created items that will be added. - Set tasks_total to the total number of unique tasks discovered in this run. - Set tasks_completed to the count of tasks with state "Done" or closed status. {{ end }} @@ -83,7 +83,7 @@ Guidance: 10. **Predefined fields only** - Only update explicitly defined project board fields 11. **Explicit outcomes** - Record actual outcomes, never infer status 12. **Idempotent operations** - Re-execution produces the same result without corruption -13. **Dashboard synchronization** - Keep Project items in sync with tracker-labeled issues/PRs +13. **Dashboard synchronization** - Keep the Project board in sync with discovered work (Project items are canonical; tracker labels are optional ingestion) ### Objective and KPIs (first-class) @@ -118,13 +118,18 @@ Execute these steps in sequence each time this orchestrator runs: #### Phase 1: Read State (Discovery) -1. **Query tracker-labeled items** - Search for issues and PRs matching the campaign's tracker label +1. **Query current project state (canonical)** - Read the GitHub Project board + - Retrieve all items currently on the project board + - For each item, record: content type, URL (if present), draft title (if present), status field value, and other predefined field values + - Create a snapshot of current board state + +2. **Query tracker-labeled items (optional ingestion)** - If a tracker label is configured, search for issues and PRs matching it - Search: `repo:OWNER/REPO label:TRACKER_LABEL` for all open and closed items - If governance opt-out labels are configured, exclude items with those labels - Collect all matching issue/PR URLs - Record metadata: number, title, state (open/closed), created date, updated date -2. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids +3. **Query worker-created content** (if workers are configured) - Search for issues, PRs, and discussions containing worker tracker-ids {{ if .Workflows }} - Worker workflows: {{ range $i, $w := .Workflows }}{{ if $i }}, {{ end }}{{ $w }}{{ end }} - **IMPORTANT**: You MUST perform SEPARATE searches for EACH worker workflow listed above - **IMPORTANT**: Workers may create different types of content (issues, PRs, discussions, comments). Search ALL content types to discover all worker outputs. @@ -139,18 +144,29 @@ Execute these steps in sequence each time this orchestrator runs: - Combine results from all worker searches into a single list of discovered items - Note: Comments are discovered via their parent issue/PR - the issue/PR is what gets added to the board -3. **Query current project state** - Read the GitHub Project board - - Retrieve all items currently on the project board - - For each item, record: issue URL, status field value, other predefined field values - - Create a snapshot of current board state - -4. **Compare and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) - - Items from step 1 or 2 not on board = **new work discovered** (report count) - - Items on board with state mismatch = **status updates needed** (report count) +4. **Merge and identify gaps** - Analyze current state (for reporting only - do NOT use this to filter items in Phase 3) + - Items on the board are **in scope** by definition (canonical membership) + - Items from steps 2-3 not on board = **new work discovered** (report count) + - Items on board with state mismatch vs issue/PR state = **status updates needed** (report count) - Items on board with missing custom fields (e.g., worker_workflow) = **fields to populate** (report count) - - Items on board but no longer found = **check if archived/deleted** (report count) + - Items on board but no longer accessible = **check if archived/deleted** (report count) - **CRITICAL**: This comparison is for reporting and planning only. In Phase 3, you MUST send ALL discovered items to update-project regardless of whether they appear to be on the board. The update-project tool handles duplicate detection automatically. +4.8 **Locate the campaign hub issue (optional but recommended)** + +If you have permission and comment writes are allowed, attempt to locate the campaign hub (“epic”) issue for posting per-run summaries. + +Deterministic matching rules: +- Prefer an issue that has BOTH labels: `campaign-tracker` AND `campaign:{{ .CampaignID }}`. +- If none found, do NOT guess. Proceed without an epic issue comment. +- If multiple matches, treat as ambiguous and proceed without commenting (report ambiguity). + +If a single hub issue is found, treat it as an in-scope item for synchronization: +- Include it in the Phase 3 `update-project` operations so it is present on the Project board (idempotent). +- When updating the hub issue item, set (when the fields exist): + - `worker_workflow = "orchestrator"` + - `human_oversight_required = "Yes"` + #### Phase 2: Make Decisions (Planning) 4.5 **Deterministic planner step (required when objective/KPIs are present)** @@ -173,12 +189,13 @@ Plan format (keep under 2KB): } ``` -5. **Decide processing order (with pacing)** - For items discovered in steps 1-2: - - **CRITICAL**: ALL discovered items (both tracker-labeled from step 1 AND worker-created from step 2) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. +5. **Decide processing order (with pacing)** - For items discovered in steps 1-3: + - **CRITICAL**: ALL discovered items (project items from step 1, tracker-labeled from step 2, and worker-created from step 3) MUST be sent to update-project in Phase 3, regardless of whether they appear to already be on the board. The update-project tool handles idempotency automatically. - If `governance.max-new-items-per-run` is set, process at most that many items in this single run (remaining items will be processed in subsequent runs) - When applying the governance limit, prioritize in this order: - 1. Tracker-labeled items (campaign tasks) - process oldest first - 2. Worker-created items (worker outputs) - process oldest first + 1. Project board items (canonical scope) - process oldest first + 2. Tracker-labeled items (optional ingestion) - process oldest first + 3. Worker-created items (worker outputs) - process oldest first - Determine appropriate status field value based on item state: - Open issue/PR/discussion → "Todo" status - Closed issue/discussion → "Done" status @@ -207,12 +224,12 @@ Plan format (keep under 2KB): #### Phase 3: Write State (Execution) -**CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-2, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. +**CRITICAL RULE**: In this phase, you MUST send update-project requests for ALL discovered items from steps 1-3, regardless of whether they appear to already be on the board. The update-project tool handles duplicate detection and idempotency automatically. Do NOT pre-filter items based on board state. 8. **Execute project updates** - Send update-project for ALL discovered items - - Process ALL items from steps 1-2 (both tracker-labeled and worker-created), up to the governance limit if set + - Process ALL items from steps 1-3 (project items, tracker-labeled, and worker-created), up to the governance limit if set - Use `update-project` safe-output for EVERY discovered item - - Include fields from steps 5-6.5: `status`, `worker_workflow`, `priority`, `size`, etc. + - Include fields from steps 5-6.5: `status`, `worker_workflow`, `human_oversight_required`, `priority`, `size`, etc. - **The update-project tool will automatically**: - Skip adding items that are already on the board (idempotent add) - Update fields for items already on the board @@ -230,21 +247,36 @@ Plan format (keep under 2KB): #### Phase 4: Report (Output) 10. **Generate status report** - Summarize execution results: - - Total items discovered via tracker label (by type: issues, PRs) - - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) - - Items processed with update-project this run (count and URLs, broken down by: tracker-labeled vs worker-created) + - Total items currently on the project board (canonical) + - Total items discovered via tracker label (optional ingestion, by type: issues, PRs) + - Total items discovered via worker tracker-ids (by type: issues, PRs, discussions) + - Items processed with update-project this run (count and URLs, broken down by: project-board vs tracker-labeled vs worker-created) - Items skipped due to governance limits (count, type, and why - noting they will be processed in next run) - Current campaign metrics: open vs closed, progress percentage - Any failures encountered during update-project operations - Campaign completion status +11. **Post a hub issue comment (if hub issue was found and add-comment is allowed)** + +Post a short comment to the campaign hub issue that includes: +- Link to the Project board +- What was processed this run (counts + a few representative URLs) +- The current “Needs human” queue size (items with `human_oversight_required = Yes` if that field is used) +- If repo-memory metrics are enabled, include the path to the latest metrics snapshot written this run + +Do not paste large JSON blobs. Keep the comment concise and human-scannable. + ### Predefined Project Fields Only these fields may be updated on the project board: -- `status` (required) - Values: "Todo", "In Progress", "Done" +- `status` (required) - Values: "Todo", "In Progress", "Blocked", "Done" +- `worker_workflow` (optional) - String (recommended: the worker workflow ID/name) +- `human_oversight_required` (optional) - Values: "Yes", "No" (powers a dedicated human review queue) - `priority` (optional) - Values: "High", "Medium", "Low" - `size` (optional) - Values: "Small", "Medium", "Large" +- `start_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) +- `end_date` (optional) - ISO date YYYY-MM-DD (if the project has a matching field) - `campaign_status` (metadata) - Values: "active", "completed" Do NOT update any other fields or create custom fields. diff --git a/pkg/campaign/prompts/project_update_instructions.md b/pkg/campaign/prompts/project_update_instructions.md index fa120ffa39b..aa68b4acfa0 100644 --- a/pkg/campaign/prompts/project_update_instructions.md +++ b/pkg/campaign/prompts/project_update_instructions.md @@ -4,9 +4,7 @@ Execute state writes using the `update-project` safe-output. All writes must target this exact project URL: **Project URL**: {{.ProjectURL}} -{{if .TrackerLabel}} -**Campaign ID**: Extract from tracker label `{{.TrackerLabel}}` (format: `campaign:CAMPAIGN_ID`) -{{end}} +**Campaign ID**: `{{.CampaignID}}` (recommended to tag items via the optional `campaign_id` field) #### Adding New Issues/PRs @@ -16,8 +14,8 @@ update-project: project: "{{.ProjectURL}}" content_type: "issue" # or "pull_request" content_number: 123 # Extract number from URL like https://github.com/owner/repo/issues/123 -{{if .TrackerLabel}} campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label {{.TrackerLabel}} -{{end}} fields: + campaign_id: "{{.CampaignID}}" # Optional: tags items for this campaign + fields: status: "Todo" # or "Done" if issue/PR is already closed/merged ``` @@ -40,8 +38,9 @@ update-project: content_number: 123 # Extract from URL fields: status: "Todo" # or "In Progress", "Blocked", "Done" -{{if .TrackerLabel}} campaign_id: "CAMPAIGN_ID" # Extract from tracker label {{.TrackerLabel}} -{{end}} worker_workflow: "WORKFLOW_ID" # Enables swimlane grouping and filtering + campaign_id: "{{.CampaignID}}" # Optional: tags items for this campaign + worker_workflow: "WORKFLOW_ID" # Enables swimlane grouping and filtering + human_oversight_required: "No" # or "Yes" - powers a dedicated human review queue priority: "High" # or "Medium", "Low" - enables priority-based views effort: "Medium" # or "Small", "Large" - enables capacity planning team: "TEAM_NAME" # Optional: for team-based grouping @@ -50,6 +49,7 @@ update-project: **Custom Field Benefits**: - `worker_workflow`: Groups items by workflow in Roadmap swimlanes; enables "Slice by" filtering in Table views (orchestrator populates this by discovering which worker created the item via tracker-id) +- `human_oversight_required`: Creates an explicit human review queue; use a "Needs human" view filtered to "Yes" - `priority`: Enables priority-based filtering and sorting - `effort`: Supports capacity planning and workload distribution - `team`: Enables team-based grouping for multi-team campaigns @@ -67,8 +67,8 @@ update-project: project: "{{.ProjectURL}}" content_type: "issue" # or "pull_request" content_number: 123 # Extract from URL -{{if .TrackerLabel}} campaign_id: "CAMPAIGN_ID" # Required: extract from tracker label {{.TrackerLabel}} -{{end}} fields: + campaign_id: "{{.CampaignID}}" # Optional: tags items for this campaign + fields: status: "Done" # or "In Progress", "Todo" ``` diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json index 1064a083aa3..812f547adf0 100644 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -150,7 +150,7 @@ }, "tracker-label": { "type": "string", - "description": "Label used to associate issues/PRs with this campaign (e.g., campaign:incident-response)", + "description": "Optional ingestion label used to discover issues/PRs for this campaign (e.g., campaign:incident-response). Project membership is the canonical campaign scope.", "pattern": "^[^:]+:.+$", "minLength": 1 }, diff --git a/pkg/campaign/template_test.go b/pkg/campaign/template_test.go index c2e4928dbaf..ea5ba83db87 100644 --- a/pkg/campaign/template_test.go +++ b/pkg/campaign/template_test.go @@ -39,7 +39,7 @@ func TestRenderOrchestratorInstructions(t *testing.T) { shouldContain: []string{ "Query worker-created content", "Query current project state", - "Compare and identify gaps", + "Merge and identify gaps", "Decide processing order", "Decide updates", "Decide field values", @@ -94,19 +94,18 @@ func TestRenderProjectUpdateInstructions(t *testing.T) { shouldBeEmpty: false, }, { - name: "with project URL and tracker label", + name: "with project URL and campaign ID", data: CampaignPromptData{ - ProjectURL: "https://github.com/orgs/test/projects/1", - TrackerLabel: "campaign:my-campaign", + ProjectURL: "https://github.com/orgs/test/projects/1", + CampaignID: "my-campaign", }, shouldContain: []string{ "Project Board Integration", "update-project", "https://github.com/orgs/test/projects/1", "Campaign ID", - "campaign:my-campaign", - "campaign_id:", - "CAMPAIGN_ID", + "my-campaign", + "campaign_id: \"my-campaign\"", }, shouldBeEmpty: false, }, diff --git a/pkg/campaign/validation.go b/pkg/campaign/validation.go index 9a14a2093b2..31c4feb8881 100644 --- a/pkg/campaign/validation.go +++ b/pkg/campaign/validation.go @@ -77,9 +77,7 @@ func ValidateSpec(spec *CampaignSpec) []string { } } - if strings.TrimSpace(spec.TrackerLabel) == "" { - problems = append(problems, "tracker-label should be set to link issues and PRs to this campaign") - } else if !strings.Contains(spec.TrackerLabel, ":") { + if strings.TrimSpace(spec.TrackerLabel) != "" && !strings.Contains(spec.TrackerLabel, ":") { problems = append(problems, "tracker-label should follow a namespaced pattern (for example: campaign:security-q1-2025)") } diff --git a/pkg/campaign/validation_test.go b/pkg/campaign/validation_test.go index dbd5b542db2..c2ebcbdc942 100644 --- a/pkg/campaign/validation_test.go +++ b/pkg/campaign/validation_test.go @@ -123,7 +123,7 @@ func TestValidateSpec_MissingWorkflows(t *testing.T) { } } -func TestValidateSpec_MissingTrackerLabel(t *testing.T) { +func TestValidateSpec_MissingTrackerLabelIsAllowed(t *testing.T) { spec := &CampaignSpec{ ID: "test-campaign", Name: "Test Campaign", @@ -132,20 +132,12 @@ func TestValidateSpec_MissingTrackerLabel(t *testing.T) { } problems := ValidateSpec(spec) - if len(problems) == 0 { - t.Fatal("Expected validation problems for missing tracker label") - } - - found := false for _, p := range problems { - if strings.Contains(p, "tracker-label should be set") { - found = true + if strings.Contains(p, "tracker-label") { + t.Errorf("Did not expect tracker-label problem when omitted, got: %v", problems) break } } - if !found { - t.Errorf("Expected tracker label validation problem, got: %v", problems) - } } func TestValidateSpec_InvalidTrackerLabelFormat(t *testing.T) { diff --git a/pkg/cli/completions.go b/pkg/cli/completions.go index d6bf390f0e7..140223475a9 100644 --- a/pkg/cli/completions.go +++ b/pkg/cli/completions.go @@ -13,6 +13,8 @@ import ( var completionsLog = logger.New("cli:completions") +const workflowsDirAnnotationKey = "gh-aw-workflows-dir" + // getWorkflowDescription extracts the description field from a workflow's frontmatter // Returns empty string if the description is not found or if there's an error reading the file func getWorkflowDescription(filePath string) string { @@ -77,7 +79,14 @@ func ValidEngineNames() []string { func CompleteWorkflowNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { completionsLog.Printf("Completing workflow names with prefix: %s", toComplete) - mdFiles, err := getMarkdownWorkflowFiles() + workflowsDir := getWorkflowsDir() + if cmd != nil && cmd.Annotations != nil { + if overrideDir, ok := cmd.Annotations[workflowsDirAnnotationKey]; ok && overrideDir != "" { + workflowsDir = overrideDir + } + } + + mdFiles, err := getMarkdownWorkflowFilesInDir(workflowsDir) if err != nil { completionsLog.Printf("Failed to get workflow files: %v", err) return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/pkg/cli/completions_test.go b/pkg/cli/completions_test.go index ddb4e0b4ee3..73ef75d51a6 100644 --- a/pkg/cli/completions_test.go +++ b/pkg/cli/completions_test.go @@ -142,15 +142,7 @@ No description workflow f.Close() } - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} tests := []struct { name string @@ -290,15 +282,7 @@ func TestCompleteWorkflowNames(t *testing.T) { f.Close() } - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} tests := []struct { name string @@ -340,15 +324,7 @@ func TestCompleteWorkflowNamesNoWorkflowsDir(t *testing.T) { // Create a temporary directory without .github/workflows tmpDir := t.TempDir() - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: filepath.Join(tmpDir, ".github", "workflows")}} completions, directive := CompleteWorkflowNames(cmd, nil, "") assert.Empty(t, completions) @@ -401,15 +377,7 @@ func TestCompleteWorkflowNamesWithSpecialCharacters(t *testing.T) { f.Close() } - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} tests := []struct { name string @@ -506,15 +474,7 @@ func TestCompleteWorkflowNamesWithInvalidFiles(t *testing.T) { err = os.WriteFile(invalidMd, []byte("not valid yaml frontmatter"), 0644) require.NoError(t, err) - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} completions, directive := CompleteWorkflowNames(cmd, nil, "") @@ -538,73 +498,58 @@ func TestCompleteWorkflowNamesWithInvalidFiles(t *testing.T) { // TestCompleteWorkflowNamesCaseSensitivity tests prefix matching is case-sensitive func TestCompleteWorkflowNamesCaseSensitivity(t *testing.T) { - // Create a temporary directory structure - tmpDir := t.TempDir() - workflowsDir := filepath.Join(tmpDir, ".github", "workflows") - require.NoError(t, os.MkdirAll(workflowsDir, 0755)) - - // Create test workflow files with different cases - testWorkflows := []string{ - "test-workflow.md", - "Test-Workflow.md", - "TEST-WORKFLOW.md", - "other-workflow.md", - } - for _, wf := range testWorkflows { - f, err := os.Create(filepath.Join(workflowsDir, wf)) - require.NoError(t, err) - f.Close() - } - - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} - - tests := []struct { - name string - toComplete string - expectContains []string - expectMissing []string + // Note: macOS filesystems are commonly case-insensitive. To keep this test reliable across + // environments, run each case-variant in its own temp directory. + variants := []struct { + name string + fileName string + matches []string + misses []string }{ { - name: "lowercase test prefix", - toComplete: "test", - expectContains: []string{"test-workflow"}, - expectMissing: []string{"Test-Workflow", "TEST-WORKFLOW"}, + name: "lowercase workflow name", + fileName: "test-workflow.md", + matches: []string{"test"}, + misses: []string{"Test", "TEST"}, }, { - name: "capitalized Test prefix", - toComplete: "Test", - expectContains: []string{"Test-Workflow"}, - expectMissing: []string{"test-workflow", "TEST-WORKFLOW"}, + name: "capitalized workflow name", + fileName: "Test-Workflow.md", + matches: []string{"Test"}, + misses: []string{"test", "TEST"}, }, { - name: "uppercase TEST prefix", - toComplete: "TEST", - expectContains: []string{"TEST-WORKFLOW"}, - expectMissing: []string{"test-workflow", "Test-Workflow"}, + name: "uppercase workflow name", + fileName: "TEST-WORKFLOW.md", + matches: []string{"TEST"}, + misses: []string{"test", "Test"}, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - completions, directive := CompleteWorkflowNames(cmd, nil, tt.toComplete) - assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) - - // Verify expected completions are present - for _, expected := range tt.expectContains { - assert.Contains(t, completions, expected, "Expected completion '%s' not found", expected) + for _, v := range variants { + t.Run(v.name, func(t *testing.T) { + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, v.fileName), []byte("# test\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "other-workflow.md"), []byte("# other\n"), 0644)) + + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} + + for _, prefix := range v.matches { + t.Run("matches_"+prefix, func(t *testing.T) { + completions, directive := CompleteWorkflowNames(cmd, nil, prefix) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.Contains(t, completions, strings.TrimSuffix(v.fileName, ".md"), "Expected workflow not found") + }) } - // Verify unwanted completions are not present - for _, missing := range tt.expectMissing { - assert.NotContains(t, completions, missing, "Unexpected completion '%s' found", missing) + for _, prefix := range v.misses { + t.Run("misses_"+prefix, func(t *testing.T) { + completions, directive := CompleteWorkflowNames(cmd, nil, prefix) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.NotContains(t, completions, strings.TrimSuffix(v.fileName, ".md"), "Unexpected workflow found") + }) } }) } @@ -629,15 +574,7 @@ func TestCompleteWorkflowNamesExactMatch(t *testing.T) { f.Close() } - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} // Test exact match - should still return the match and any others with same prefix completions, directive := CompleteWorkflowNames(cmd, nil, "test") @@ -668,15 +605,7 @@ func TestCompleteWorkflowNamesLongNames(t *testing.T) { require.NoError(t, err) f.Close() - // Change to the temp directory - originalDir, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - defer func() { - _ = os.Chdir(originalDir) - }() - - cmd := &cobra.Command{} + cmd := &cobra.Command{Annotations: map[string]string{workflowsDirAnnotationKey: workflowsDir}} // Test completion with empty prefix should include both completions, directive := CompleteWorkflowNames(cmd, nil, "") diff --git a/pkg/cli/mcp_server_error_codes_test.go b/pkg/cli/mcp_server_error_codes_test.go index 76bb35937e2..4b14e3df86b 100644 --- a/pkg/cli/mcp_server_error_codes_test.go +++ b/pkg/cli/mcp_server_error_codes_test.go @@ -194,7 +194,7 @@ func TestMCPServer_ErrorCodes_InternalError(t *testing.T) { params := &mcp.CallToolParams{ Name: "audit", Arguments: map[string]any{ - "run_id": int64(1), // Invalid run ID + "run_id_or_url": "1", // Invalid run ID (as string) }, } diff --git a/pkg/cli/mcp_server_json_integration_test.go b/pkg/cli/mcp_server_json_integration_test.go index 98797204947..003f83c3dab 100644 --- a/pkg/cli/mcp_server_json_integration_test.go +++ b/pkg/cli/mcp_server_json_integration_test.go @@ -453,7 +453,7 @@ func TestMCPServer_AllToolsReturnContent(t *testing.T) { name: "audit", toolName: "audit", args: map[string]any{ - "run_id": int64(1), + "run_id_or_url": "1", }, expectJSON: false, // May return error message mayFailInTest: true, // Expected to fail with invalid run ID diff --git a/pkg/cli/run_command_test.go b/pkg/cli/run_command_test.go index ceaaec4290d..d420b7bf509 100644 --- a/pkg/cli/run_command_test.go +++ b/pkg/cli/run_command_test.go @@ -135,10 +135,10 @@ func TestProgressFlagSignature(t *testing.T) { // This is a compile-time check more than a runtime check // RunWorkflowOnGitHub should NOT accept progress parameter anymore - _ = RunWorkflowOnGitHub("test", false, "", "", "", false, false, false, []string{}, false) + _ = RunWorkflowOnGitHub(context.Background(), "test", false, "", "", "", false, false, false, []string{}, false) // RunWorkflowsOnGitHub should NOT accept progress parameter anymore - _ = RunWorkflowsOnGitHub([]string{"test"}, 0, false, "", "", "", false, false, []string{}, false) + _ = RunWorkflowsOnGitHub(context.Background(), []string{"test"}, 0, false, "", "", "", false, false, []string{}, false) // getLatestWorkflowRunWithRetry should NOT accept progress parameter anymore _, _ = getLatestWorkflowRunWithRetry("test.lock.yml", "", false) @@ -148,22 +148,22 @@ func TestProgressFlagSignature(t *testing.T) { func TestRefFlagSignature(t *testing.T) { // Test that RunWorkflowOnGitHub accepts refOverride parameter // This is a compile-time check that ensures the refOverride parameter exists - _ = RunWorkflowOnGitHub("test", false, "", "", "main", false, false, false, []string{}, false) + _ = RunWorkflowOnGitHub(context.Background(), "test", false, "", "", "main", false, false, false, []string{}, false) // Test that RunWorkflowsOnGitHub accepts refOverride parameter - _ = RunWorkflowsOnGitHub([]string{"test"}, 0, false, "", "", "main", false, false, []string{}, false) + _ = RunWorkflowsOnGitHub(context.Background(), []string{"test"}, 0, false, "", "", "main", false, false, []string{}, false) } // TestRunWorkflowOnGitHubWithRef tests that the ref parameter is handled correctly func TestRunWorkflowOnGitHubWithRef(t *testing.T) { // Test with explicit ref override (should still fail for non-existent workflow, but syntax is valid) - err := RunWorkflowOnGitHub("nonexistent-workflow", false, "", "", "main", false, false, false, []string{}, false) + err := RunWorkflowOnGitHub(context.Background(), "nonexistent-workflow", false, "", "", "main", false, false, false, []string{}, false) if err == nil { t.Error("RunWorkflowOnGitHub should return error for non-existent workflow even with ref flag") } // Test with ref override and repo override - err = RunWorkflowOnGitHub("nonexistent-workflow", false, "", "owner/repo", "feature-branch", false, false, false, []string{}, false) + err = RunWorkflowOnGitHub(context.Background(), "nonexistent-workflow", false, "", "owner/repo", "feature-branch", false, false, false, []string{}, false) if err == nil { t.Error("RunWorkflowOnGitHub should return error for non-existent workflow with both ref and repo") } @@ -173,10 +173,10 @@ func TestRunWorkflowOnGitHubWithRef(t *testing.T) { func TestInputFlagSignature(t *testing.T) { // Test that RunWorkflowOnGitHub accepts inputs parameter // This is a compile-time check that ensures the inputs parameter exists - _ = RunWorkflowOnGitHub("test", false, "", "", "", false, false, false, []string{"key=value"}, false) + _ = RunWorkflowOnGitHub(context.Background(), "test", false, "", "", "", false, false, false, []string{"key=value"}, false) // Test that RunWorkflowsOnGitHub accepts inputs parameter - _ = RunWorkflowsOnGitHub([]string{"test"}, 0, false, "", "", "", false, false, []string{"key=value"}, false) + _ = RunWorkflowsOnGitHub(context.Background(), []string{"test"}, 0, false, "", "", "", false, false, []string{"key=value"}, false) } // TestInputValidation tests that input validation works correctly @@ -231,7 +231,7 @@ func TestInputValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Since we can't actually run workflows in tests, we'll just test the validation // by checking if the function would error before attempting to run - err := RunWorkflowOnGitHub("nonexistent-workflow", false, "", "owner/repo", "", false, false, false, tt.inputs, false) + err := RunWorkflowOnGitHub(context.Background(), "nonexistent-workflow", false, "", "owner/repo", "", false, false, false, tt.inputs, false) if tt.shouldError { if err == nil { diff --git a/pkg/cli/status_command.go b/pkg/cli/status_command.go index b42ec2440c4..67aac7d8241 100644 --- a/pkg/cli/status_command.go +++ b/pkg/cli/status_command.go @@ -369,8 +369,12 @@ func calculateTimeRemaining(stopTimeStr string) string { // StatusWorkflows shows status of workflows // getMarkdownWorkflowFiles finds all markdown files in .github/workflows directory func getMarkdownWorkflowFiles() ([]string, error) { - workflowsDir := getWorkflowsDir() + return getMarkdownWorkflowFilesInDir(getWorkflowsDir()) +} +// getMarkdownWorkflowFilesInDir finds all markdown workflow files in the provided directory. +// This is primarily used for shell completions and tests to avoid relying on process-wide cwd. +func getMarkdownWorkflowFilesInDir(workflowsDir string) ([]string, error) { if _, err := os.Stat(workflowsDir); os.IsNotExist(err) { return nil, fmt.Errorf("no .github/workflows directory found") } diff --git a/pkg/workflow/safe_output_validation_config.go b/pkg/workflow/safe_output_validation_config.go index 58727acfcbf..6971af4eac6 100644 --- a/pkg/workflow/safe_output_validation_config.go +++ b/pkg/workflow/safe_output_validation_config.go @@ -230,17 +230,20 @@ var ValidationConfig = map[string]TypeValidationConfig{ }, }, "update_project": { - DefaultMax: 10, + DefaultMax: 10, + CustomValidation: "updateProjectValidTarget", Fields: map[string]FieldValidation{ "project": {Required: true, Type: "string", Sanitize: true, MaxLength: 512, Pattern: "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", PatternError: "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)"}, // campaign_id is an optional field used by Campaign Workflows to tag project items. // When provided, the update-project safe output applies a "campaign:" label. // This is part of the campaign tracking convention but not required for general use. "campaign_id": {Type: "string", Sanitize: true, MaxLength: 128}, - "content_type": {Type: "string", Enum: []string{"issue", "pull_request"}}, + "content_type": {Type: "string", Enum: []string{"issue", "pull_request", "draft_issue"}}, "content_number": {OptionalPositiveInteger: true}, "issue": {OptionalPositiveInteger: true}, // Legacy "pull_request": {OptionalPositiveInteger: true}, // Legacy + "draft_title": {Type: "string", Sanitize: true, MaxLength: 256}, + "draft_body": {Type: "string", Sanitize: true, MaxLength: MaxBodyLength}, "fields": {Type: "object"}, }, }, diff --git a/pkg/workflow/safe_output_validation_config_test.go b/pkg/workflow/safe_output_validation_config_test.go index 4335116eb68..8553cc29924 100644 --- a/pkg/workflow/safe_output_validation_config_test.go +++ b/pkg/workflow/safe_output_validation_config_test.go @@ -30,6 +30,7 @@ func TestGetValidationConfigJSON(t *testing.T) { "assign_milestone", "assign_to_agent", "assign_to_user", + "update_project", "update_issue", "update_pull_request", "push_to_pull_request_branch", @@ -134,6 +135,23 @@ func TestGetValidationConfigForType(t *testing.T) { wantMax: 1, wantFields: []string{"title", "body", "labels", "parent", "temporary_id"}, }, + { + name: "update_project type", + typeName: "update_project", + wantFound: true, + wantMax: 10, + wantFields: []string{ + "project", + "campaign_id", + "content_type", + "content_number", + "issue", + "pull_request", + "draft_title", + "draft_body", + "fields", + }, + }, { name: "link_sub_issue type", typeName: "link_sub_issue", @@ -236,6 +254,7 @@ func TestValidationConfigConsistency(t *testing.T) { "requiresOneOf:title,body": true, "startLineLessOrEqualLine": true, "parentAndSubDifferent": true, + "updateProjectValidTarget": true, } for typeName, config := range ValidationConfig { diff --git a/pkg/workflow/safe_outputs_integration_test.go b/pkg/workflow/safe_outputs_integration_test.go index 0dd313a1882..414d96b6a7c 100644 --- a/pkg/workflow/safe_outputs_integration_test.go +++ b/pkg/workflow/safe_outputs_integration_test.go @@ -517,9 +517,8 @@ func TestConsolidatedSafeOutputsJobIntegration(t *testing.T) { "SHARED_VAR", }, expectedStepNames: []string{ - "create_issue", - "create_pull_request", - "add_comment", + "process_safe_outputs", // Handler manager consolidates create_issue and add_comment + "create_pull_request", // Create PR is handled separately // Note: "noop" is not included in consolidated job }, },