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