From 8310a7cc017a93e796187799f263267e251d3396 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:42:50 +0000 Subject: [PATCH 01/10] fix(automation): validate action ref existence and add major bump warnings Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/.github_changelog_generator.yml | 2 +- .../aw/archive/daily-backlog-burner.lock.yml | 37 +- .../aw/archive/daily-perf-improver.lock.yml | 37 +- .github/aw/archive/daily-qa.lock.yml | 37 +- .github/aw/archive/daily-repo-status.lock.yml | 37 +- .../archive/daily-workflow-updater.lock.yml | 37 +- .../aw/archive/discussion-task-miner.lock.yml | 37 +- .github/aw/archive/plan.lock.yml | 37 +- .github/aw/archive/pr-fix.lock.yml | 37 +- .github/scripts/repository_automation.py | 4 +- .../scripts/repository_automation_common.py | 113 ++++- .../scripts/repository_automation_tasks.py | 420 +++++++++++++++--- .github/workflows/agentics-maintenance.yml | 14 +- .github/workflows/changelog.yml | 6 +- .github/workflows/jules-daily-qa.yml | 6 +- cache.py | 4 +- conftest.py | 2 +- fix_env.py | 8 +- main.py | 69 ++- patch.yml | 7 - patch2.yml | 12 - test_main.py | 2 + tests/test_automation/__init__.py | 0 .../test_automation/test_workflow_updater.py | 151 +++++++ tests/test_fix_env.py | 4 +- tests/test_retry_jitter.py | 1 + tests/test_ux.py | 4 + 27 files changed, 832 insertions(+), 293 deletions(-) delete mode 100644 patch.yml delete mode 100644 patch2.yml create mode 100644 tests/test_automation/__init__.py create mode 100644 tests/test_automation/test_workflow_updater.py diff --git a/.github/.github_changelog_generator.yml b/.github/.github_changelog_generator.yml index bd476a33..cd5bc33f 100644 --- a/.github/.github_changelog_generator.yml +++ b/.github/.github_changelog_generator.yml @@ -67,4 +67,4 @@ verbose: true # Notes: # - Removed duplicate label entries with different casing by using lowercase only. # - This assumes the changelog generator supports case-insensitive label matching. -# - Simplifies maintenance and reduces potential label mismatches. \ No newline at end of file +# - Simplifies maintenance and reduces potential label mismatches. diff --git a/.github/aw/archive/daily-backlog-burner.lock.yml b/.github/aw/archive/daily-backlog-burner.lock.yml index 502be332..476f8cb1 100644 --- a/.github/aw/archive/daily-backlog-burner.lock.yml +++ b/.github/aw/archive/daily-backlog-burner.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -161,7 +161,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -199,9 +199,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -686,17 +686,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -714,9 +714,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -728,7 +728,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -739,10 +739,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -830,7 +830,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1301,4 +1301,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/daily-perf-improver.lock.yml b/.github/aw/archive/daily-perf-improver.lock.yml index 51a23c21..4f30a5fe 100644 --- a/.github/aw/archive/daily-perf-improver.lock.yml +++ b/.github/aw/archive/daily-perf-improver.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -162,7 +162,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -200,9 +200,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -717,17 +717,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -745,9 +745,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -759,7 +759,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -770,10 +770,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -861,7 +861,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1332,4 +1332,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/daily-qa.lock.yml b/.github/aw/archive/daily-qa.lock.yml index 5af8b1ba..fb2fad12 100644 --- a/.github/aw/archive/daily-qa.lock.yml +++ b/.github/aw/archive/daily-qa.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -161,7 +161,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -199,9 +199,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -686,17 +686,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -714,9 +714,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -728,7 +728,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -739,10 +739,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -830,7 +830,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1301,4 +1301,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/daily-repo-status.lock.yml b/.github/aw/archive/daily-repo-status.lock.yml index f4708ce8..eda50b29 100644 --- a/.github/aw/archive/daily-repo-status.lock.yml +++ b/.github/aw/archive/daily-repo-status.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -157,7 +157,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -192,9 +192,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -553,17 +553,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -581,9 +581,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -594,7 +594,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -605,10 +605,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -695,7 +695,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1108,4 +1108,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/daily-workflow-updater.lock.yml b/.github/aw/archive/daily-workflow-updater.lock.yml index 889c6d5e..2340c309 100644 --- a/.github/aw/archive/daily-workflow-updater.lock.yml +++ b/.github/aw/archive/daily-workflow-updater.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -157,7 +157,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -192,9 +192,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -566,17 +566,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -594,9 +594,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -608,7 +608,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -619,10 +619,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -738,7 +738,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1209,4 +1209,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/discussion-task-miner.lock.yml b/.github/aw/archive/discussion-task-miner.lock.yml index ad8a27cb..f2060ddb 100644 --- a/.github/aw/archive/discussion-task-miner.lock.yml +++ b/.github/aw/archive/discussion-task-miner.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -160,7 +160,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF cat << 'GH_AW_PROMPT_EOF' @@ -207,9 +207,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -653,17 +653,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -681,9 +681,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -695,7 +695,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -706,10 +706,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -817,7 +817,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1320,4 +1320,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/plan.lock.yml b/.github/aw/archive/plan.lock.yml index 15c87263..19457969 100644 --- a/.github/aw/archive/plan.lock.yml +++ b/.github/aw/archive/plan.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -197,7 +197,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "/opt/gh-aw/prompts/pr_context_prompt.md" @@ -243,9 +243,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -686,17 +686,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -714,9 +714,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -728,7 +728,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -739,10 +739,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -830,7 +830,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1284,4 +1284,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/aw/archive/pr-fix.lock.yml b/.github/aw/archive/pr-fix.lock.yml index a57fdc27..9032b99a 100644 --- a/.github/aw/archive/pr-fix.lock.yml +++ b/.github/aw/archive/pr-fix.lock.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -230,7 +230,7 @@ jobs: - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ {{/if}} - + GH_AW_PROMPT_EOF if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "/opt/gh-aw/prompts/pr_context_prompt.md" @@ -275,9 +275,9 @@ jobs: script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); - + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - + // Call the substitution function return await substitutePlaceholders({ file: process.env.GH_AW_PROMPT, @@ -756,17 +756,17 @@ jobs: # Mask immediately to prevent timing vulnerabilities API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${API_KEY}" - + PORT=3001 - + # Set outputs for next steps { echo "safe_outputs_api_key=${API_KEY}" echo "safe_outputs_port=${PORT}" } >> "$GITHUB_OUTPUT" - + echo "Safe Outputs MCP server will run on port ${PORT}" - + - name: Start Safe Outputs MCP HTTP Server id: safe-outputs-start env: @@ -784,9 +784,9 @@ jobs: export GH_AW_SAFE_OUTPUTS_TOOLS_PATH export GH_AW_SAFE_OUTPUTS_CONFIG_PATH export GH_AW_MCP_LOG_DIR - + bash /opt/gh-aw/actions/start_safe_outputs_server.sh - + - name: Start MCP Gateway id: start-mcp-gateway env: @@ -798,7 +798,7 @@ jobs: run: | set -eo pipefail mkdir -p /tmp/gh-aw/mcp-config - + # Export gateway environment variables for MCP config and gateway script export MCP_GATEWAY_PORT="80" export MCP_GATEWAY_DOMAIN="host.docker.internal" @@ -809,10 +809,10 @@ jobs: mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" export DEBUG="*" - + export GH_AW_ENGINE="copilot" export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - + mkdir -p /home/runner/.copilot cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh { @@ -900,7 +900,7 @@ jobs: # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them SESSION_STATE_DIR="$HOME/.copilot/session-state" LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - + if [ -d "$SESSION_STATE_DIR" ]; then echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" mkdir -p "$LOGS_DIR" @@ -1401,4 +1401,3 @@ jobs: name: safe-output-items path: /tmp/safe-output-items.jsonl if-no-files-found: warn - diff --git a/.github/scripts/repository_automation.py b/.github/scripts/repository_automation.py index 5b11b5f9..745c851a 100755 --- a/.github/scripts/repository_automation.py +++ b/.github/scripts/repository_automation.py @@ -24,7 +24,9 @@ def main() -> int: - parser = argparse.ArgumentParser(description="Consolidated repository automation runner") + parser = argparse.ArgumentParser( + description="Consolidated repository automation runner" + ) parser.add_argument("task") parser.add_argument("result_path", nargs="?") args = parser.parse_args() diff --git a/.github/scripts/repository_automation_common.py b/.github/scripts/repository_automation_common.py index 0cb43e30..32f07dcf 100644 --- a/.github/scripts/repository_automation_common.py +++ b/.github/scripts/repository_automation_common.py @@ -88,7 +88,9 @@ def run_checked(command: list[str]) -> subprocess.CompletedProcess[str]: return run_process(command, check=True) -def warn_on_default(tool: str, args: list[str], proc: subprocess.CompletedProcess[str]) -> None: +def warn_on_default( + tool: str, args: list[str], proc: subprocess.CompletedProcess[str] +) -> None: error_text = proc.stderr.strip() or proc.stdout.strip() print( f"Warning: `{tool} {' '.join(args)}` failed with exit code {proc.returncode}. {error_text}", @@ -130,7 +132,9 @@ def normalise_status(status: str) -> str: return status if status in ALLOWED_STATUSES else "warning" -def build_result(task: str, status: str, summary: str, extra: dict[str, Any] | None = None) -> dict[str, Any]: +def build_result( + task: str, status: str, summary: str, extra: dict[str, Any] | None = None +) -> dict[str, Any]: result = { "task": task, "status": normalise_status(status), @@ -142,11 +146,15 @@ def build_result(task: str, status: str, summary: str, extra: dict[str, Any] | N return result -def write_result(task: str, status: str, summary: str, body: str, extra: dict[str, Any] | None = None) -> dict[str, Any]: +def write_result( + task: str, status: str, summary: str, body: str, extra: dict[str, Any] | None = None +) -> dict[str, Any]: result = build_result(task, status, summary, extra) directory = task_dir(task) (directory / "report.md").write_text(body.rstrip() + "\n") - (directory / "result.json").write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") + (directory / "result.json").write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n" + ) print(body) summary_path = os.environ.get("GITHUB_STEP_SUMMARY") if summary_path: @@ -205,7 +213,15 @@ def safe_pr_body(title: str, updates: list[dict[str, str]], notes: list[str]) -> if notes: lines.extend(["", "### Guardrails"]) lines.extend(f"- {note}" for note in notes) - lines.extend(["", "### Safety notes", "- Draft PR only", "- No force-pushes", "- No automatic merges"]) + lines.extend( + [ + "", + "### Safety notes", + "- Draft PR only", + "- No force-pushes", + "- No automatic merges", + ] + ) return "\n".join(lines) + "\n" @@ -245,7 +261,9 @@ def filter_existing_labels(labels: list[Any]) -> list[str]: specs = normalize_label_specs(labels) if not specs: return [] - label_rows = gh_json(["label", "list", "--limit", "100", "--json", "name"], default=[]) + label_rows = gh_json( + ["label", "list", "--limit", "100", "--json", "name"], default=[] + ) known = {row.get("name") for row in label_rows} for spec in specs: ensure_label_exists(spec, known) @@ -260,7 +278,19 @@ def gh_with_body(args: list[str], body: str) -> str: def create_or_update_issue(title: str, body: str, labels: list[Any]) -> str: - search = gh_json(["issue", "list", "--state", "all", "--limit", "100", "--json", "number,title,url"], default=[]) + search = gh_json( + [ + "issue", + "list", + "--state", + "all", + "--limit", + "100", + "--json", + "number,title,url", + ], + default=[], + ) existing = next((item for item in search if item.get("title") == title), None) existing_labels = filter_existing_labels(labels) if existing: @@ -275,26 +305,43 @@ def create_or_update_issue(title: str, body: str, labels: list[Any]) -> str: return gh_with_body(create_command, body) -def create_pr_for_current_changes(branch_prefix: str, commit_message: str, pr_title: str, pr_body: str) -> str: - existing = gh_json(["pr", "list", "--state", "open", "--json", "title,url"], default=[]) - existing_match = next((item for item in existing if item.get("title") == pr_title), None) +def create_pr_for_current_changes( + branch_prefix: str, commit_message: str, pr_title: str, pr_body: str +) -> str: + existing = gh_json( + ["pr", "list", "--state", "open", "--json", "title,url"], default=[] + ) + existing_match = next( + (item for item in existing if item.get("title") == pr_title), None + ) if existing_match: return existing_match["url"] branch_name = f"{branch_prefix.replace('/', '-')}-{now_utc().strftime('%Y%m%d')}-{os.environ.get('GITHUB_RUN_ATTEMPT', '1')}" run_checked([GIT_BIN, "config", "user.name", "repository-automation[bot]"]) - run_checked([GIT_BIN, "config", "user.email", "repository-automation[bot]@users.noreply.github.com"]) + run_checked( + [ + GIT_BIN, + "config", + "user.email", + "repository-automation[bot]@users.noreply.github.com", + ] + ) run_checked([GIT_BIN, "checkout", "-b", branch_name]) run_checked([GIT_BIN, "add", "-A"]) run_checked([GIT_BIN, "commit", "-m", commit_message]) run_checked([GIT_BIN, "push", "--set-upstream", "origin", branch_name]) - return gh_with_body(["pr", "create", "--draft", "--title", pr_title, "--body-file", "-"], pr_body) + return gh_with_body( + ["pr", "create", "--draft", "--title", pr_title, "--body-file", "-"], pr_body + ) def repository_slug() -> str: slug = os.environ.get("GITHUB_REPOSITORY", "") if slug: return slug - return gh_text(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], "") + return gh_text( + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], "" + ) def release_url(tag_name: str) -> str: @@ -305,12 +352,50 @@ def release_url(tag_name: str) -> str: def latest_tag_for_action(repo_id: str) -> str: - latest = gh_text(["api", f"repos/{repo_id}/releases/latest", "--jq", ".tag_name"]) + # Get the latest stable release (prerelease=false in GitHub API) + # The API actually returns a 404 for /releases/latest if there are no stable releases + # But occasionally it might return a pre-release if it was explicitly marked "latest" via API misuse. + # So we filter explicitly. + latest = gh_text( + [ + "api", + f"repos/{repo_id}/releases/latest", + "--jq", + "select(.prerelease == false) | .tag_name", + ] + ) if latest: return latest + + # Fallback 1: List all releases, get the first non-prerelease + releases = gh_json( + [ + "api", + f"repos/{repo_id}/releases", + "--jq", + "[.[] | select(.prerelease == false)] | .[0].tag_name", + ], + default=None, + ) + if releases and isinstance(releases, str): + return releases + + # Fallback 2: Get the most recent tag (which may not be associated with a release object) return gh_text(["api", f"repos/{repo_id}/tags?per_page=1", "--jq", ".[0].name"]) +def ref_exists(repo_id: str, ref: str) -> bool: + """Check if a specific ref (tag or branch) exists in the repository.""" + # First check tags + tag_result = run_process([GH_BIN, "api", f"repos/{repo_id}/git/refs/tags/{ref}"]) + if tag_result.returncode == 0: + return True + + # Then check branches (heads) + head_result = run_process([GH_BIN, "api", f"repos/{repo_id}/git/refs/heads/{ref}"]) + return head_result.returncode == 0 + + def numeric_version(text: str) -> tuple[int, int, int] | None: match = re.search(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", text) if not match: diff --git a/.github/scripts/repository_automation_tasks.py b/.github/scripts/repository_automation_tasks.py index e21a204a..adf1ceb0 100644 --- a/.github/scripts/repository_automation_tasks.py +++ b/.github/scripts/repository_automation_tasks.py @@ -19,6 +19,8 @@ latest_tag_for_action, matches_any, now_utc, + numeric_version, + ref_exists, release_url, run_shell_command, safe_pr_body, @@ -34,12 +36,18 @@ def configured_commands(section: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: return [ (bucket_name, item) - for bucket_name, key in (("setup", "setup_commands"), ("command", "commands"), ("security", "security_commands")) + for bucket_name, key in ( + ("setup", "setup_commands"), + ("command", "commands"), + ("security", "security_commands"), + ) for item in section.get(key, []) ] -def execute_configured_commands(section: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def execute_configured_commands( + section: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: setup_entries = [] command_entries = [] for bucket_name, item in configured_commands(section): @@ -56,7 +64,9 @@ def execute_configured_commands(section: dict[str, Any]) -> tuple[list[dict[str, return setup_entries, command_entries -def classify_entries(entries: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: +def classify_entries( + entries: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: failures = [] warnings = [] for entry in entries: @@ -78,7 +88,9 @@ def render_entry_section(title: str, entries: list[dict[str, Any]]) -> list[str] return lines -def render_review_section(title: str, entries: list[dict[str, Any]], template: str) -> list[str]: +def render_review_section( + title: str, entries: list[dict[str, Any]], template: str +) -> list[str]: if not entries: return [] lines = [title] @@ -87,7 +99,9 @@ def render_review_section(title: str, entries: list[dict[str, Any]], template: s return lines -def run_command_set(task_name: str, section: dict[str, Any]) -> tuple[str, str, dict[str, Any]]: +def run_command_set( + task_name: str, section: dict[str, Any] +) -> tuple[str, str, dict[str, Any]]: setup_entries, command_entries = execute_configured_commands(section) failures, warnings = classify_entries(setup_entries + command_entries) status = "failure" if failures else "warning" if warnings else "success" @@ -116,11 +130,15 @@ def run_command_set(task_name: str, section: dict[str, Any]) -> tuple[str, str, "- `{name}` failed but is configured as optional.", ) ) - return status, summary, { - "setup_results": setup_entries, - "command_results": command_entries, - "body": "\n".join(body_parts).strip() + "\n", - } + return ( + status, + summary, + { + "setup_results": setup_entries, + "command_results": command_entries, + "body": "\n".join(body_parts).strip() + "\n", + }, + ) def discover_hotspots(limit: int = 5) -> list[tuple[str, int]]: @@ -159,6 +177,21 @@ def workflow_file_plans() -> list[dict[str, Any]]: proposed = target_ref(current, latest) if not proposed or proposed == current: continue + + # Ensure the proposed target ref actually exists (could be a branch or tag) + if not ref_exists(repo_id, proposed): + print( + f"Warning: Proposed ref '{proposed}' does not exist for {repo_id}. Skipping update." + ) + continue + + # Detect major bumps for compatibility notes + is_major_bump = False + current_v = numeric_version(current) + proposed_v = numeric_version(proposed) + if current_v and proposed_v and proposed_v[0] > current_v[0]: + is_major_bump = True + replacements.append( { "old": match.group(0), @@ -167,20 +200,24 @@ def workflow_file_plans() -> list[dict[str, Any]]: "action": action_ref, "current": current, "target": proposed, + "is_major_bump": is_major_bump, } ) if replacements: - plans.append({"path": file_path, "text": text, "replacements": replacements}) + plans.append( + {"path": file_path, "text": text, "replacements": replacements} + ) return plans -def flattened_updates(plans: list[dict[str, Any]]) -> list[dict[str, str]]: +def flattened_updates(plans: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ { "file": item["file"], "action": item["action"], "current": item["current"], "target": item["target"], + "is_major_bump": item["is_major_bump"], } for plan in plans for item in plan["replacements"] @@ -200,7 +237,9 @@ def restore_workflow_updates(plans: list[dict[str, Any]]) -> None: plan["path"].write_text(plan["text"]) -def allowed_workflow_updates(updates: list[dict[str, str]], patterns: list[str]) -> bool: +def allowed_workflow_updates( + updates: list[dict[str, str]], patterns: list[str] +) -> bool: return all(matches_any(item["file"], patterns) for item in updates) @@ -211,7 +250,10 @@ def render_update_table(updates: list[dict[str, str]]) -> list[str]: "| --- | --- | --- | --- |", ] lines.extend( - [f"| `{item['file']}` | `{item['action']}` | `{item['current']}` | `{item['target']}` |" for item in updates] + [ + f"| `{item['file']}` | `{item['action']}` | `{item['current']}` | `{item['target']}` |" + for item in updates + ] ) lines.append("") return lines @@ -223,58 +265,131 @@ def run_workflow_updater(config: dict[str, Any]) -> dict[str, Any]: updates = flattened_updates(plans) if not updates: body = "# Workflow updater\n\n- Status: **success**\n- Summary: No GitHub Action updates were detected.\n" - return write_result("workflow-updater", "success", "No GitHub Action updates were detected.", body, {"updates": []}) + return write_result( + "workflow-updater", + "success", + "No GitHub Action updates were detected.", + body, + {"updates": []}, + ) status = "warning" summary = f"Detected {len(updates)} workflow action updates." - body_parts = ["# Workflow updater", "", f"- Status: **{status}**", f"- Summary: {summary}", ""] + body_parts = [ + "# Workflow updater", + "", + f"- Status: **{status}**", + f"- Summary: {summary}", + "", + ] body_parts.extend(render_update_table(updates)) - can_write = writes_allowed() and ensure_gh_token() and section.get("create_draft_pr", False) + can_write = ( + writes_allowed() and ensure_gh_token() and section.get("create_draft_pr", False) + ) if not can_write: - body_parts.extend(["## Write gate", "- Draft PR creation is disabled or writes are not allowed for this run.", ""]) - return write_result("workflow-updater", status, summary, "\n".join(body_parts), {"updates": updates, "pull_request_url": ""}) + body_parts.extend( + [ + "## Write gate", + "- Draft PR creation is disabled or writes are not allowed for this run.", + "", + ] + ) + return write_result( + "workflow-updater", + status, + summary, + "\n".join(body_parts), + {"updates": updates, "pull_request_url": ""}, + ) - allowed_paths = section.get("allowed_paths", [".github/workflows/*.yml", ".github/workflows/*.yaml"]) + allowed_paths = section.get( + "allowed_paths", [".github/workflows/*.yml", ".github/workflows/*.yaml"] + ) if not allowed_workflow_updates(updates, allowed_paths): - body_parts.extend(["## Human review required", "- Refusing to write because one or more files are outside the allow-list.", ""]) - return write_result("workflow-updater", "needs_review", summary, "\n".join(body_parts), {"updates": updates, "pull_request_url": ""}) + body_parts.extend( + [ + "## Human review required", + "- Refusing to write because one or more files are outside the allow-list.", + "", + ] + ) + return write_result( + "workflow-updater", + "needs_review", + summary, + "\n".join(body_parts), + {"updates": updates, "pull_request_url": ""}, + ) pr_url = "" try: apply_workflow_updates(plans) + + # Check for major bumps that need human review + major_bumps = {} + for item in updates: + if item["is_major_bump"]: + major_bumps[item["action"]] = (item["current"], item["target"]) + + notes = [ + "Security gate limited changes to allow-listed workflow paths.", + "No force-push or merge is performed by this automation.", + ] + pr_body = safe_pr_body( section.get("pr_title", "Workflow update"), updates, - [ - "Security gate limited changes to allow-listed workflow paths.", - "No force-push or merge is performed by this automation.", - ], + notes, ) + + if major_bumps: + pr_body += "\n### Compatibility review required\n" + for action, (current, target) in major_bumps.items(): + pr_body += f"- `{action}` major bump ({current} -> {target}): review inputs and syntax for breaking changes before merging\n" + pr_url = create_pr_for_current_changes( section.get("branch_prefix", "automation/workflow-updates"), - section.get("commit_message", "chore(actions): update workflow dependencies"), + section.get( + "commit_message", "chore(actions): update workflow dependencies" + ), section.get("pr_title", "chore(actions): update workflow dependencies"), pr_body, ) status = "success" - summary = f"Detected {len(updates)} workflow action updates and prepared a draft PR." + summary = ( + f"Detected {len(updates)} workflow action updates and prepared a draft PR." + ) body_parts.extend(["## Draft PR", f"- {pr_url}", ""]) except Exception as exc: # pragma: no cover - runtime integration restore_workflow_updates(plans) status = "failure" body_parts.extend(["## Draft PR failure", f"- {exc}", ""]) - return write_result("workflow-updater", status, summary, "\n".join(body_parts), {"updates": updates, "pull_request_url": pr_url}) + return write_result( + "workflow-updater", + status, + summary, + "\n".join(body_parts), + {"updates": updates, "pull_request_url": pr_url}, + ) def run_performance_optimizer(config: dict[str, Any]) -> dict[str, Any]: section = config.get("performance_optimizer", {}) - status, summary, details = run_command_set("performance-optimizer", { - "setup_commands": section.get("setup_commands", []), - "commands": section.get("commands", []), - }) + status, summary, details = run_command_set( + "performance-optimizer", + { + "setup_commands": section.get("setup_commands", []), + "commands": section.get("commands", []), + }, + ) hotspots = discover_hotspots() - lines = [details["body"].rstrip(), "## Static hotspots", "| File | Approximate lines |", "| --- | ---: |"] + lines = [ + details["body"].rstrip(), + "## Static hotspots", + "| File | Approximate lines |", + "| --- | ---: |", + ] for file_name, count in hotspots: lines.append(f"| `{file_name}` | {count} |") suggestions = section.get("suggestions", []) @@ -293,7 +408,13 @@ def run_performance_optimizer(config: dict[str, Any]) -> dict[str, Any]: def run_quality_assurance(config: dict[str, Any]) -> dict[str, Any]: section = config.get("quality_assurance", {}) status, summary, details = run_command_set("quality-assurance", section) - return write_result("quality-assurance", status, summary, details["body"], {"command_results": details["command_results"]}) + return write_result( + "quality-assurance", + status, + summary, + details["body"], + {"command_results": details["command_results"]}, + ) def parse_timestamp(value: str) -> dt.datetime: @@ -305,15 +426,26 @@ def age_days(timestamp: str) -> int: def render_issue_rows(issues: list[dict[str, Any]]) -> list[str]: - rows = ["## Open issues (oldest updated first)", "| Issue | Last updated | Age (days) | Labels |", "| --- | --- | ---: | --- |"] + rows = [ + "## Open issues (oldest updated first)", + "| Issue | Last updated | Age (days) | Labels |", + "| --- | --- | ---: | --- |", + ] for item in issues: labels = ", ".join(label["name"] for label in item.get("labels", [])) - rows.append(f"| [#{item['number']}]({item['url']}) | {item['updatedAt'][:10]} | {age_days(item['updatedAt'])} | {labels or '-'} |") + rows.append( + f"| [#{item['number']}]({item['url']}) | {item['updatedAt'][:10]} | {age_days(item['updatedAt'])} | {labels or '-'} |" + ) return rows def render_pr_rows(prs: list[dict[str, Any]]) -> list[str]: - rows = ["", "## Open pull requests (oldest updated first)", "| PR | Last updated | Age (days) | Draft | Review | Merge state |", "| --- | --- | ---: | --- | --- | --- |"] + rows = [ + "", + "## Open pull requests (oldest updated first)", + "| PR | Last updated | Age (days) | Draft | Review | Merge state |", + "| --- | --- | ---: | --- | --- | --- |", + ] rows.extend( [ f"| [#{item['number']}]({item['url']}) | {item['updatedAt'][:10]} | {age_days(item['updatedAt'])} | {item.get('isDraft')} | {item.get('reviewDecision') or '-'} | {item.get('mergeStateStatus') or '-'} |" @@ -327,12 +459,38 @@ def run_backlog_manager(config: dict[str, Any]) -> dict[str, Any]: section = config.get("backlog_manager", {}) max_issues = int(section.get("max_issues", 10)) max_prs = int(section.get("max_pull_requests", 10)) - issues = gh_json(["issue", "list", "--state", "open", "--limit", str(max_issues), "--json", "number,title,updatedAt,url,labels"], default=[]) - prs = gh_json(["pr", "list", "--state", "open", "--limit", str(max_prs), "--json", "number,title,updatedAt,url,isDraft,reviewDecision,mergeStateStatus"], default=[]) + issues = gh_json( + [ + "issue", + "list", + "--state", + "open", + "--limit", + str(max_issues), + "--json", + "number,title,updatedAt,url,labels", + ], + default=[], + ) + prs = gh_json( + [ + "pr", + "list", + "--state", + "open", + "--limit", + str(max_prs), + "--json", + "number,title,updatedAt,url,isDraft,reviewDecision,mergeStateStatus", + ], + default=[], + ) issues = sorted(issues, key=lambda item: item.get("updatedAt", "")) prs = sorted(prs, key=lambda item: item.get("updatedAt", "")) stale_days = int(section.get("stale_days", 14)) - stale_issues = [item for item in issues if age_days(item["updatedAt"]) >= stale_days] + stale_issues = [ + item for item in issues if age_days(item["updatedAt"]) >= stale_days + ] stale_prs = [item for item in prs if age_days(item["updatedAt"]) >= stale_days] status = "warning" if stale_issues or stale_prs else "success" summary = f"Backlog scan found {len(issues)} open issues and {len(prs)} open PRs in the sampled set." @@ -365,7 +523,12 @@ def run_backlog_manager(config: dict[str, Any]) -> dict[str, Any]: status, summary, "\n".join(lines) + "\n", - {"issues": issues, "pull_requests": prs, "stale_issues": stale_issues, "stale_pull_requests": stale_prs}, + { + "issues": issues, + "pull_requests": prs, + "stale_issues": stale_issues, + "stale_pull_requests": stale_prs, + }, ) @@ -400,10 +563,21 @@ def status_icon(status: str) -> str: }.get(status, status.upper()) -def daily_report_lines(config: dict[str, Any], results: list[dict[str, Any]]) -> list[str]: - open_issues = gh_json(["issue", "list", "--state", "open", "--limit", "200", "--json", "number"], default=[]) - open_prs = gh_json(["pr", "list", "--state", "open", "--limit", "200", "--json", "number"], default=[]) - releases = gh_json(["release", "list", "--limit", "5", "--json", "name,publishedAt,tagName"], default=[]) +def daily_report_lines( + config: dict[str, Any], results: list[dict[str, Any]] +) -> list[str]: + open_issues = gh_json( + ["issue", "list", "--state", "open", "--limit", "200", "--json", "number"], + default=[], + ) + open_prs = gh_json( + ["pr", "list", "--state", "open", "--limit", "200", "--json", "number"], + default=[], + ) + releases = gh_json( + ["release", "list", "--limit", "5", "--json", "name,publishedAt,tagName"], + default=[], + ) overall = overall_status(results) lines = [ f"# Daily Repository Automation Report - {iso_day()}", @@ -416,7 +590,12 @@ def daily_report_lines(config: dict[str, Any], results: list[dict[str, Any]]) -> "| Task | Status | Summary |", "| --- | --- | --- |", ] - lines.extend([f"| `{item['task']}` | {status_icon(item['status'])} | {item['summary']} |" for item in results]) + lines.extend( + [ + f"| `{item['task']}` | {status_icon(item['status'])} | {item['summary']} |" + for item in results + ] + ) lines.extend(["", "## Recent releases"]) if releases: for release in releases: @@ -424,16 +603,24 @@ def daily_report_lines(config: dict[str, Any], results: list[dict[str, Any]]) -> tag_name = release.get("tagName") or "" url = release_url(tag_name) rendered_name = f"[{name}]({url})" if url else name - lines.append(f"- {rendered_name} published {release.get('publishedAt', '')[:10]}") + lines.append( + f"- {rendered_name} published {release.get('publishedAt', '')[:10]}" + ) else: lines.append("- No recent releases returned by the API.") lines.extend(["", "## Recommendations"]) if overall == "success": - lines.append("- No blocking findings. Review the status report issue and any workflow-updater draft PR before merging.") + lines.append( + "- No blocking findings. Review the status report issue and any workflow-updater draft PR before merging." + ) else: - lines.append("- Review the failing or warning tasks before trusting any generated changes.") + lines.append( + "- Review the failing or warning tasks before trusting any generated changes." + ) if any(item.get("status") in {"failure", "needs_review"} for item in results): - lines.append("- Human review is required for at least one task; no silent automation escalation was performed.") + lines.append( + "- Human review is required for at least one task; no silent automation escalation was performed." + ) lines.extend(["", "", ""]) @@ -442,17 +629,29 @@ def daily_report_lines(config: dict[str, Any], results: list[dict[str, Any]]) -> def run_daily_status_report(config: dict[str, Any]) -> dict[str, Any]: results = load_task_results() - summary = f"Daily automation completed with overall status {overall_status(results)}." + summary = ( + f"Daily automation completed with overall status {overall_status(results)}." + ) section = config.get("status_report", {}) title = f"{config.get('reporting', {}).get('daily_issue_prefix', '[repo-automation] Daily Status Report')} - {iso_day()}" body = "\n".join(daily_report_lines(config, results)) - body, issue_url, error = append_publication_result(body, title=title, labels=section.get("labels", []), noun="daily issue") + body, issue_url, error = append_publication_result( + body, title=title, labels=section.get("labels", []), noun="daily issue" + ) status = "failure" if error else overall_status(results) - return write_result("daily-status-report", status, summary, body, {"issue_url": issue_url, "task_results": results}) + return write_result( + "daily-status-report", + status, + summary, + body, + {"issue_url": issue_url, "task_results": results}, + ) def extract_status_markers(issue_body: str) -> dict[str, str]: - match = re.search(r"", issue_body, re.S) + match = re.search( + r"", issue_body, re.S + ) if not match: return {} markers = {} @@ -463,15 +662,24 @@ def extract_status_markers(issue_body: str) -> dict[str, str]: return markers -def run_safe_adjustment_commands(section: dict[str, Any]) -> tuple[list[dict[str, Any]], str]: +def run_safe_adjustment_commands( + section: dict[str, Any], +) -> tuple[list[dict[str, Any]], str]: if not writes_allowed() or not section.get("auto_apply_safe_changes"): return [], "" command_results = [ - {"name": item["name"], **run_shell_command(item["run"], int(item.get("timeout_seconds", 1200)))} + { + "name": item["name"], + **run_shell_command(item["run"], int(item.get("timeout_seconds", 1200))), + } for item in section.get("safe_adjustment_commands", []) ] - changed = [line[3:] for line in git_output("status", "--porcelain").splitlines() if line] - allowed_paths = section.get("allowed_paths", [".github/workflows/*.yml", ".github/workflows/*.yaml"]) + changed = [ + line[3:] for line in git_output("status", "--porcelain").splitlines() if line + ] + allowed_paths = section.get( + "allowed_paths", [".github/workflows/*.yml", ".github/workflows/*.yaml"] + ) if not changed or not all(matches_any(path, allowed_paths) for path in changed): return command_results, "" body = safe_pr_body( @@ -484,7 +692,9 @@ def run_safe_adjustment_commands(section: dict[str, Any]) -> tuple[list[dict[str ) url = create_pr_for_current_changes( section.get("branch_prefix", "automation/weekly-workflow-tuning"), - section.get("commit_message", "chore(actions): apply safe weekly automation tuning"), + section.get( + "commit_message", "chore(actions): apply safe weekly automation tuning" + ), "chore(actions): weekly automation tuning", body, ) @@ -493,13 +703,37 @@ def run_safe_adjustment_commands(section: dict[str, Any]) -> tuple[list[dict[str def recent_daily_runs() -> list[dict[str, Any]]: cutoff = now_utc() - dt.timedelta(days=7) - runs = gh_json(["run", "list", "--workflow", DAILY_WORKFLOW_NAME, "--limit", "20", "--json", "number,createdAt,status,conclusion,url"], default=[]) + runs = gh_json( + [ + "run", + "list", + "--workflow", + DAILY_WORKFLOW_NAME, + "--limit", + "20", + "--json", + "number,createdAt,status,conclusion,url", + ], + default=[], + ) return [item for item in runs if parse_timestamp(item["createdAt"]) >= cutoff] def weekly_markers(prefix: str) -> dict[str, dict[str, int]]: cutoff = now_utc() - dt.timedelta(days=7) - issues = gh_json(["issue", "list", "--state", "all", "--limit", "100", "--json", "title,createdAt,body"], default=[]) + issues = gh_json( + [ + "issue", + "list", + "--state", + "all", + "--limit", + "100", + "--json", + "title,createdAt,body", + ], + default=[], + ) markers: dict[str, dict[str, int]] = {} for issue in issues: if not issue.get("title", "").startswith(prefix): @@ -512,7 +746,13 @@ def weekly_markers(prefix: str) -> dict[str, dict[str, int]]: return markers -def weekly_report_lines(config: dict[str, Any], runs: list[dict[str, Any]], markers: dict[str, dict[str, int]], safe_changes: list[dict[str, Any]], safe_pr_url: str) -> tuple[str, list[str]]: +def weekly_report_lines( + config: dict[str, Any], + runs: list[dict[str, Any]], + markers: dict[str, dict[str, int]], + safe_changes: list[dict[str, Any]], + safe_pr_url: str, +) -> tuple[str, list[str]]: status = "success" if any(item.get("conclusion") not in {"success", "skipped", None} for item in runs): status = "warning" @@ -537,18 +777,31 @@ def weekly_report_lines(config: dict[str, Any], runs: list[dict[str, Any]], mark lines.append("| Task | Status counts |") lines.append("| --- | --- |") for task_name, counts in sorted(markers.items()): - rendered = ", ".join(f"{name}: {count}" for name, count in sorted(counts.items())) + rendered = ", ".join( + f"{name}: {count}" for name, count in sorted(counts.items()) + ) lines.append(f"| `{task_name}` | {rendered} |") else: - lines.append("- No machine-readable task markers were found in the last week's daily status issues.") + lines.append( + "- No machine-readable task markers were found in the last week's daily status issues." + ) lines.extend(["", "## Recommendations"]) if status == "success": - lines.append("- The consolidated automation was stable this week. Keep manual review on for writes that touch protected areas.") + lines.append( + "- The consolidated automation was stable this week. Keep manual review on for writes that touch protected areas." + ) else: - lines.append("- Review repeated warning or failure patterns before increasing automation scope.") + lines.append( + "- Review repeated warning or failure patterns before increasing automation scope." + ) if safe_changes: lines.extend(["", "## Safe adjustment command results"]) - lines.extend([f"- `{entry['name']}` -> exit `{entry['exit_code']}`" for entry in safe_changes]) + lines.extend( + [ + f"- `{entry['name']}` -> exit `{entry['exit_code']}`" + for entry in safe_changes + ] + ) if safe_pr_url: lines.extend(["", "## Safe auto-apply draft PR", f"- {safe_pr_url}"]) return status, lines @@ -557,19 +810,40 @@ def weekly_report_lines(config: dict[str, Any], runs: list[dict[str, Any]], mark def run_weekly_retrospective(config: dict[str, Any]) -> dict[str, Any]: section = config.get("weekly_retrospective", {}) runs = recent_daily_runs() - markers = weekly_markers(config.get('reporting', {}).get('daily_issue_prefix', '[repo-automation] Daily Status Report')) + markers = weekly_markers( + config.get("reporting", {}).get( + "daily_issue_prefix", "[repo-automation] Daily Status Report" + ) + ) safe_changes = [] safe_pr_url = "" if ensure_gh_token(): try: safe_changes, safe_pr_url = run_safe_adjustment_commands(section) except Exception as exc: # pragma: no cover - runtime integration - safe_changes = [{"name": "safe-adjustment-commands", "exit_code": 1, "stdout": "", "stderr": str(exc)}] - status, lines = weekly_report_lines(config, runs, markers, safe_changes, safe_pr_url) + safe_changes = [ + { + "name": "safe-adjustment-commands", + "exit_code": 1, + "stdout": "", + "stderr": str(exc), + } + ] + status, lines = weekly_report_lines( + config, runs, markers, safe_changes, safe_pr_url + ) summary = f"Reviewed {len(runs)} daily workflow runs from the last 7 days." title = f"{config.get('reporting', {}).get('weekly_issue_prefix', '[repo-automation] Weekly Retrospective')} - {iso_day()}" body = "\n".join(lines) + "\n" - body, issue_url, error = append_publication_result(body, title=title, labels=section.get("labels", []), noun="weekly issue") + body, issue_url, error = append_publication_result( + body, title=title, labels=section.get("labels", []), noun="weekly issue" + ) if error: status = "failure" - return write_result("weekly-retrospective", status, summary, body, {"issue_url": issue_url, "runs": runs, "safe_pr_url": safe_pr_url}) + return write_result( + "weekly-retrospective", + status, + summary, + body, + {"issue_url": issue_url, "runs": runs, "safe_pr_url": safe_pr_url}, + ) diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml index 4c9b84c7..2a7a5b19 100644 --- a/.github/workflows/agentics-maintenance.yml +++ b/.github/workflows/agentics-maintenance.yml @@ -1,12 +1,12 @@ # -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ # | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ +# | | | | (_| | __/ | | | |_| | (__ # \_| |_/\__, |\___|_| |_|\__|_|\___| # __/ | -# _ _ |___/ +# _ _ |___/ # | | | | / _| | # | | | | ___ _ __ _ __| |_| | _____ ____ # | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| @@ -23,10 +23,10 @@ # # Alternative regeneration methods: # make recompile -# +# # Or use the gh-aw CLI directly: # ./gh-aw compile --validate --verbose -# +# # The workflow is generated when any workflow uses the 'expires' field # in create-discussions, create-issues, or create-pull-request safe-outputs configuration. # Schedule frequency is automatically determined by the shortest expiration time. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 9940ac67..1132a740 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v6 with: # Required for changelog generators to read previous tags and commit history - fetch-depth: 0 + fetch-depth: 0 - name: Install github_changelog_generator run: | @@ -43,11 +43,11 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" git add CHANGELOG.md - + if ! git diff --cached --quiet; then git commit -m "docs: update CHANGELOG [automated]" # actions/checkout automatically handles authentication, a simple 'git push' works safely git push else echo "No changes to commit." - fi \ No newline at end of file + fi diff --git a/.github/workflows/jules-daily-qa.yml b/.github/workflows/jules-daily-qa.yml index 7c212d15..b334234a 100644 --- a/.github/workflows/jules-daily-qa.yml +++ b/.github/workflows/jules-daily-qa.yml @@ -24,7 +24,7 @@ jobs: run: | cat << 'EOF' > prompt.md @google-labs-jules Please act as an agentic QA engineer for this repository. - + Your tasks: 1. Check that the code builds and runs. 2. Check that the tests pass. @@ -32,10 +32,10 @@ jobs: 4. Search for any previous "Jules Daily QA & Agentic Review" open discussions/issues. If the status is the same, just comment that you found nothing new. 5. If you find small problems you can fix with high confidence, create a PR. 6. Summarize your findings in this issue. Use note form. Highlight any bash commands you used. - + If the repository is perfectly healthy, simply state that and close this issue. EOF - + gh issue create \ --title "Daily QA Check - $(date +'%Y-%m-%d')" \ --body-file prompt.md \ diff --git a/cache.py b/cache.py index 4e875e93..ebb93098 100644 --- a/cache.py +++ b/cache.py @@ -215,7 +215,9 @@ def save_disk_cache() -> None: # Security: use tempfile.mkstemp to securely create a unique temporary file # with O_CREAT | O_EXCL and 0o600 permissions, preventing predictable # temporary file vulnerabilities and TOCTOU races. - fd, temp_file_path_str = tempfile.mkstemp(prefix="blocklists.", suffix=".tmp", dir=str(cache_dir)) + fd, temp_file_path_str = tempfile.mkstemp( + prefix="blocklists.", suffix=".tmp", dir=str(cache_dir) + ) temp_path = Path(temp_file_path_str) try: diff --git a/conftest.py b/conftest.py index c11a986c..a3fdff25 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,6 @@ - # Disable pytest cache provider due to container permission issues pytest_plugins = [] + def pytest_configure(config): config.option.cache = "no" diff --git a/fix_env.py b/fix_env.py index 4ad8b07c..24e6c02c 100644 --- a/fix_env.py +++ b/fix_env.py @@ -6,6 +6,7 @@ __all__ = ["fix_env", "clean_val", "escape_val"] + # Helper to clean quotes (curly or straight) def clean_val(val): if not val: @@ -14,6 +15,7 @@ def clean_val(val): val = val.strip() return re.sub(r"^[\"\u201c\u201d\']|[\"\u201c\u201d\']$", "", val) + # Helper to escape value for shell def escape_val(val): if not val: @@ -21,6 +23,7 @@ def escape_val(val): # Escape backslashes first, then double quotes return val.replace("\\", "\\\\").replace('"', '\\"') + def fix_env(): """Read `.env`, correct swapped TOKEN/PROFILE assignments, and rewrite securely. @@ -34,7 +37,9 @@ def fix_env(): # Security: Don't follow symlinks when fixing .env # This prevents attacks where .env is symlinked to a system file if os.path.islink(".env"): - print("Security Warning: .env is a symlink. Skipping to avoid damaging target file.") + print( + "Security Warning: .env is a symlink. Skipping to avoid damaging target file." + ) return try: @@ -117,5 +122,6 @@ def fix_env(): print("Fixed .env file: standardized quotes and corrected variable assignments.") print("Security: .env permissions set to 600 (read/write only by owner).") + if __name__ == "__main__": fix_env() diff --git a/main.py b/main.py index c262c798..0af451a0 100644 --- a/main.py +++ b/main.py @@ -185,6 +185,7 @@ class SyncResult(TypedDict): if _use_json_log: USE_COLORS = False + class Colors: if USE_COLORS: HEADER = "\033[95m" @@ -209,15 +210,37 @@ class Colors: UNDERLINE = "" DIM = "" + class Box: """Box drawing characters for pretty tables.""" + if USE_COLORS: H, V, TL, TR, BL, BR, T, B, L, R, X = ( - "─", "│", "┌", "┐", "└", "┘", "┬", "┴", "├", "┤", "┼", + "─", + "│", + "┌", + "┐", + "└", + "┘", + "┬", + "┴", + "├", + "┤", + "┼", ) else: H, V, TL, TR, BL, BR, T, B, L, R, X = ( - "-", "|", "+", "+", "+", "+", "+", "+", "+", "+", "+", + "-", + "|", + "+", + "+", + "+", + "+", + "+", + "+", + "+", + "+", + "+", ) @@ -447,10 +470,7 @@ def check_env_permissions(env_path: str = ".env") -> None: FOLDER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+$") _ALLOWED_RULE_CHARS = frozenset( - "abcdefghijklmnopqrstuvwxyz" - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "0123456789" - ".-_:*/@" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_:*/@" ) # Parallel processing configuration @@ -555,7 +575,9 @@ def print_plan_details(plan_entry: PlanEntry) -> None: if not folders: if USE_COLORS: print(f" {Colors.WARNING}No folders to sync.{Colors.ENDC}") - print(f" {Colors.DIM}💡 Hint: Add folder URLs using --folder-url or in your config.yaml{Colors.ENDC}") + print( + f" {Colors.DIM}💡 Hint: Add folder URLs using --folder-url or in your config.yaml{Colors.ENDC}" + ) else: print(" No folders to sync.") print(" Hint: Add folder URLs using --folder-url or in your config.yaml") @@ -1018,6 +1040,7 @@ def _api_client() -> httpx.Client: _CGNAT_NETWORK = ipaddress.IPv4Network("100.64.0.0/10") + def _is_safe_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Checks against CGNAT space, multicast, and relies on is_global for the rest.""" if ip.is_multicast: @@ -1264,7 +1287,10 @@ def validate_folder_data(data: dict[str, Any], url: str) -> TypeGuard[FolderData # Optimization: Fast path inline type check avoids function call overhead per rule. # Fallback identifies the exact error for logging. rules_list = data["rules"] - if not all(type(r) is dict and ((pk := r.get("PK")) is None or type(pk) is str) for r in rules_list): + if not all( + type(r) is dict and ((pk := r.get("PK")) is None or type(pk) is str) + for r in rules_list + ): for j, rule in enumerate(rules_list): if not isinstance(rule, dict): log.error( @@ -1302,14 +1328,19 @@ def validate_folder_data(data: dict[str, Any], url: str) -> TypeGuard[FolderData rg_rules_list = rg["rules"] # Optimization: Fast path inline type check avoids function call overhead per rule. # Fallback identifies the exact error for logging. - if not all(type(r) is dict and ((pk := r.get("PK")) is None or type(pk) is str) for r in rg_rules_list): + if not all( + type(r) is dict and ((pk := r.get("PK")) is None or type(pk) is str) + for r in rg_rules_list + ): for j, rule in enumerate(rg_rules_list): if not isinstance(rule, dict): log.error( f"Invalid data from {sanitize_for_log(url)}: rule_groups[{i}].rules[{j}] must be an object." ) return False - if (pk := rule.get("PK")) is not None and not isinstance(pk, str): + if (pk := rule.get("PK")) is not None and not isinstance( + pk, str + ): log.error( f"Invalid data from {sanitize_for_log(url)}: rule_groups[{i}].rules[{j}].PK must be a string." ) @@ -2342,8 +2373,10 @@ def _fetch_if_valid(url: str): if not folder_data_list: log.error("No valid folder data found") - hint_message = ("💡 Hint: Check your --folder-url flags or your config file " - "(see --config, config.yaml, or config.yml) for typos or unreachable URLs") + hint_message = ( + "💡 Hint: Check your --folder-url flags or your config file " + "(see --config, config.yaml, or config.yml) for typos or unreachable URLs" + ) if USE_COLORS: log.warning(f"{Colors.DIM}{hint_message}{Colors.ENDC}") else: @@ -2578,15 +2611,16 @@ def prompt_for_interactive_restart(profile_ids: list[str]) -> None: print(f"\n{Colors.WARNING}⚠️ Cancelled.{Colors.ENDC}") - def print_line(left_char: str, mid_char: str, right_char: str, w: list[int]) -> str: """Format a horizontal table separator line.""" return f"{Colors.BOLD}{left_char}{mid_char.join('─' * (x + 2) for x in w)}{right_char}{Colors.ENDC}" + def print_row(cols: list[str], w: list[int]) -> str: """Format a row of table data.""" return f"{Colors.BOLD}│{Colors.ENDC} {cols[0]:<{w[0]}} {Colors.BOLD}│{Colors.ENDC} {cols[1]:>{w[1]}} {Colors.BOLD}│{Colors.ENDC} {cols[2]:>{w[2]}} {Colors.BOLD}│{Colors.ENDC} {cols[3]:>{w[3]}} {Colors.BOLD}│{Colors.ENDC} {cols[4]:<{w[4]}} {Colors.BOLD}│{Colors.ENDC}" + def print_summary_table( sync_results: list[SyncResult], success_count: int, total: int, dry_run: bool ) -> None: @@ -2629,7 +2663,7 @@ def print_summary_table( print( f"{print_line('├', '┬', '┤', w)}\n{print_row([f'{Colors.HEADER}Profile ID{Colors.ENDC}', f'{Colors.HEADER}Folders{Colors.ENDC}', f'{Colors.HEADER}Rules{Colors.ENDC}', f'{Colors.HEADER}Duration{Colors.ENDC}', f'{Colors.HEADER}Status{Colors.ENDC}'], w)}" ) - print(print_line('├', '┼', '┤', w)) + print(print_line("├", "┼", "┤", w)) for r in sync_results: sc = Colors.GREEN if r["success"] else Colors.FAIL @@ -2641,7 +2675,8 @@ def print_summary_table( f"{r['rules']:,}", f"{r['duration']:.1f}s", f"{sc}{r['status_label']}{Colors.ENDC}", - ], w + ], + w, ) ) @@ -2772,7 +2807,9 @@ def main() -> None: exit(1) else: print(f"{Colors.CYAN}ℹ No cache file found, nothing to clear{Colors.ENDC}") - print(f"{Colors.DIM}💡 Hint: The cache file will be created or updated after a successful sync run without --dry-run{Colors.ENDC}") + print( + f"{Colors.DIM}💡 Hint: The cache file will be created or updated after a successful sync run without --dry-run{Colors.ENDC}" + ) _disk_cache.clear() exit(0) profiles_arg = ( diff --git a/patch.yml b/patch.yml deleted file mode 100644 index 8c7398e0..00000000 --- a/patch.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- a/requirements.txt -+++ b/requirements.txt -@@ -4,4 +4,3 @@ - httpx>=0.28.1 - python-dotenv>=1.1.1 - pyyaml>=6.0 --pytest-benchmark>=4.0.0 diff --git a/patch2.yml b/patch2.yml deleted file mode 100644 index d950367b..00000000 --- a/patch2.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- a/.github/workflows/performance.yml -+++ b/.github/workflows/performance.yml -@@ -10,7 +10,8 @@ - jobs: - benchmark: - runs-on: ubuntu-latest - permissions: -- contents: read -+ contents: write -+ pull-requests: write - steps: - - uses: actions/checkout@v4 diff --git a/test_main.py b/test_main.py index 702632d3..4bf3302e 100644 --- a/test_main.py +++ b/test_main.py @@ -790,6 +790,8 @@ def test_validate_folder_data_structure(monkeypatch): valid_rg = valid_base.copy() valid_rg["rule_groups"] = [{"rules": [{"PK": "rule1"}]}] assert m.validate_folder_data(valid_rg, "url") is True + + def test_is_valid_profile_id_format(monkeypatch): m = reload_main_with_env(monkeypatch) # Valid IDs diff --git a/tests/test_automation/__init__.py b/tests/test_automation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_automation/test_workflow_updater.py b/tests/test_automation/test_workflow_updater.py new file mode 100644 index 00000000..73536d6d --- /dev/null +++ b/tests/test_automation/test_workflow_updater.py @@ -0,0 +1,151 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + + +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".github" / "scripts")) + +from repository_automation_common import latest_tag_for_action, ref_exists, target_ref +from repository_automation_tasks import workflow_file_plans + + +@patch("repository_automation_common.gh_json") +@patch("repository_automation_common.gh_text") +def test_latest_tag_for_action_prerelease(mock_gh_text, mock_gh_json): + # Setup: releases/latest returns empty (404), releases list returns non-prerelease + mock_gh_text.side_effect = ["", "v1.2.3"] + mock_gh_json.return_value = "v2.0.0" + + assert latest_tag_for_action("actions/checkout") == "v2.0.0" + + +@patch("repository_automation_common.gh_json") +@patch("repository_automation_common.gh_text") +def test_latest_tag_for_action_empty(mock_gh_text, mock_gh_json): + # Setup: releases/latest returns empty (404) + mock_gh_text.side_effect = ["", ""] + mock_gh_json.return_value = None + + assert latest_tag_for_action("actions/checkout") == "" + + +def test_target_ref_valid_and_invalid(): + # Valid upgrades + assert target_ref("v4", "v5.0.0") == "v5" + assert target_ref("v4.2.2", "v4.3.0") == "v4.3.0" + # When current is 'v4' and latest is 'v4.3.0', target_ref returns 'v4' which triggers a skip later + assert target_ref("v4", "v4.3.0") == "v4" + + # Invalid tag inputs or no upgrades + assert target_ref("v4", "v3.0.0") is None + assert target_ref("v4", "v4") is None + assert target_ref("invalid", "v5.0.0") is None + assert target_ref("v4", "invalid") is None + + +@patch("repository_automation_common.run_process") +def test_ref_exists(mock_run_process): + # Setup for tag exists + mock_result = MagicMock() + mock_result.returncode = 0 + mock_run_process.return_value = mock_result + assert ref_exists("actions/checkout", "v5") is True + + # Setup for tag fails, branch exists + mock_tag_result = MagicMock() + mock_tag_result.returncode = 1 + mock_branch_result = MagicMock() + mock_branch_result.returncode = 0 + mock_run_process.side_effect = [mock_tag_result, mock_branch_result] + assert ref_exists("actions/checkout", "v5") is True + + # Setup for both fail + mock_tag_result = MagicMock() + mock_tag_result.returncode = 1 + mock_branch_result = MagicMock() + mock_branch_result.returncode = 1 + mock_run_process.side_effect = [mock_tag_result, mock_branch_result] + assert ref_exists("actions/checkout", "v5") is False + + +@patch("repository_automation_tasks.ROOT") +@patch("repository_automation_tasks.latest_tag_for_action") +@patch("repository_automation_tasks.ref_exists") +def test_workflow_file_plans_skips_non_existent( + mock_ref_exists, mock_latest_tag, mock_root +): + # Setup fake workflow file + mock_workflows_dir = MagicMock() + mock_file = MagicMock() + mock_file.read_text.return_value = "uses: actions/checkout@v4" + mock_file.relative_to.return_value = ".github/workflows/test.yml" + mock_workflows_dir.glob.return_value = [mock_file] + mock_root.__truediv__.return_value = mock_workflows_dir + + # The workflow_file_plans function uses an internal cache: latest_cache: dict[str, str] = {} + # Because of this cache, we cannot just change mock_ref_exists on a second call + # without running into potential issues if it caches the result or if the generator is exhausted. + # We should run it fresh each time. + + # Sorted iterator issue? The glob uses sorted(), so if it's not a real path it might fail inside sorted + # Let's mock the actual path representation + mock_file.__lt__ = lambda self, other: True + + # Let's just create a list of mock files directly + # Wait, the code is: sorted((ROOT / ".github" / "workflows").glob("*.y*ml")) + + # Resetting the mock iterables correctly so multiple calls work + mock_file1 = MagicMock() + mock_file1.read_text.return_value = "uses: actions/checkout@v4" + mock_file1.relative_to.return_value = ".github/workflows/test.yml" + mock_file1.__lt__ = lambda self, other: True + + mock_file2 = MagicMock() + mock_file2.read_text.return_value = "uses: actions/setup-node@v3" + mock_file2.relative_to.return_value = ".github/workflows/test2.yml" + mock_file2.__lt__ = lambda self, other: True + + # To fix issues with the pathlib mock, define a mock path class. + class MockPath: + def __init__(self, name, text): + self.name = name + self._text = text + + def read_text(self): + return self._text + + def relative_to(self, root): + return self.name + + def __lt__(self, other): + return self.name < other.name + + def __str__(self): + return self.name + + with patch("repository_automation_tasks.target_ref") as mock_target_ref: + # Test 1: ref does not exist + p1 = MockPath(".github/workflows/test.yml", "uses: actions/checkout@v4") + mock_root.__truediv__.return_value.__truediv__.return_value.glob.return_value = [ + p1 + ] + + mock_target_ref.return_value = "v5" + mock_ref_exists.return_value = False + plans_missing = workflow_file_plans() + assert len(plans_missing) == 0 # Should be skipped because ref doesn't exist + + # Test 2: ref exists + p2 = MockPath(".github/workflows/test2.yml", "uses: actions/setup-node@v3") + mock_root.__truediv__.return_value.__truediv__.return_value.glob.return_value = [ + p2 + ] + + mock_target_ref.return_value = "v4" + mock_ref_exists.return_value = True + plans_exist = workflow_file_plans() + + assert len(plans_exist) == 1 + assert plans_exist[0]["replacements"][0]["target"] == "v4" + assert plans_exist[0]["replacements"][0]["is_major_bump"] is True diff --git a/tests/test_fix_env.py b/tests/test_fix_env.py index 24b6c443..ce9dc7a7 100644 --- a/tests/test_fix_env.py +++ b/tests/test_fix_env.py @@ -117,6 +117,7 @@ def test_fix_env_handles_existing_temp_file(tmp_path, monkeypatch): finally: os.chdir(cwd) + def test_clean_val(): """ Verify clean_val correctly removes surrounding quotes and strips whitespace. @@ -131,6 +132,7 @@ def test_clean_val(): assert fix_env.clean_val("\u201cvalue\u201d") == "value" # curly quotes assert fix_env.clean_val('""value""') == '"value"' # only removes outermost + def test_escape_val(): """ Verify escape_val correctly escapes backslashes and double quotes. @@ -139,5 +141,5 @@ def test_escape_val(): assert fix_env.escape_val("") == "" assert fix_env.escape_val("value") == "value" assert fix_env.escape_val('val"ue') == 'val\\"ue' - assert fix_env.escape_val('val\\ue') == 'val\\\\ue' + assert fix_env.escape_val("val\\ue") == "val\\\\ue" assert fix_env.escape_val('val\\"ue') == 'val\\\\\\"ue' diff --git a/tests/test_retry_jitter.py b/tests/test_retry_jitter.py index bbc003a3..9dbc77dc 100644 --- a/tests/test_retry_jitter.py +++ b/tests/test_retry_jitter.py @@ -46,6 +46,7 @@ def test_retry_with_jitter_bounds_and_randomness(self): # Both jittered delays must still respect the same cap used above for attempt 2. for delay in (delay_1, delay_2): assert 0.0 <= delay < 4.0 + def test_jitter_adds_randomness_to_retry_delays(self): """Verify that retry delays include jitter and aren't identical.""" request_func = Mock(side_effect=httpx.TimeoutException("Connection timeout")) diff --git a/tests/test_ux.py b/tests/test_ux.py index 1e54a5c0..5d38d51c 100644 --- a/tests/test_ux.py +++ b/tests/test_ux.py @@ -246,6 +246,7 @@ def test_typical_layout(self): expected = "L" + "M".join(expected_parts) + "R" assert result == expected + def test_print_line(): """Verify print_line produces correct unicode table borders.""" w = [2, 3] @@ -255,6 +256,7 @@ def test_print_line(): inner = result.replace(main.Colors.BOLD, "").replace(main.Colors.ENDC, "") assert inner == "[────*─────]" + def test_print_row(): """Verify print_row produces correctly padded columns with bold separators.""" w = [2, 3, 4, 5, 6] @@ -264,6 +266,7 @@ def test_print_row(): clean_result = result.replace(main.Colors.BOLD, "").replace(main.Colors.ENDC, "") assert clean_result == expected_inner + def test_print_summary_table_unicode_print_line(monkeypatch, capsys): """ Test that print_summary_table correctly uses the print_line and print_row helpers @@ -297,6 +300,7 @@ def test_print_summary_table_unicode_print_line(monkeypatch, capsys): assert "1,500" in captured.out assert "2.5s" in captured.out + def test_print_summary_table_ascii_fallback(monkeypatch, capsys): """ Test that print_summary_table correctly falls back to ASCII output From 9db730c2cf21513c7a483fcfa63017349b8a28e2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:57:40 +0000 Subject: [PATCH 02/10] fix(ci): fix labeler config, greetings inputs, and refactor tasks for code health Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/labeler.yml | 22 ++- .../scripts/repository_automation_tasks.py | 166 ++++++++++-------- .github/workflows/greetings.yml | 6 +- 3 files changed, 109 insertions(+), 85 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 6f2ee22a..08260b9a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,18 +1,22 @@ # Label PRs based on changed file paths documentation: - - '**/*.md' + - any: + - '**/*.md' configuration: - - '**/*.yml' - - '**/*.yaml' - - '.github/**/*' + - any: + - '**/*.yml' + - '**/*.yaml' + - '.github/**/*' scripts: - - '**/*.sh' - - '**/*.bash' + - any: + - '**/*.sh' + - '**/*.bash' python: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' + - any: + - '**/*.py' + - 'pyproject.toml' + - 'uv.lock' diff --git a/.github/scripts/repository_automation_tasks.py b/.github/scripts/repository_automation_tasks.py index adf1ceb0..397cabd2 100644 --- a/.github/scripts/repository_automation_tasks.py +++ b/.github/scripts/repository_automation_tasks.py @@ -5,6 +5,8 @@ import re from typing import Any +from pathlib import Path + from repository_automation_common import ( DAILY_WORKFLOW_NAME, OUTPUT_ROOT, @@ -155,6 +157,50 @@ def discover_hotspots(limit: int = 5) -> list[tuple[str, int]]: return sorted(candidates, key=lambda item: item[1], reverse=True)[:limit] +def is_major_bump(current: str, proposed: str) -> bool: + current_v = numeric_version(current) + proposed_v = numeric_version(proposed) + return bool(current_v and proposed_v and proposed_v[0] > current_v[0]) + + +def process_workflow_match( + match: re.Match[str], file_path: Path, latest_cache: dict[str, str] +) -> dict[str, Any] | None: + action_ref = match.group(2) + current = match.group(3) + if action_ref.startswith("./") or action_ref.startswith("docker://"): + return None + parts = action_ref.split("/") + if len(parts) < 2: + return None + repo_id = "/".join(parts[:2]) + + if repo_id not in latest_cache: + latest_cache[repo_id] = latest_tag_for_action(repo_id) + latest = latest_cache[repo_id] + + proposed = target_ref(current, latest) + if not proposed or proposed == current: + return None + + # Ensure the proposed target ref actually exists (could be a branch or tag) + if not ref_exists(repo_id, proposed): + print( + f"Warning: Proposed ref '{proposed}' does not exist for {repo_id}. Skipping update." + ) + return None + + return { + "old": match.group(0), + "new": f"{match.group(1)}{action_ref}@{proposed}", + "file": str(file_path.relative_to(ROOT)), + "action": action_ref, + "current": current, + "target": proposed, + "is_major_bump": is_major_bump(current, proposed), + } + + def workflow_file_plans() -> list[dict[str, Any]]: latest_cache: dict[str, str] = {} plans = [] @@ -162,47 +208,9 @@ def workflow_file_plans() -> list[dict[str, Any]]: text = file_path.read_text() replacements = [] for match in WORKFLOW_PATTERN.finditer(text): - action_ref = match.group(2) - current = match.group(3) - if action_ref.startswith("./") or action_ref.startswith("docker://"): - continue - parts = action_ref.split("/") - if len(parts) < 2: - continue - repo_id = "/".join(parts[:2]) - latest = latest_cache.get(repo_id) - if latest is None: - latest = latest_tag_for_action(repo_id) - latest_cache[repo_id] = latest - proposed = target_ref(current, latest) - if not proposed or proposed == current: - continue - - # Ensure the proposed target ref actually exists (could be a branch or tag) - if not ref_exists(repo_id, proposed): - print( - f"Warning: Proposed ref '{proposed}' does not exist for {repo_id}. Skipping update." - ) - continue - - # Detect major bumps for compatibility notes - is_major_bump = False - current_v = numeric_version(current) - proposed_v = numeric_version(proposed) - if current_v and proposed_v and proposed_v[0] > current_v[0]: - is_major_bump = True - - replacements.append( - { - "old": match.group(0), - "new": f"{match.group(1)}{action_ref}@{proposed}", - "file": str(file_path.relative_to(ROOT)), - "action": action_ref, - "current": current, - "target": proposed, - "is_major_bump": is_major_bump, - } - ) + replacement = process_workflow_match(match, file_path, latest_cache) + if replacement: + replacements.append(replacement) if replacements: plans.append( {"path": file_path, "text": text, "replacements": replacements} @@ -259,10 +267,52 @@ def render_update_table(updates: list[dict[str, str]]) -> list[str]: return lines +def extract_major_bumps(updates: list[dict[str, Any]]) -> dict[str, tuple[str, str]]: + major_bumps = {} + for item in updates: + if item["is_major_bump"]: + major_bumps[item["action"]] = (item["current"], item["target"]) + return major_bumps + + +def build_pr_body(section: dict[str, Any], updates: list[dict[str, Any]]) -> str: + major_bumps = extract_major_bumps(updates) + notes = [ + "Security gate limited changes to allow-listed workflow paths.", + "No force-push or merge is performed by this automation.", + ] + pr_body = safe_pr_body( + section.get("pr_title", "Workflow update"), + updates, + notes, + ) + + if major_bumps: + pr_body += "\n### Compatibility review required\n" + for action, (current, target) in major_bumps.items(): + pr_body += f"- `{action}` major bump ({current} -> {target}): review inputs and syntax for breaking changes before merging\n" + + return pr_body + + +def apply_updates_and_create_pr( + section: dict[str, Any], plans: list[dict[str, Any]], updates: list[dict[str, Any]] +) -> str: + apply_workflow_updates(plans) + pr_body = build_pr_body(section, updates) + return create_pr_for_current_changes( + section.get("branch_prefix", "automation/workflow-updates"), + section.get("commit_message", "chore(actions): update workflow dependencies"), + section.get("pr_title", "chore(actions): update workflow dependencies"), + pr_body, + ) + + def run_workflow_updater(config: dict[str, Any]) -> dict[str, Any]: section = config.get("workflow_updater", {}) plans = workflow_file_plans() updates = flattened_updates(plans) + if not updates: body = "# Workflow updater\n\n- Status: **success**\n- Summary: No GitHub Action updates were detected.\n" return write_result( @@ -324,38 +374,7 @@ def run_workflow_updater(config: dict[str, Any]) -> dict[str, Any]: pr_url = "" try: - apply_workflow_updates(plans) - - # Check for major bumps that need human review - major_bumps = {} - for item in updates: - if item["is_major_bump"]: - major_bumps[item["action"]] = (item["current"], item["target"]) - - notes = [ - "Security gate limited changes to allow-listed workflow paths.", - "No force-push or merge is performed by this automation.", - ] - - pr_body = safe_pr_body( - section.get("pr_title", "Workflow update"), - updates, - notes, - ) - - if major_bumps: - pr_body += "\n### Compatibility review required\n" - for action, (current, target) in major_bumps.items(): - pr_body += f"- `{action}` major bump ({current} -> {target}): review inputs and syntax for breaking changes before merging\n" - - pr_url = create_pr_for_current_changes( - section.get("branch_prefix", "automation/workflow-updates"), - section.get( - "commit_message", "chore(actions): update workflow dependencies" - ), - section.get("pr_title", "chore(actions): update workflow dependencies"), - pr_body, - ) + pr_url = apply_updates_and_create_pr(section, plans, updates) status = "success" summary = ( f"Detected {len(updates)} workflow action updates and prepared a draft PR." @@ -365,6 +384,7 @@ def run_workflow_updater(config: dict[str, Any]) -> dict[str, Any]: restore_workflow_updates(plans) status = "failure" body_parts.extend(["## Draft PR failure", f"- {exc}", ""]) + return write_result( "workflow-updater", status, diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 16a8fd52..392f45f0 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -11,6 +11,6 @@ jobs: steps: - uses: actions/first-interaction@v3 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: "Message that will be displayed on users' first issue" - pr-message: "Message that will be displayed on users' first pull request" + repo_token: ${{ secrets.GITHUB_TOKEN }} + issue_message: "Message that will be displayed on users' first issue" + pr_message: "Message that will be displayed on users' first pull request" From 0205c5f0c323d27af9144d4bf262964f8a1347bc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:04:37 +0000 Subject: [PATCH 03/10] fix(ci): update actions/labeler config to match v5+ schema expectations Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/labeler.yml | 26 +++++++++++++------------- test.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 test.py diff --git a/.github/labeler.yml b/.github/labeler.yml index 08260b9a..cd0181d6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,22 +1,22 @@ # Label PRs based on changed file paths documentation: - - any: - - '**/*.md' + - changed-files: + - any-glob-to-any-file: '**/*.md' configuration: - - any: - - '**/*.yml' - - '**/*.yaml' - - '.github/**/*' + - changed-files: + - any-glob-to-any-file: '**/*.yml' + - any-glob-to-any-file: '**/*.yaml' + - any-glob-to-any-file: '.github/**/*' scripts: - - any: - - '**/*.sh' - - '**/*.bash' + - changed-files: + - any-glob-to-any-file: '**/*.sh' + - any-glob-to-any-file: '**/*.bash' python: - - any: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' + - changed-files: + - any-glob-to-any-file: '**/*.py' + - any-glob-to-any-file: 'pyproject.toml' + - any-glob-to-any-file: 'uv.lock' diff --git a/test.py b/test.py new file mode 100644 index 00000000..d96723d7 --- /dev/null +++ b/test.py @@ -0,0 +1,16 @@ +import yaml + +data1 = """ +documentation: + - any: + - '**/*.md' +""" + +data2 = """ +documentation: + - any: + - '**/*.md' +""" + +print("1:", yaml.safe_load(data1)) +print("2:", yaml.safe_load(data2)) From 980fa511b00f41bdc36f969882b2951efc3123d1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:17:51 +0000 Subject: [PATCH 04/10] refactor(automation): reduce complexity of report generators to fix CodeScene errors Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/labeler.yml | 13 +- .../scripts/repository_automation_tasks.py | 136 ++++++++++-------- 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index cd0181d6..cf1e2a97 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -2,21 +2,16 @@ documentation: - changed-files: - - any-glob-to-any-file: '**/*.md' + - any-glob-to-any-file: ['**/*.md'] configuration: - changed-files: - - any-glob-to-any-file: '**/*.yml' - - any-glob-to-any-file: '**/*.yaml' - - any-glob-to-any-file: '.github/**/*' + - any-glob-to-any-file: ['**/*.yml', '**/*.yaml', '.github/**/*'] scripts: - changed-files: - - any-glob-to-any-file: '**/*.sh' - - any-glob-to-any-file: '**/*.bash' + - any-glob-to-any-file: ['**/*.sh', '**/*.bash'] python: - changed-files: - - any-glob-to-any-file: '**/*.py' - - any-glob-to-any-file: 'pyproject.toml' - - any-glob-to-any-file: 'uv.lock' + - any-glob-to-any-file: ['**/*.py', 'pyproject.toml', 'uv.lock'] diff --git a/.github/scripts/repository_automation_tasks.py b/.github/scripts/repository_automation_tasks.py index 397cabd2..1bac0aa8 100644 --- a/.github/scripts/repository_automation_tasks.py +++ b/.github/scripts/repository_automation_tasks.py @@ -475,45 +475,51 @@ def render_pr_rows(prs: list[dict[str, Any]]) -> list[str]: return rows -def run_backlog_manager(config: dict[str, Any]) -> dict[str, Any]: - section = config.get("backlog_manager", {}) - max_issues = int(section.get("max_issues", 10)) - max_prs = int(section.get("max_pull_requests", 10)) - issues = gh_json( - [ - "issue", - "list", - "--state", - "open", - "--limit", - str(max_issues), - "--json", - "number,title,updatedAt,url,labels", - ], - default=[], - ) - prs = gh_json( +def fetch_sorted_items(item_type: str, limit: int, fields: str) -> list[dict[str, Any]]: + items = gh_json( [ - "pr", + item_type, "list", "--state", "open", "--limit", - str(max_prs), + str(limit), "--json", - "number,title,updatedAt,url,isDraft,reviewDecision,mergeStateStatus", + fields, ], default=[], ) - issues = sorted(issues, key=lambda item: item.get("updatedAt", "")) - prs = sorted(prs, key=lambda item: item.get("updatedAt", "")) + return sorted(items, key=lambda item: item.get("updatedAt", "")) + + +def render_stale_candidates(stale_items: list[dict[str, Any]], noun: str) -> list[str]: + return [ + f"- {noun} #{item['number']} has been quiet for {age_days(item['updatedAt'])} days: {item['title']}" + for item in stale_items + ] + + +def run_backlog_manager(config: dict[str, Any]) -> dict[str, Any]: + section = config.get("backlog_manager", {}) stale_days = int(section.get("stale_days", 14)) + + issues = fetch_sorted_items( + "issue", int(section.get("max_issues", 10)), "number,title,updatedAt,url,labels" + ) + prs = fetch_sorted_items( + "pr", + int(section.get("max_pull_requests", 10)), + "number,title,updatedAt,url,isDraft,reviewDecision,mergeStateStatus", + ) + stale_issues = [ item for item in issues if age_days(item["updatedAt"]) >= stale_days ] stale_prs = [item for item in prs if age_days(item["updatedAt"]) >= stale_days] + status = "warning" if stale_issues or stale_prs else "success" summary = f"Backlog scan found {len(issues)} open issues and {len(prs)} open PRs in the sampled set." + lines = [ "# Backlog manager", "", @@ -524,20 +530,12 @@ def run_backlog_manager(config: dict[str, Any]) -> dict[str, Any]: ] lines.extend(render_issue_rows(issues)) lines.extend(render_pr_rows(prs)) + if stale_issues or stale_prs: lines.extend(["", "## Human review candidates"]) - lines.extend( - [ - f"- Issue #{item['number']} has been quiet for {age_days(item['updatedAt'])} days: {item['title']}" - for item in stale_issues - ] - ) - lines.extend( - [ - f"- PR #{item['number']} has been quiet for {age_days(item['updatedAt'])} days: {item['title']}" - for item in stale_prs - ] - ) + lines.extend(render_stale_candidates(stale_issues, "Issue")) + lines.extend(render_stale_candidates(stale_prs, "PR")) + return write_result( "backlog-manager", status, @@ -583,6 +581,42 @@ def status_icon(status: str) -> str: }.get(status, status.upper()) +def render_daily_releases(releases: list[dict[str, Any]]) -> list[str]: + lines = ["", "## Recent releases"] + if releases: + for release in releases: + name = release.get("name") or release.get("tagName") or "Unnamed release" + tag_name = release.get("tagName") or "" + url = release_url(tag_name) + rendered_name = f"[{name}]({url})" if url else name + lines.append( + f"- {rendered_name} published {release.get('publishedAt', '')[:10]}" + ) + else: + lines.append("- No recent releases returned by the API.") + return lines + + +def render_daily_recommendations( + overall: str, results: list[dict[str, Any]] +) -> list[str]: + lines = ["", "## Recommendations"] + if overall == "success": + lines.append( + "- No blocking findings. Review the status report issue and any workflow-updater draft PR before merging." + ) + else: + lines.append( + "- Review the failing or warning tasks before trusting any generated changes." + ) + + if any(item.get("status") in {"failure", "needs_review"} for item in results): + lines.append( + "- Human review is required for at least one task; no silent automation escalation was performed." + ) + return lines + + def daily_report_lines( config: dict[str, Any], results: list[dict[str, Any]] ) -> list[str]: @@ -598,7 +632,9 @@ def daily_report_lines( ["release", "list", "--limit", "5", "--json", "name,publishedAt,tagName"], default=[], ) + overall = overall_status(results) + lines = [ f"# Daily Repository Automation Report - {iso_day()}", "", @@ -616,34 +652,14 @@ def daily_report_lines( for item in results ] ) - lines.extend(["", "## Recent releases"]) - if releases: - for release in releases: - name = release.get("name") or release.get("tagName") or "Unnamed release" - tag_name = release.get("tagName") or "" - url = release_url(tag_name) - rendered_name = f"[{name}]({url})" if url else name - lines.append( - f"- {rendered_name} published {release.get('publishedAt', '')[:10]}" - ) - else: - lines.append("- No recent releases returned by the API.") - lines.extend(["", "## Recommendations"]) - if overall == "success": - lines.append( - "- No blocking findings. Review the status report issue and any workflow-updater draft PR before merging." - ) - else: - lines.append( - "- Review the failing or warning tasks before trusting any generated changes." - ) - if any(item.get("status") in {"failure", "needs_review"} for item in results): - lines.append( - "- Human review is required for at least one task; no silent automation escalation was performed." - ) + + lines.extend(render_daily_releases(releases)) + lines.extend(render_daily_recommendations(overall, results)) + lines.extend(["", "", ""]) + return lines From c718538c20396b742f193676a5e1cf0df223e2f6 Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Mon, 23 Mar 2026 17:21:25 -0500 Subject: [PATCH 05/10] Update repository_automation_common.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/scripts/repository_automation_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/repository_automation_common.py b/.github/scripts/repository_automation_common.py index 32f07dcf..21b53ba2 100644 --- a/.github/scripts/repository_automation_common.py +++ b/.github/scripts/repository_automation_common.py @@ -373,7 +373,7 @@ def latest_tag_for_action(repo_id: str) -> str: "api", f"repos/{repo_id}/releases", "--jq", - "[.[] | select(.prerelease == false)] | .[0].tag_name", + "[.[] | select(.prerelease == false) | .tag_name] | .[0]", ], default=None, ) From 2adf0fc201556b3c73f40a655d903e7451f1513d Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Mon, 23 Mar 2026 17:21:31 -0500 Subject: [PATCH 06/10] Update repository_automation_common.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .github/scripts/repository_automation_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/repository_automation_common.py b/.github/scripts/repository_automation_common.py index 21b53ba2..0861b9d1 100644 --- a/.github/scripts/repository_automation_common.py +++ b/.github/scripts/repository_automation_common.py @@ -381,7 +381,7 @@ def latest_tag_for_action(repo_id: str) -> str: return releases # Fallback 2: Get the most recent tag (which may not be associated with a release object) - return gh_text(["api", f"repos/{repo_id}/tags?per_page=1", "--jq", ".[0].name"]) + return gh_text(["api", f"repos/{repo_id}/tags?per_page=1", "--jq", "[.[] | .name] | .[0]"]) def ref_exists(repo_id: str, ref: str) -> bool: From 9684faeea6ad6f01234d2506a515d46979b081a2 Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Tue, 24 Mar 2026 21:10:42 +0000 Subject: [PATCH 07/10] =?UTF-8?q?fix(review):=20restore=20labeler.yml=20fo?= =?UTF-8?q?rmat=20for=20actions/labeler@v6=20=E2=80=94=20automated=20PR=20?= =?UTF-8?q?review=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/labeler.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index cf1e2a97..6f2ee22a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,17 +1,18 @@ # Label PRs based on changed file paths documentation: - - changed-files: - - any-glob-to-any-file: ['**/*.md'] + - '**/*.md' configuration: - - changed-files: - - any-glob-to-any-file: ['**/*.yml', '**/*.yaml', '.github/**/*'] + - '**/*.yml' + - '**/*.yaml' + - '.github/**/*' scripts: - - changed-files: - - any-glob-to-any-file: ['**/*.sh', '**/*.bash'] + - '**/*.sh' + - '**/*.bash' python: - - changed-files: - - any-glob-to-any-file: ['**/*.py', 'pyproject.toml', 'uv.lock'] + - '**/*.py' + - 'pyproject.toml' + - 'uv.lock' From 3188789584ac3b25571cdc28a210b90889fa4d43 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:14:22 +0000 Subject: [PATCH 08/10] fix(ci): update actions/labeler config to match v5+ schema expectations Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/labeler.yml | 17 ++++++++--------- .github/scripts/repository_automation_common.py | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 6f2ee22a..cf1e2a97 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,18 +1,17 @@ # Label PRs based on changed file paths documentation: - - '**/*.md' + - changed-files: + - any-glob-to-any-file: ['**/*.md'] configuration: - - '**/*.yml' - - '**/*.yaml' - - '.github/**/*' + - changed-files: + - any-glob-to-any-file: ['**/*.yml', '**/*.yaml', '.github/**/*'] scripts: - - '**/*.sh' - - '**/*.bash' + - changed-files: + - any-glob-to-any-file: ['**/*.sh', '**/*.bash'] python: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' + - changed-files: + - any-glob-to-any-file: ['**/*.py', 'pyproject.toml', 'uv.lock'] diff --git a/.github/scripts/repository_automation_common.py b/.github/scripts/repository_automation_common.py index 0861b9d1..32f07dcf 100644 --- a/.github/scripts/repository_automation_common.py +++ b/.github/scripts/repository_automation_common.py @@ -373,7 +373,7 @@ def latest_tag_for_action(repo_id: str) -> str: "api", f"repos/{repo_id}/releases", "--jq", - "[.[] | select(.prerelease == false) | .tag_name] | .[0]", + "[.[] | select(.prerelease == false)] | .[0].tag_name", ], default=None, ) @@ -381,7 +381,7 @@ def latest_tag_for_action(repo_id: str) -> str: return releases # Fallback 2: Get the most recent tag (which may not be associated with a release object) - return gh_text(["api", f"repos/{repo_id}/tags?per_page=1", "--jq", "[.[] | .name] | .[0]"]) + return gh_text(["api", f"repos/{repo_id}/tags?per_page=1", "--jq", ".[0].name"]) def ref_exists(repo_id: str, ref: str) -> bool: From d2f98091bae152c64ee74ff8cd266a3477dd71bc Mon Sep 17 00:00:00 2001 From: Abhi Mehrotra Date: Tue, 24 Mar 2026 21:18:26 +0000 Subject: [PATCH 09/10] =?UTF-8?q?fix(review):=20sync=20labeler.yml=20with?= =?UTF-8?q?=20main=20(v6-compatible)=20=E2=80=94=20automated=20PR=20review?= =?UTF-8?q?=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/labeler.yml | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index cf1e2a97..09cdea43 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,17 +1,24 @@ -# Label PRs based on changed file paths - +# Label PRs based on changed file paths (actions/labeler@v6 format) documentation: - - changed-files: - - any-glob-to-any-file: ['**/*.md'] +- changed-files: + - any-glob-to-any-file: '**/*.md' configuration: - - changed-files: - - any-glob-to-any-file: ['**/*.yml', '**/*.yaml', '.github/**/*'] +- changed-files: + - any-glob-to-any-file: + - '**/*.yml' + - '**/*.yaml' + - '.github/**/*' scripts: - - changed-files: - - any-glob-to-any-file: ['**/*.sh', '**/*.bash'] +- changed-files: + - any-glob-to-any-file: + - '**/*.sh' + - '**/*.bash' python: - - changed-files: - - any-glob-to-any-file: ['**/*.py', 'pyproject.toml', 'uv.lock'] +- changed-files: + - any-glob-to-any-file: + - '**/*.py' + - 'pyproject.toml' + - 'uv.lock' From 2ad7c97a5efb198fe4e582f7522c6d7f58f4d5ac Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:26:50 +0000 Subject: [PATCH 10/10] fix(automation): handle pull request comments Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com> --- .github/labeler.yml | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 09cdea43..cf1e2a97 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,24 +1,17 @@ -# Label PRs based on changed file paths (actions/labeler@v6 format) +# Label PRs based on changed file paths + documentation: -- changed-files: - - any-glob-to-any-file: '**/*.md' + - changed-files: + - any-glob-to-any-file: ['**/*.md'] configuration: -- changed-files: - - any-glob-to-any-file: - - '**/*.yml' - - '**/*.yaml' - - '.github/**/*' + - changed-files: + - any-glob-to-any-file: ['**/*.yml', '**/*.yaml', '.github/**/*'] scripts: -- changed-files: - - any-glob-to-any-file: - - '**/*.sh' - - '**/*.bash' + - changed-files: + - any-glob-to-any-file: ['**/*.sh', '**/*.bash'] python: -- changed-files: - - any-glob-to-any-file: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' + - changed-files: + - any-glob-to-any-file: ['**/*.py', 'pyproject.toml', 'uv.lock']